Designing with SolidJS Signals: An Architecture Note on Fine-Grained Reactivity
An architecture note on SolidJS signals: the requirements they satisfy, the minimal setup, mutation boundaries, operational verification, and when to switch to a store.
27 Nov 2025, 20:17 UTC

If your UI must update the moment underlying state changes — without a virtual DOM diff pass and without manual subscription bookkeeping — SolidJS signals are the smallest primitive that satisfies that requirement. This note walks through the requirements, the minimal design, where trust boundaries sit, how to check the design in operation, and the failure modes that would justify moving to a centralized store.
Requirements
The motivating requirements for choosing signals over a component-rerender model:
- Updates must be immediate and scoped: when a value changes, only the DOM nodes that read it should update, not the whole component subtree.
- Boilerplate should be minimal: no selectors, no dependency arrays to maintain by hand, no manual unsubscribe calls.
- Performance should be predictable: update cost should scale with the number of affected bindings, not the size of the component tree.
SolidJS meets these by making the signal the unit of subscription. A component's JSX runs once; the expressions inside it subscribe directly to the signals they read.
The smallest suitable design
Three primitives cover most needs:
createSignal(value)returns a getter and a setter. The getter, when called inside a tracked scope, registers a subscription.createMemo(fn)derives a cached value that recomputes only when its source signals change.- JSX expressions like
{count()}subscribe automatically. There is nothing to clean up — Solid disposes subscriptions when the owning scope is destroyed.
import { createSignal, createMemo } from \"solid-js\";\n\nfunction Counter() {\n const [count, setCount] = createSignal(0);\n const doubled = createMemo(() => count() * 2);\n\n return (\n <button onClick={() => setCount(c => c + 1)}>\n Count: {count()} — doubled: {doubled()}\n </button>\n );\n}Clicking the button updates the two text nodes in place. The component function does not re-run. This example reflects documented SolidJS behavior (1.x); verify against your installed version, since the reactive API has been stable but the surrounding tooling changes.
Trust and data boundaries
A signal is scoped to where it is created. The boundary rule that keeps the design sane: pass the getter down by default; pass the setter only when the child is meant to mutate.
// Parent keeps mutation authority\n<Child value={count} /> // read-only access\n<Editor value={count} onChange={setCount} /> // explicit write pathBecause the getter is just a function, a child that receives it can read but not write. This makes the mutation graph easy to audit: search for where setters are passed, and you have found every write path. Two cautions apply:
- Do not destructure props in Solid —
const { value } = propsreads once and breaks reactivity. Accessprops.valueat the point of use. - Passing a setter wrapped in a new inline closure at every render can cause children to re-subscribe unnecessarily. Define handlers once, outside the JSX, when the child is expensive.
Operational checks
Three checks confirm the design is doing what you think it is:
- DOM-scope check. Build a component with a signal and a sibling that renders static content. Toggle the signal and inspect the DOM: only the bound text node should change. If whole subtrees are being replaced, something (often a destructured prop or a spread) broke tracking.
- Subscription tracing. Use the Solid devtools extension to visualize which memos and effects subscribe to which signals. A memo should recompute only when a source it actually read changes — if it recomputes on unrelated updates, it is reading more than it needs.
- Async lifetime check. Mutate a signal from a
setTimeoutthat fires after the component unmounts. Solid disposes reactive scopes on unmount, so this should be a no-op with no leak reported in devtools' memory view. If you hold setters in long-lived external closures (event buses, websockets), you own that lifetime — scope disposal will not save you.
Failure modes
- Lost reactivity. The most common bug: reading a signal outside a tracked scope (e.g., caching
const v = count()at the top of a component) and expecting updates. The read must happen inside JSX, a memo, or an effect. - Subscription storms on large collections. A signal holding a 10,000-item array, where each row reads the whole array, creates far more work than necessary. Use
<For>with keyed references rather thanmapoverarray(), so row identity is stable. - Unsynchronized writers. If several async sources mutate the same signal (polling plus user input plus a websocket), last-write-wins can silently drop updates. Signals give you no conflict resolution.
What would change the design
- Deeply nested mutable state. When you are updating fields three levels deep and signal setters force you to clone whole objects, switch to
createStore, which supports path-based mutation (setState(\"users\", 0, \"name\", \"Ada\")) with the same fine-grained tracking. - Multiple concurrent writers needing coordination. When race conditions appear, centralize: one store, one mutation layer, and explicit update functions. The signal-per-field design stops paying for itself here.
- State shared across unrelated subtrees. Rather than threading signals through many prop layers, create them in a module or provide them via context — but keep the same getter/setter boundary rule.
The design principle throughout: keep subscriptions fine, keep write paths few, and verify with devtools that the subscription graph matches your mental model. When it does not, the bug is almost always a read that escaped a tracked scope.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.