npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

quel

v0.3.7

Published

Expression-based reactive library for hot listenables

Downloads

431

Readme

npm package minimized gzipped size) types version GitHub Workflow Status

Reactive Expressions for JavaScript

npm i quel

quel is a tiny library for reactive programming in JavaScript. You can use it to write applications that react to user interactions, events, timers, web sockets, etc. using plain JavaScript expressions and functions.

import { from, observe } from 'quel'


const div$ = document.querySelector('div')

// 👇 this is a source of change, as value of the input changes
const input = from(document.querySelector('textarea'))

// 👇 these are computed values based on that source
const chars = $ => $(input)?.length ?? 0
const words = $ => $(input)?.split(' ').length ?? 0

// 👇 this is a side effect executed when the computed values change
observe($ => div$.textContent = `${$(chars)} chars, ${$(words)} words`)

▷ TRY IT

quel focuses on simplicity and composability. Even for more complex use cases (such as higher-order reactive sources, bouncing events, etc.) it relies on native JavaScript features such async functions and combination, instead of operators, hooks, or other custom abstractions.

//
// this code creates a timer whose rate changes
// based on the value of an input
//

import { from, observe, Timer } from 'quel'


const div$ = document.querySelector('div')
const input = from(document.querySelector('input'))
const rate = $ => parseInt($(input) ?? 100)

//
// 👇 as the rate of the timer changes, so does the timer itself.
//    with a constant rate, the timer would be a simple source of change,
//    with changing rates, the timer becomes a "higher-order" source.
//
const timer = async $ => {
  await sleep(200)
  return $(rate) && new Timer($(rate))
}

observe($ => {
  //
  // 👇 `$(timer)` would yield the latest timer, 
  //     and `$($(timer))` would yield the latest
  //     value of that timer, which is what we want to display.
  //
  const elapsed = $($(timer)) ?? '-'
  div$.textContent = `elapsed: ${elapsed}`
})

▷ TRY IT

Contents

Installation

On node:

npm i quel

On browser (or deno):

import { from, observe } from 'https://esm.sh/quel'

Usage

Working with quel involves four steps:

  1. Encapsulate (or create) sources of change,
  2. Process and combine the these changing values using functions & expressions,
  3. Observe these changing values and react to them (or iterate over them),
  4. Clean up the sources, releasing resources (e.g. stop a timer, remove an event listener, cloe a socket, etc.).

Sources

Create a subject (whose value you can manually set at any time):

import { Subject } from 'quel'

const a = new Subject()
a.set(2)

Create a timer:

import { Timer } from 'quel'

const timer = new Timer(1000)

Create an event source:

import { from } from 'quel'

const click = from(document.querySelector('button'))
const hover = from(document.querySelector('button'), 'hover')
const input = from(document.querySelector('input'))

Create a custom source:

import { Source } from 'quel'

const src = new Source(async emit => {
  await sleep(1000)
  emit('Hellow World!')
})

Read latest value of a source:

src.get()

Stop a source:

src.stop()

Wait for a source to be stopped:

await src.stops()

In runtimes supporting using keyword (see proposal), you can safely subscribe to a source:

using sub = src.subscribe(value => ...)

Currently TypeScript 5.2 or later supports using keyword.

Expressions

Combine two sources using simple expression functions:

const sum = $ => $(a) + $(b)

Filter values:

import { SKIP } from 'quel'

const odd = $ => $(a) % 2 === 0 ? SKIP : $(a)

Expressions can be async:

const response = async $ => {
  await sleep(200)
  
  if ($(query)) {
    try {
      const res = await fetch('https://pokeapi.co/api/v2/pokemon/' + $(query))
      const json = await res.json()

      return JSON.stringify(json, null, 2)
    } catch {
      return 'Could not find Pokemon'
    }
  }
}

▷ TRY IT

Flatten higher-order sources:

const variableTimer = $ => new Timer($(input))
const message = $ => 'elapsed: ' + $($(timer))

Stop the expression:

import { STOP } from 'quel'

let count = 0
const take5 = $ => {
  if (count++ > 5) return STOP

  return $(src)
}

ℹ️ IMPORTANT

The $ function, passed to expressions, tracks and returns the latest value of a given source. Expressions are then re-evaluated every time a change in some tracked source results in some new value. This means that the sources you track must remain the same when the expression is re-evaluated.

DO NOT create sources you want to track inside an expression:

// 👇 this is WRONG ❌
const computed = $ => $(new Timer(1000)) * 2
// 👇 this is CORRECT ✅
const timer = new Timer(1000)
const computed = $ => $(timer) * 2

You CAN create new sources inside an expression and return them (without tracking) them, creating a higher-order source:

//
// this is OK ✅
// `timer` is a source of changing timers, 
// who themselves are a source of changing numbers.
//
const timer = $ => new Timer($(rate))
//
// this is OK ✅
// `$(timer)` returns the latest timer as long as a new timer
// is not created (in response to a change in `rate`), so this
// expression is re-evaluated only when it needs to.
//
const msg = $ => 'elapsed: ' + $($(timer))

Observation

Run side effects:

import { observe } from 'quel'

observe($ => console.log($(message)))

Observations are sources themselves:

const y = observe($ => $(x) * 2)
console.log(y.get())

Async expressions might get aborted mid-execution. You can handle those events by passing a second argument to observe():

let ctrl = new AbortController()

