SolidJS and Fine-Grained Rendering
The Framework That Runs Components Once
Here's a statement that breaks React developers' brains: in SolidJS, your component function runs exactly once. Ever. It never re-executes when state changes.
function Counter() {
const [count, setCount] = createSignal(0);
console.log('I run once. Only once. Even when count changes.');
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count()}
</button>
);
}
Click that button 1000 times. The console.log fires once. The button text updates 1000 times. But the component function? Called once at mount time, never again.
This isn't a trick or an optimization -- it's the fundamental architecture. Solid uses the component function as a setup function, not a render function. It runs once to establish reactive relationships, then the signal system handles all future updates by surgically mutating the DOM.
The Mental Model
Think of a Solid component like wiring a circuit. When the component runs, you're connecting wires between signal sources (state) and LED outputs (DOM nodes). Once the circuit is wired, electricity (data) flows through it automatically. You don't rewire the circuit every time you flip a switch -- the electricity just takes the path you already laid out.
React, by contrast, is like redrawing the circuit diagram every time a switch flips, then comparing the new diagram to the old one, and only changing the wires that differ. Both approaches light up the right LEDs. But Solid skips the redrawing and comparison steps entirely.
JSX Compilation: The Key Insight
The reason Solid can run components once is that its JSX compiles to something fundamentally different from React's.
React's JSX compiles to createElement calls that return virtual DOM objects:
// React JSX: <div>{count}</div>
// Compiles to:
React.createElement('div', null, count);
// Returns a virtual DOM object: { type: 'div', props: { children: count } }
Solid's JSX compiles to real DOM creation plus reactive bindings:
// Solid JSX: <div>{count()}</div>
// Compiles to (simplified):
const _el = document.createElement('div');
const _text = document.createTextNode('');
_el.appendChild(_text);
createEffect(() => {
_text.data = count();
});
return _el;
See the difference? Solid creates the actual DOM element once, then wraps count() in an effect. When count changes, the effect re-runs and updates the text node directly. No virtual DOM. No diffing. Just a direct textNode.data = newValue assignment.
// A more complete example
function UserCard() {
const [name, setName] = createSignal('Alice');
const [role, setRole] = createSignal('Engineer');
return (
<div class="card">
<h2>{name()}</h2>
<p>{role()}</p>
<button onClick={() => setName('Bob')}>Change name</button>
</div>
);
}
// Compiles roughly to:
function UserCard() {
const [name, setName] = createSignal('Alice');
const [role, setRole] = createSignal('Engineer');
const _div = _tmpl.cloneNode(true); // Clone pre-built template
const _h2 = _div.firstChild;
const _p = _h2.nextSibling;
const _button = _p.nextSibling;
createEffect(() => { _h2.textContent = name(); });
createEffect(() => { _p.textContent = role(); });
_button.addEventListener('click', () => setName('Bob'));
return _div;
}
When setName('Bob') fires, only the _h2.textContent effect re-runs. The _p.textContent effect is untouched because it doesn't depend on name. The button listener stays the same. The div, p, and button DOM elements were never recreated.
The Three Solid Primitives
createSignal -- Reactive State
const [count, setCount] = createSignal(0);
count(); // Read: returns 0 (and tracks if inside a tracking context)
setCount(1); // Write: sets to 1, notifies subscribers
setCount(c => c + 1); // Write with updater function: sets to 2
Unlike React's useState, calling count() inside a reactive context (effect, computed, JSX) creates a subscription. The tuple is a getter function and a setter function -- not a value and a setter.
The parentheses matter. In React, count is a value. In Solid, count is a function you must call to read the value. If you pass count (without parentheses) as a prop or into an expression, you're passing the getter function, not the value. This is actually useful -- it preserves reactivity when passing to child components. But forgetting the parentheses when you want the value is a common Solid bug.
createMemo -- Derived State
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
doubled(); // 0, tracked
setCount(5);
doubled(); // 10, recalculated because count changed
doubled(); // 10, cached (count hasn't changed since last read)
createMemo is Solid's computed. It's lazy, cached, and auto-tracking. Unlike React's useMemo, it's truly reactive -- it subscribes to its dependencies and invalidates when they change, rather than comparing a dependency array on every render.
createEffect -- Side Effects
const [userId, setUserId] = createSignal(1);
createEffect(() => {
console.log(`Fetching user ${userId()}`);
fetchUser(userId()).then(setUser);
});
Effects run immediately and re-run when any tracked signal changes. They're synchronous and run during the component's setup phase.
Solid effects run synchronously during component creation. If you need to wait for the DOM to be mounted, use onMount() or createEffect with an explicit condition. Also note that createEffect tracks signals read in the first synchronous tick -- anything after an await is not tracked, just like all signal systems.
Control Flow: Components, Not Ternaries
Because Solid components run once, conditional and list rendering can't use the same patterns as React. In React, a ternary works because the component re-executes:
// React: works fine because component re-runs
function Greeting({ loggedIn }) {
return loggedIn ? <Welcome /> : <Login />;
}
In Solid, the component function runs once, so the ternary would evaluate once and never update. Instead, Solid provides control flow components:
Show -- Conditional Rendering
function Greeting() {
const [loggedIn, setLoggedIn] = createSignal(false);
return (
<Show when={loggedIn()} fallback={<Login />}>
<Welcome />
</Show>
);
}
Show is a component that reactively switches between its children and the fallback based on the when signal. Under the hood, it creates an effect that watches loggedIn() and swaps DOM nodes.
For -- List Rendering
function TodoList() {
const [todos, setTodos] = createSignal([
{ id: 1, text: 'Learn Solid' },
{ id: 2, text: 'Build app' }
]);
return (
<For each={todos()}>
{(todo, index) => <li>{todo.text}</li>}
</For>
);
}
For is keyed by reference. It tracks each array item by identity, creating DOM nodes for new items and removing nodes for deleted items, without re-creating nodes for items that moved. This is Solid's equivalent of React's key prop, but automatic.
Switch/Match -- Multi-way Conditional
<Switch>
<Match when={status() === 'loading'}>
<Spinner />
</Match>
<Match when={status() === 'error'}>
<ErrorMessage />
</Match>
<Match when={status() === 'success'}>
<Content />
</Match>
</Switch>
Why No Virtual DOM?
React's virtual DOM exists because React re-runs component functions on every update. The VDOM is a lightweight representation that can be cheaply created and compared. Without it, React would create real DOM nodes on every render, which is far more expensive.
Solid doesn't need a VDOM because it never re-runs component functions. DOM nodes are created once during setup, and subsequent updates go through effects that directly mutate specific nodes. There's nothing to diff because there's no second render to compare against.
| Aspect | React (VDOM) | Solid (No VDOM) |
|---|---|---|
| Component execution | Re-runs on every state change | Runs once at mount |
| JSX output | Virtual DOM objects | Real DOM nodes + reactive bindings |
| Update mechanism | Diff old VDOM vs new VDOM, patch DOM | Effects directly mutate specific DOM nodes |
| Memory | Two trees (current + new VDOM) | One tree (real DOM) + lightweight signals |
| Update cost | O(tree size) for diffing | O(changed signals) for updates |
| Initial render | Slightly faster (batch DOM creation) | Slightly slower (individual DOM operations) |
| Subsequent updates | Slower (diff + patch) | Faster (direct mutation) |
Template cloning: Solid's rendering secret weapon
Solid uses template cloning for initial render performance. Static HTML structure is extracted at compile time and turned into template elements:
const _tmpl = document.createElement('template');
_tmpl.innerHTML = '<div class="card"><h2></h2><p></p><button>Change</button></div>';
function UserCard() {
const _el = _tmpl.content.firstChild.cloneNode(true);
// ... wire up reactive bindings
return _el;
}cloneNode(true) is extremely fast -- the browser can clone an entire DOM subtree in a single operation, much faster than creating each element individually with createElement. Solid extracts all the static structure into templates at compile time and only creates dynamic bindings at runtime.
This is why Solid consistently tops JS Framework Benchmark results. The combination of template cloning for initial render and fine-grained signal updates for subsequent changes covers both hot paths.
SolidJS 2.0: What's Changing
SolidJS 2.0, currently in beta (as of early 2026), brings significant improvements:
@solidjs/signals-- the reactive core extracted as a standalone package, usable outside SolidcreateAsync-- the standard async primitive, replacingcreateResource- Concurrent transitions -- Solid's answer to React's
useTransition, allowing non-blocking state updates - Self-healing error boundaries -- error boundaries that can retry after failure
- Immutable diffable stores -- improved store primitives for complex nested state
- Automatic batching -- all synchronous updates are batched by default (no more manual
batch()for most cases) - Lazy memos and derived signals -- computeds that are even lazier, only subscribing when needed
The 2.0 release also introduces Loading for initial readiness states and makes isPending a reactive expression rather than a boolean flag, aligning with the signal-first philosophy.
- 1Solid components run once as setup functions -- state changes never re-execute the component body
- 2JSX compiles to real DOM creation plus fine-grained effects that directly mutate specific DOM nodes
- 3Use control flow components (Show, For, Switch) instead of ternaries and .map() for reactive conditionals and lists
- 4Signal getters are functions -- count() reads the value, count passes the getter (preserving reactivity)
- 5No virtual DOM: initial render uses template cloning, updates use direct DOM mutations through the signal graph
| What developers do | What they should do |
|---|---|
| Destructuring props in Solid components Destructuring breaks the reactive binding. props.name is a getter that tracks reactively. const { name } = props reads the value once and loses reactivity | Access props as props.name to preserve reactivity, or use splitProps/mergeProps |
| Using .map() directly for lists instead of the For component A .map() inside JSX evaluates once during component setup. The For component creates an effect that tracks the array signal and efficiently adds/removes DOM nodes | Use the For component for reactive lists |
| Expecting component functions to re-run on state changes If you put logging or side effects directly in the component body, they run once. For reactive side effects, use createEffect | Think of Solid components as setup functions that wire reactive bindings |