Signals
š The goal of this library is to provide a lightweight reactivity API for other UI libraries to
be built on top of. It follows the "lazy principle" that Svelte adheres to - don't
do any unnecessary work and don't place the burden of figuring it out on the developer.
This is a tiny (~850B minzipped) library for creating reactive observables via functions called
signals. You can use signals to store state, create computed properties (y = mx + b
), and subscribe
to updates as its value changes.
- šŖ¶ Light (~850B minzipped)
- š½ Works in both browsers and Node.js
- š All types are observable (i.e., string, array, object, etc.)
- šµļøāāļø Only updates when value has changed
- ā±ļø Batched updates via microtask scheduler
- š“ Lazy by default - efficiently re-computes only what's needed
- š¬ Computations via
computed
- š Effect subscriptions via
effect
- ā»ļø Detects cyclic dependencies
- š Debugging identifiers
- šŖ Strongly typed - built with TypeScript
āļø Skip to API
āļø Skip to TypeScript
āļø Skip to Benchmarks
Here's a simple demo to see how it works:
import { root, signal, computed, effect, tick } from '@maverick-js/signals';
root((dispose) => {
const $m = signal(1);
const $x = signal(1);
const $b = signal(0);
const $y = computed(() => $m() * $x() + $b());
const stop = effect(() => {
console.log($y());
return () => {};
});
$m.set(10);
tick();
$b.next((prev) => prev + 5);
tick();
$y();
stop();
dispose();
});
Export Sizes
- Average: ~850B (brotli)
- Without Computed Maps: ~1KB (brotli)
- Total: ~1.5KB (brotli)
You can also check out the library size on Bundlephobia (less accurate).
Note
Maverick Signals is treeshakable and side-effect free so you'll only end up with what you use.
Installation
$: npm i @maverick-js/signals
$: pnpm i @maverick-js/signals
$: yarn add @maverick-js/signals
API
root
Computations are generally child computations. When their respective parent scope is destroyed so
are they. You can create orphan computations (i.e., no parent). Orphans will live in memory until
their internal object references are garbage collected (GC) (i.e., dropped from memory):
import { computed } from '@maverick-js/signals';
const obj = {};
const $b = computed(() => obj);
Orphans can make it hard to determine when a computation is disposed so you'll generally want to
ensure you only create child computations. The root
function stores all inner computations as
a child and provides a function to easily dispose of them all:
import { root, signal, computed, effect } from '@maverick-js/signals';
root((dispose) => {
const $a = signal(10);
const $b = computed(() => $a());
effect(() => console.log($b()));
dispose();
});
const result = root(() => 10);
console.log(result);
signal
Wraps the given value into a signal. The signal will return the current value when invoked fn()
,
and provide a simple write API via set()
and next()
. The value can now be observed when used
inside other computations created with computed
and effect
.
import { signal } from '@maverick-js/signals';
const $a = signal(10);
$a();
$a.set(20);
$a.next((prev) => prev + 10);
Warning
Read the tick
section below to understand batched updates.
computed
Creates a new signal whose value is computed and returned by the given function. The given
compute function is only re-run when one of it's dependencies are updated. Dependencies are
are all signals that are read during execution.
import { signal, computed, tick } from '@maverick-js/signals';
const $a = signal(10);
const $b = signal(10);
const $c = computed(() => $a() + $b());
console.log($c());
$a.set(20);
tick();
console.log($c());
$b.set(20);
tick();
console.log($c());
console.log($c());
import { signal, computed } from '@maverick-js/signals';
const $a = signal(10);
const $b = signal(10);
const $c = computed(() => $a() + $b());
const $d = computed(() => $a() + $b() + $c());
const $e = computed(() => $d());
effect
Invokes the given function each time any of the signals that are read inside are updated
(i.e., their value changes). The effect is immediately invoked on initialization.
import { signal, computed, effect } from '@maverick-js/signals';
const $a = signal(10);
const $b = signal(20);
const $c = computed(() => $a() + $b());
const stop = effect(() => console.log($c()));
stop();
You can optionally return a function from inside the effect
that will be run each time the
effect re-runs and when it's finally stopped/disposed of:
effect(() => {
return () => {
};
});
peek
Returns the current value stored inside the given compute function without triggering any
dependencies. Use untrack
if you want to also disable scope tracking.
import { signal, computed, peek } from '@maverick-js/signals';
const $a = signal(10);
const $b = computed(() => {
const value = peek($a);
});
untrack
Returns the current value inside a signal whilst disabling both scope and observer
tracking. Use peek
if only observer tracking should be disabled.
import { signal, effect, untrack } from '@maverick-js/signals';
effect(() => {
untrack(() => {
const $a = signal(10);
});
});
readonly
Takes in the given signal and makes it read only by removing access to write operations (i.e.,
set()
and next()
).
import { signal, readonly } from '@maverick-js/signals';
const $a = signal(10);
const $b = readonly($a);
console.log($b());
$a.set(20);
console.log($b());
tick
By default, signal updates are batched on the microtask queue which is an async process. You can
flush the queue synchronously to get the latest updates by calling tick()
.
Note
You can read more about microtasks on MDN.
import { signal } from '@maverick-js/signals';
const $a = signal(10);
$a.set(10);
$a.set(20);
$a.set(30);
import { signal, tick } from '@maverick-js/signals';
const $a = signal(10);
$a.set(10);
tick();
$a.set(20);
tick();
$a.set(30);
computedMap
Note
Same implementation as indexArray
in Solid JS.
Prefer computedKeyedMap
when referential checks are required.
Reactive map helper that caches each item by index to reduce unnecessary mapping on updates.
It only runs the mapping function once per item and adds/removes as needed. In a non-keyed map like
this the index is fixed but value can change (opposite of a keyed map).
import { signal, tick, computedMap } from '@maverick-js/signals';
const source = signal([1, 2, 3]);
const map = computedMap(source, (value, index) => {
return {
i: index,
get id() {
return value() * 2;
},
};
});
console.log(map());
source.set([3, 2, 1]);
tick();
console.log(map());
computedKeyedMap
Note
Same implementation as mapArray
in Solid JS.
Prefer computedMap
when working with primitives to avoid unnecessary re-renders.
Reactive map helper that caches each list item by reference to reduce unnecessary mapping on
updates. It only runs the mapping function once per item and then moves or removes it as needed. In
a keyed map like this the value is fixed but the index changes (opposite of non-keyed map).
import { signal, tick, computedKeyedMap } from '@maverick-js/signals';
const source = signal([{ id: 0 }, { id: 1 }, { id: 2 }]);
const nodes = computedKeyedMap(source, (value, index) => {
const div = document.createElement('div');
div.setAttribute('id', String(value.id));
Object.defineProperty(div, 'i', {
get() {
return index();
},
});
return div;
});
console.log(nodes());
source.next((prev) => {
const tmp = prev[1];
prev[1] = prev[0];
prev[0] = tmp;
return [...prev];
});
tick();
console.log(nodes());
dispose
Unsubscribes the given signal and all inner child computations. Disposed functions will retain
their current value but are no longer reactive.
import { signal, dispose } from '@maverick-js/signals';
const $a = signal(10);
const $b = computed(() => $a());
dispose($a);
$a.set(100);
console.log($b());
onError
Runs the given function when an error is thrown in a child scope. If the error is thrown again
inside the error handler, it will trigger the next available parent scope handler.
import { effect, onError } from '@maverick-js/signals';
effect(() => {
onError((error) => {
});
});
onDispose
Runs the given function when the parent scope computation is being disposed of.
import { effect, onDispose } from '@maverick-js/signals';
const listen = (type, callback) => {
window.addEventListener(type, callback);
onDispose(() => window.removeEventListener(type, callback));
};
const stop = effect(
listen('click', () => {
}),
);
stop();
The onDispose
callback will return a function to clear the disposal early if it's no longer
required:
effect(() => {
const dispose = onDispose(() => {});
dispose();
});
isReadSignal
Whether the given value is a readonly signal.
isReadSignal(10);
isReadSignal(computed(() => 10));
isReadSignal(readonly(signal(10)));
isReadSignal(false);
isReadSignal(null);
isReadSignal(undefined);
isReadSignal(() => {});
isWriteSignal
Whether the given value is a write signal (i.e., can produce new values via write API).
isWriteSignal(signal(10));
isWriteSignal(false);
isWriteSignal(null);
isWriteSignal(undefined);
isWriteSignal(() => {});
isWriteSignal(computed(() => 10));
isWriteSignal(readonly(signal(10)));
scope
Scopes the given function to the current parent scope so context and error handling continue to
work as expected. Generally this should be called on non-signal functions. A scoped function will
return undefined
if an error is thrown.
This is more compute and memory efficient than the alternative effect(() => peek(callback))
because it doesn't require creating and tracking a computed
signal.
import { root, scope } from '@maverick-js/signals';
let callback;
root(() => {
callback = scope(() => {
});
});
callback();
getScope
Returns the owning scope of the given function. If no function is given it'll return the
currently executing parent scope. You can use this to walk up the computation tree.
root(() => {
effect(() => {
const $a = signal(0);
getScope($a);
getScope(getScope());
});
getScope();
});
getContext
Attempts to get a context value for the given key. It will start from the parent scope and
walk up the computation tree trying to find a context record and matching key. If no value can be
found undefined
will be returned. This is intentionally low-level so you can design a context API
in your library as desired.
In your implementation make sure to check if a parent scope exists via getScope()
. If one does
not exist log a warning that this function should not be called outside a computation or render
function.
Note
See the setContext
code example below for a demo of this function.
setContext
Attempts to set a context value on the parent scope with the given key. This will be a no-op if
no parent scope is defined. This is intentionally low-level so you can design a context API in your
library as desired.
In your implementation make sure to check if a parent scope exists via getScope()
. If one does
not exist log a warning that this function should not be called outside a computation or render
function.
import { root, getContext, setContext } from '@maverick-js/signals';
const key = Symbol();
root(() => {
setContext(key, 100);
root(() => {
const value = getContext(key);
});
});
getScheduler
Returns the global scheduler which can be used to queue additional tasks or synchronously flush
the queue.
const scheduler = getScheduler();
scheduler.enqueue(() => {
});
scheduler.syncFlush();
Note
See our Scheduler repo for more information.
Debugging
The signal
, computed
, and effect
functions accept a debugging ID (string) as part
of their options. This can be helpful when logging a cyclic dependency chain to understand
where it's occurring.
import { signal, computed } from '@maverick-js/signals';
const $a = signal(10, { id: 'a' });
const $b = computed(() => $a() + $c(), { id: 'b' });
const $c = computed(() => $a() + $b(), { id: 'c' });
Note
This feature is only available in a development or testing Node environment (i.e., NODE_ENV
).
TypeScript
import {
isReadSignal,
isWriteSignal,
type Effect,
type ReadSignal,
type WriteSignal,
type MaybeSignal,
} from '@maverick-js/signals';
const signal: ReadSignal<number>;
const computed: ReadSignal<string>;
const effect: Effect;
const $a = computed<string>(() => );
const $b: MaybeSignal<number>;
if (isReadSignal($b)) {
$b();
}
if (isWriteSignal($b)) {
$b.set(10);
}
Benchmarks
Layers
This benchmark was taken from cellx
. It
tests how long it takes for an n
deeply layered computation to update. The benchmark can be
found here.
Each column represents how deep computations were layered. The average time taken to update the
computation out of a 100 runs is used for each library.
Sync
Batched
Notes
- Only Maverick and Solid JS are feature complete below which includes nested effects, arbritrary
node disposal, context, and error handling.
- Nearly all computations in a real world app are going to be less than 10 layers deep, so only the
first column really matters. What this benchmark is really showing is how notification propagation
scales with computation depth.
Reactively
This benchmark was taken from reactively
. It sets
up various computation graphs with a set number of sources (e.g., 1000x5
is 1000 computations with
a tree depth of 5). The benchmark measures how long it takes for changes to be applied after static
or dynamic updates are made to the graph (i.e., pick a node and update its value).
Notes
- This is not an apples to apples comparison. Reactively and Preact Signals are not feature
complete as they currently don't support scope tracking, nested effects, context, error handling,
and arbritray subtree disposal. This means their overall tracking logic is simplified. You can
safely apply a ~10-15% penalty to their scores (do note we haven't applied it here).
- This assumes Solid JS is in batch-only mode which is not realistic as a real world app won't
have batch applied everywhere.
- Preact Signals is reporting unusually slow numbers for the Wide Dense and Large Web App charts
which may be the result of a bug or something to do with how they've modelled the computation
graph. Issue is being tracked here.
- Only Maverick uses a
Set
to track observers/dependencies. This means multiple observer calls in
other libraries will result in an edge being created every time a signal is called. This is one of
the reasons why Maverick does consistenly well across small and large data sets.
Inspiration
@maverick-js/signals
was made possible based on my learnings from:
Special thanks to Wesley, Julien, and Solid/Svelte contributors for all their work š