firsthand

ADR-0018: Deep reactivity beside signals, not inside them

Status: accepted · 2026-09-20

Problem

A signal holds one value and notifies when that value is replaced. For a tree, that is the wrong grain:

const form = signal({ user: { name: 'Ada' }, tags: ['x'] });
form.value.user.name = 'Grace'; // nothing happens
form.value = { ...form.value, user: { ...form.value.user, name: 'Grace' } }; // this does

The second line is correct and nobody enjoys writing it. For a settings tree, a document, or a form with thirty fields, spreading down to the leaf on every keystroke is the whole day’s work — and each spread replaces objects that nothing else changed, so every reader of every sibling is notified too.

Vue answers this with reactive(): a proxy where reading any property at any depth subscribes, and writing any property notifies. The request was to have that here without changing signal.

Constraints

Options

  1. A deep: true option on signal. One import, and wrong: it changes the return type of the most used function in the framework, and every call site then has to know which kind it is holding.
  2. Deep reactivity inside @firsthandjs/core. Convenient, but the core is the thing every application downloads, and this is not something every application needs. The project’s own non-goal — “anything optional inside the core runtime” — rules it out.
  3. Its own package, built on signal. @firsthandjs/deep, one dependency, optional by construction.

Chosen design

Option 3. deepSignal(object) returns a proxy; each property that is read gets a version cell (an ordinary signal(0)), and a write bumps it. Nothing new enters the reactive graph: the cells are signals, so dependency tracking, the synchronous flush, disposal and untrack all behave as they already do.

Three details worth stating:

Only objects and arrays

Deep = Record<PropertyKey, unknown> | unknown[], enforced by the signature. A Map, Set, Date, RegExp, Promise, function or class instance is a compile error, and one found inside deep state is handed back unproxied.

The reason is not taste. Those objects reach their internal state through this, and inside a proxy this is the proxy — a proxied Map throws on get. Vue handles it with a second family of handlers per collection type; that is a real amount of code for a case where “keep it in a signal and replace it” works. Saying no keeps the package at 0.72 kB and keeps the failure at compile time.

Performance implications

Memory implications

Proxies and cell tables are held in WeakMaps keyed by the raw object, so a subtree that becomes unreachable takes its proxy and its cells with it. Cells are per property that was read, not per property.

DX implications

Rejected alternatives