SolidJS Reactivity: Why Fine-Grained Beats Virtual DOM Diffing
SolidJS replaces virtual DOM diffing with fine-grained reactivity: signals track their own readers, effects and memos subscribe automatically, and control flow updates only the DOM nodes that change. A worked example shows a filterable list updating surgically — no dependency arrays, no useMemo.
01 Sept 2025, 18:04 UTC

The Problem: Re-renders Are a Leaky Abstraction
If you've built anything non-trivial in React, you've felt the pain: a parent re-renders, children re-render, useMemo and useCallback litter the codebase, and you're still debugging why a list item flickers when unrelated state changes. The virtual DOM promises declarative UI, but the diffing algorithm is a blunt instrument — it compares entire trees when often only a single text node changed.
SolidJS takes a different bet: track exactly which values are read where, and update only those DOM nodes when a value changes. No virtual DOM, no component re-render cycle, no dependency arrays. This post walks through how that works in practice and what it means for everyday component design.
Signals: Getter/Setter Pairs That Track Themselves
A createSignal call returns a getter and a setter. The getter, when called inside a tracking scope (an effect, a memo, or a JSX expression), registers the current subscriber. The setter, when invoked, notifies only those subscribers. That's the entire reactivity primitive.
import { createSignal, createEffect } from 'solid-js';
const [count, setCount] = createSignal(0);
createEffect(() => {
console.log('count changed to', count()); // getter called here → subscription
});
setCount(5); // only the effect above re-runs
Notice: no dependency array. The effect runs once on mount, then re-runs whenever any signal read inside it changes. If you read count() in two different effects, each tracks independently. Updating count triggers both, but nothing else.
This is the mental model shift: components don't re-render. Signals propagate changes through a dependency graph. Your component function runs once, sets up the graph, and returns DOM instructions. Subsequent updates are surgical.
Derived State Without the Boilerplate
createMemo creates a cached derived signal. It recomputes only when its dependencies change, and — crucially — downstream effects reading the memo don't re-run if the memo's output is referentially equal. This eliminates the useMemo/useCallback dance.
import { createSignal, createMemo, createEffect } from 'solid-js';
const [first, setFirst] = createSignal('Ada');
const [last, setLast] = createSignal('Lovelace');
const fullName = createMemo(() => `${first()} ${last()}`);
createEffect(() => {
console.log('Full name:', fullName()); // subscribes to fullName
});
setFirst('Alan'); // fullName recomputes, effect runs
setLast('Turing'); // fullName recomputes, effect runs
// If fullName returned the same string, effect would NOT run
Compare to React: you'd need useMemo with a dependency array, and even then, referential equality checks are manual. In Solid, the memo is a signal — it participates in the same graph.
Control Flow as Reactive Primitives
<For>, <Show>, and <Switch> aren't syntactic sugar — they're compiled into reactive DOM instructions. <For> maps over a signal array and uses keyed reconciliation to update, insert, or remove only the changed items. <Show> toggles DOM nodes without unmounting/remounting, preserving internal state (like focus or CSS transitions).
import { createSignal, For, Show } from 'solid-js';
const [items, setItems] = createSignal([
{ id: 1, text: 'Learn Solid' },
{ id: 2, text: 'Build something' }
]);
// Later: setItems([...items(), { id: 3, text: 'Profit' }])
// Only the new <li> is inserted; existing nodes stay untouched.
Contrast with React's .map() inside JSX: every parent re-render recreates the entire list of elements, relying on keys for reconciliation. Solid's <For> does the reconciliation at the DOM level, once, when the signal changes.
Worked Example: A Filterable List With Derived Counts
Let's put it together. A todo list with a filter, a derived count of visible items, and granular updates — no useEffect cleanup, no useMemo, no key prop drilling.
import { createSignal, createMemo, For, Show } from 'solid-js';
function TodoApp() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Read docs', done: true },
{ id: 2, text: 'Write component', done: false },
{ id: 3, text: 'Deploy', done: false }
]);
const [filter, setFilter] = createSignal('all'); // 'all' | 'active' | 'done'
const visible = createMemo(() => {
const f = filter();
return todos().filter(t =>
f === 'all' || (f === 'active' ? !t.done : t.done)
);
});
const activeCount = createMemo(() =>
todos().filter(t => !t.done).length
);
const toggle = (id: number) =>
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
return (
<div>
<select value={filter()} onChange={e => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="done">Done</option>
</select>
<p>Active: {activeCount()}</p>
<For each={visible()}>{todo => (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle(todo.id)}
/>
{todo.text}
</label>
)}</For>
<Show when={visible().length === 0}>
<p>No items match filter</p>
</Show>
</div>
);
}
What updates when you toggle a checkbox?
setTodosproduces a new array (immutable update for simplicity).visiblememo recomputes (readstodosandfilter).activeCountmemo recomputes (readstodos).<For>reconciles the list — only the toggled item's checkbox and text node update.- The
<p>Active: {activeCount()}text node updates. - The
<Show>condition re-evaluates; if the list becomes empty, the paragraph is inserted (not remounted).
The filter dropdown change triggers only visible and <For>/<Show> — activeCount doesn't recompute because it doesn't depend on filter. The dependency graph is automatic and minimal.
Where It Gets Tricky: Stores, Context, and the Compiler
Fine-grained reactivity has sharp edges worth knowing:
- Arrays from
createSignalare opaque.signal().map(...)doesn't track per-item changes. Use<For>orcreateStorefor granular list reactivity.createStorewraps nested objects/arrays in a proxy:store.users[0].name = 'X'triggers only effects reading that path. - Context isn't reactive by default. Pass a signal or store as the context value; consumers must call the getter (
ctx()) to subscribe. Passing a plain object won't propagate updates. - Effects run synchronously after render. Reading a signal you just wrote in the same effect causes an infinite loop. Use
untrack(() => signal())to read without subscribing, or restructure. - Debugging means reading compiled output. The Babel plugin transforms JSX into real DOM instructions (e.g.,
template(),effect(),insert()). Solid DevTools (browser extension) visualizes the reactive graph — signals, effects, memos, dependencies — which is far more useful than a virtual DOM tree.
Try It: See the Graph Yourself
Spin up a minimal project and watch the dependency graph live:
npm init solid@latest my-solid-app -- --typescript
cd my-solid-app
npm run dev
# Install Solid DevTools browser extension
# Open the component above, toggle a checkbox, filter the list
# Observe: only the expected nodes light up in the graph
Compare bundle size (npm run build) and Lighthouse scores against a React equivalent. You'll see no virtual DOM runtime, no reconciliation overhead, and a component function that runs exactly once.
Closing Thought
Solid's reactivity isn't just a performance optimization — it's a simpler mental model. State changes flow through explicit dependencies; UI updates are the natural consequence. You stop fighting re-renders and start describing relationships. For your next side project, skip the virtual DOM and see how far fine-grained gets you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.