const data = observe(async $ => {
  await sleep(200)
  
  // 👇 pass abort controller signal to fetch to cancel mid-flight requests
  const res = await fetch('https://my.api/?q=' + $(input), {
    signal: ctrl.signal
  })

  return await res.json()
}, () => {
  ctrl.abort()
  ctrl = new AbortController()
})

Iteration

Iterate on values of a source using iterate():

import { iterate } from 'quel'

for await (const i of iterate(src)) {
  // do something with it
}

If the source emits values faster than you consume them, you are going to miss out on them:

const timer = new Timer(500)

// 👇 loop body is slower than the source. values will be lost!
for await (const i of iterate(timer)) {
  await sleep(1000)
  console.log(i)
}

▷ TRY IT

Cleanup

Expressions cleanup automatically when all their tracked sources are stopped. They also lazy-check if all previously tracked sources are still being tracked when they emit (or they stop) to do proper cleanup.

You need to manually clean up sources:

const timer = new Timer(1000)
const effect = observe($ => console.log($(timer)))

// 👇 this stops the timer and the effect
timer.stop()

// 👇 this just stops the side-effect, the timer keeps going.
effect.stop()

Custom sources can return a cleanup function:

const myTimer = new Source(emit => {
  let i = 0
  const interval = setInterval(() => emit(++i), 1000)
  
  // 👇 clear the interval when the source is stopped
  return () => clearInterval(interval)
})

Or use a callback to register the cleanup code:

// 👇 with async producers, use a callback to specify cleanup code
const asyncTimer = new Source(async (emit, finalize) => {
  let i = 0
  let stopped = false
  
  finalize(() => stopped = true)
  
  while (!stopped) {
    emit(++i)
    await sleep(1000)
  }
})

In runtimes supporting using keyword (see proposal), you can safely create sources without manually cleaning them up:

using timer = new Timer(1000)

Currently TypeScript 5.2 or later supports using keyword.

Typing

TypeScript wouldn't be able to infer proper types for expressions. To resolve this issue, use Track type:

import { Track } from 'quel'

const expr = ($: Track) => $(a) * 2

👉 Check this for more useful types.

Features

🧩 quel has a minimal API surface (the whole package is ~1.3KB), and relies on composability instead of providng tons of operators / helper methods:

// combine two sources:
$ => $(a) + $(b)
// debounce:
async $ => {
  await sleep(1000)
  return $(src)
}
// flatten (e.g. switchMap):
$ => $($(src))
// filter a source
$ => $(src) % 2 === 0 ? $(src) : SKIP
// take until other source emits a value
$ => !$(notifier) ? $(src) : STOP
// batch emissions
async $ => (await Promise.resolve(), $(src))
// batch with animation frames
async $ => {
  await Promise(resolve => requestAnimationFrame(resolve))
  return $(src)
}
// merge sources
new Source(emit => {
  const obs = sources.map(src => observe($ => emit($(src))))
  return () => obs.forEach(ob => ob.stop())
})
// throttle
let timeout = null
  
$ => {
  const value = $(src)
  if (timeout === null) {
    timeout = setTimeout(() => timeout = null, 1000)
    return value
  } else {
    return SKIP
  }
}

🛂 quel is imperative (unlike most other general-purpose reactive programming libraries such as RxJS, which are functional), resulting in code that is easier to read, write and debug:

import { interval, map, filter } from 'rxjs'

const a = interval(1000)
const b = interval(500)

combineLatest(a, b).pipe(
  map(([x, y]) => x + y),
  filter(x => x % 2 === 0),
).subscribe(console.log)
import { Timer, observe } from 'quel'

const a = new Timer(1000)
const b = new Timer(500)

observe($ => {
  const sum = $(a) + $(b)
  if (sum % 2 === 0) {
    console.log(sum)
  }
})

quel is as fast as RxJS. Note that in most cases performance is not the primary concern when programming reactive applications (since you are handling async events). If performance is critical for your use case, I'd recommend using likes of xstream or streamlets, as the imperative style of quel does tax a performance penalty inevitably compared to the fastest possible implementation.

🧠 quel is more memory-intensive than RxJS. Similar to the unavoidable performance tax, tracking sources of an expression will use more memory compared to explicitly tracking and specifying them.

quel only supports hot listenables. Certain use cases would benefit (for example, in terms of performance) from using cold listenables, or from having hybrid pull-push primitives. However, most common event sources (user events, timers, Web Sockets, etc.) are hot listenables, and quel does indeed use the limited scope for simplification and optimization of its code.

Related Work

Contribution

You need node, NPM to start and git to start.

# clone the code
git clone [email protected]:loreanvictor/quel.git
# install stuff
npm i

Make sure all checks are successful on your PRs. This includes all tests passing, high code coverage, correct typings and abiding all the linting rules. The code is typed with TypeScript, Jest is used for testing and coverage reports, ESLint and TypeScript ESLint are used for linting. Subsequently, IDE integrations for TypeScript and ESLint would make your life much easier (for example, VSCode supports TypeScript out of the box and has this nice ESLint plugin), but you could also use the following commands:

# run tests
npm test
# check code coverage
npm run coverage
# run linter
npm run lint
# run type checker
npm run typecheck

You can also use the following commands to run performance benchmarks:

# run all benchmarks
npm run bench
# run performance benchmarks
npm run bench:perf
# run memory benchmarks
npm run bench:mem