firsthand

Reactivity

Index · Previous: Getting started · Next: Components


Three primitives, and one rule about when the DOM changes.

import { signal, computed, effect, batch, untrack } from '@firsthandjs/dom';

Signals

A signal is a value that remembers who read it.

const count = signal(0);

count.value; // read — and subscribe, if something is watching
count.value = 1; // write
count.peek(); // read without subscribing
count.set((n) => n + 1); // update from the previous value

A write that does not change the value does nothing at all — Object.is by default:

count.value = 1;
count.value = 1; // nothing downstream runs

Pass your own comparison when that default is wrong:

const point = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });
const always = signal(0, { equals: false }); // notify on every write

A signal is not deep

A signal holds a value; it does not watch inside it.

const user = signal({ name: 'Ada' });
user.value.name = 'Grace'; // nothing happens
user.value = { ...user.value, name: 'Grace' }; // this is the write

That is deliberate rather than missing: a proxy that watches every property allocates one per object and intercepts every access, and signal is the thing that has to stay cheap. What you get in exchange is that a write is visible in the code performing it.

When a tree is what you have — a form, a document, a settings object — the other shape is deepSignal, a few sections down. It is a separate package, it leaves signal exactly as it is, and both live in the same graph.

Computeds

A computed derives a value, lazily and once per change.

const doubled = computed(() => count.value * 2);

The interesting property is that a computed whose inputs changed but whose own value did not notifies nothing:

const positive = computed(() => count.value > 0);

count.value = 5;
count.value = 9; // `positive` recomputes, stays true, and nothing downstream runs

That is what keeps a change from spreading further than it means anything.

Effects

An effect runs now, and again whenever something it read changes.

const stop = effect(() => {
  document.title = `${String(count.value)} items`;
});

stop(); // unsubscribe

Return a function to clean up before each re-run and on disposal:

effect(() => {
  const id = setInterval(tick, delay.value);
  return () => clearInterval(id);
});

Dependencies are observed, not declared. A conditional read genuinely drops the dependency it did not take:

effect(() => {
  if (enabled.value) {
    console.log(data.value); // while disabled, `data` is not a dependency
  }
});

Inside a component, an effect is owned by that component and disposed with it — you rarely need the returned disposer. See Context and lifecycle.

When does the DOM update?

Synchronously, at the end of the outermost write or batch(). A signal write never schedules a microtask, so there is no await tick() in your code or in your tests:

count.value = 1;
element.textContent; // already the new value

The trade-off is explicit: an unbatched loop of n writes does n DOM passes.

batch(() => {
  first.value = 1;
  second.value = 2;
}); // one pass

Firsthand batches its own multi-writes — list reconciliation, prop propagation, context swaps — so “one user action, many derived changes” is already one pass. The reasoning and what was rejected are in ADR-0006.

Deep state

A signal holds one value and notices when that value is replaced. Mutating inside it is invisible:

const user = signal({ name: 'Ada' });
user.value.name = 'Grace'; // nothing updates: the signal still holds the same object
user.value = { ...user.value, name: 'Grace' }; // this is what a signal wants

Replacing the object is fine for small state and gets tedious for a form or a document. @firsthandjs/deep is the other shape — the one Vue calls reactive():

npm install @firsthandjs/deep
import { deepSignal } from '@firsthandjs/deep';

const state = deepSignal({ user: { name: 'Ada' }, todos: [] as string[] });

effect(() => console.log(state.user.name)); // subscribes to that one property
state.user.name = 'Grace'; // and only that effect re-runs
state.todos.push('write the docs'); // arrays too, as one update

Every property, at any depth, behaves like a signal — without .value anywhere, because the proxy is the value. Reads are tracked per property, so an effect that read state.user.name ignores a change to state.user.age, and writing a property nobody has read costs nothing at all.

It is 0.72 kB, it does not change signal, and both live in the same graph: one effect can read a signal and deep state together.

Only objects and arrays. A Map, a Date or a class instance is rejected by the type, because those read their own internals through this and a proxy is not the object. Keep them in a signal and replace them on each edit. The reference has the full rules, including what happens to Object.keys, in and each array mutator.

Which to reach for. A value — a count, a flag, an id — is a signal. A tree you edit in place is deepSignal. A signal read is one property read; a deep read is a proxy trap plus a cell read, which is the price of not threading .value through a structure.

Reading without subscribing

const id = untrack(() => currentUser.value.id); // read, do not depend on it
count.peek(); // the same thing for one signal

Use it when you want a value but not a dependency — logging, an id passed to a request, a one-time initialisation.

Cycles

An effect that writes what it reads is a bug, and it is reported as one:

effect(() => {
  count.value = count.value + 1; // throws FirsthandCycleError
});

The flush gives up after a large but finite number of steps rather than hanging the tab.

Types

const count: Signal<number> = signal(0);
const doubled: ReadonlyCell<number> = computed(() => count.value * 2);

ReadonlyCell has no setter — assigning to doubled.value is a type error, which the type-level tests assert.


Next: Components — what a component is when it only runs once.