SolidJS Reactivity in Practice: Signals, Components That Run Once, and the Props Destructuring Trap
Key takeaways
SolidJS looks like React but runs on a different model: component functions execute once, and only the expressions that read a signal re-run. Most SolidJS bugs come from writing React habits into that model, such as destructuring props or reading a signal outside a tracking scope.
A React-looking framework with a different engine
SolidJS uses JSX and has functions called components, so React developers feel at home within minutes. Then something doesn’t update and nothing in the React mental model explains why. The reason is that Solid’s execution model is fundamentally different:
- React: when state changes, the component function re-runs, produces a new virtual DOM tree, and React diffs it against the old one.
- Solid: the component function runs once to set up the DOM. Each JSX expression that reads a signal is compiled into a tiny subscription that updates exactly one DOM node, attribute, or text value when that signal changes. There is no virtual DOM and no re-render.
// React
function Counter() {
const [count, setCount] = useState(0);
console.log('render'); // logs on every click
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// Solid
import { createSignal } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
console.log('setup'); // logs once, ever
return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
}
In Solid, {count()} in the JSX becomes an effect that sets that text node. Clicking re-runs only that effect. Everything in this article follows from this: code in the component body is setup code, and reactivity lives only in places that are re-executed, which are JSX expressions, createMemo, createEffect, and similar primitives.
The practical payoff is that you rarely think about memoization. There is no useCallback or React.memo because nothing re-renders to invalidate references. The cost is that you must be deliberate about where a signal is read.
Starting a project
# Vite template (client-side app)
npm create vite@latest my-app -- --template solid-ts
# Or the official templates
npx degit solidjs/templates/ts my-app
For SSR, file-based routing and server functions, SolidStart is the official meta-framework (it reached 1.0 in 2024); this article sticks to core Solid, which is the same in both.
Signals are getter functions
import { createSignal } from 'solid-js';
const [count, setCount] = createSignal(0);
count(); // read: 0 (and subscribe, if inside a tracking scope)
setCount(5); // write a value
setCount((c) => c + 1); // write with an updater
A signal is read by calling it. That call is how Solid knows who depends on what: while a tracking scope (an effect, memo, or JSX expression) is executing, every getter it calls registers that scope as a subscriber. Pass count (the function) to share the live value; pass count() and you have passed a number.
Setting a signal to the same value it already has (by ===) does nothing. And writes outside a batch propagate immediately, which I verified with Solid 1.9 in Node:
import { createSignal, createEffect, createRoot, batch } from 'solid-js';
createRoot(() => {
const [a, setA] = createSignal(0);
const [b, setB] = createSignal(0);
createEffect(() => console.log('sum', a() + b()));
// later, e.g. in an event handler:
setA(1); setB(1); // sum 1, then sum 2
batch(() => { setA(2); setB(2); }); // sum 4 (one run)
});
The intermediate sum 1 is a real, observable state. If two signals must change together (say, a start and end date), wrap the writes in batch, or model them as one signal holding an object, or use a store.
The props destructuring trap
This is the bug nearly every React developer writes in their first Solid week, and it produces no error at all.
// Broken: status is read once, at setup
function StatusBadge({ status }: { status: string }) {
return <span class={`badge badge-${status}`}>{status}</span>;
}
// Also broken, for the same reason
function StatusBadge(props: { status: string }) {
const status = props.status;
return <span>{status}</span>;
}
Solid passes props as an object whose fields are getters. When a parent writes <StatusBadge status={order().status} />, the compiler turns that into get status() { return order().status }. Reading props.status inside a JSX expression calls the getter inside a tracking scope, so the badge updates. Destructuring in the parameter list calls the getter once, during setup, and stores a plain string. The component body never runs again, so the badge is stuck on its first value forever.
I verified the difference with a getter-based props object in Node: after the underlying signal changed from 1 to 2, the effect reading props.count logged 2 while the destructured copy still held 1.
The rules:
- Access props as
props.xwhere you use them (inside JSX, a memo, or an effect). - For default values, use
mergeProps; for splitting props to forward, usesplitProps. Both preserve reactivity. - If you need a derived value, wrap it:
const label = () => props.status.toUpperCase(), orcreateMemoif it is expensive.
import { mergeProps, splitProps, type JSX } from 'solid-js';
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary';
};
function Button(rawProps: ButtonProps) {
const props = mergeProps({ variant: 'primary' as const }, rawProps);
const [local, rest] = splitProps(props, ['variant', 'class']);
return (
<button {...rest} class={`btn btn-${local.variant} ${local.class ?? ''}`}>
{rest.children}
</button>
);
}
The eslint-plugin-solid ESLint plugin flags destructured props and signal reads outside tracking scopes. I’d install it on day one; it catches this whole category of silent bugs before they reach the browser.
createMemo vs createEffect
Both re-run when the signals they read change. They answer different questions.
import { createSignal, createMemo, createEffect } from 'solid-js';
const [items, setItems] = createSignal<{ price: number }[]>([]);
// Derived value: cached, readable as a signal, recomputed only when items changes
const total = createMemo(() => items().reduce((sum, i) => sum + i.price, 0));
// Side effect: talks to the outside world, returns nothing useful
createEffect(() => {
document.title = `Cart: $${total()}`;
});
createMemoruns immediately and synchronously, caches its result, and notifies dependents only when the result changes. It is the Solid equivalent of bothuseMemoand derived state.createEffectis for side effects. In components, effects run after the initial render, so the DOM exists when they execute.- A plain function
() => a() * 2is also a valid derived signal. It is recomputed on every read, which is fine for cheap expressions; usecreateMemowhen the computation is expensive or read in many places.
The anti-pattern to avoid is syncing state through effects:
// Don't: an extra signal, an extra update pass, and a moment where they disagree
const [fullName, setFullName] = createSignal('');
createEffect(() => setFullName(`${first()} ${last()}`));
// Do
const fullName = createMemo(() => `${first()} ${last()}`);
Use onMount for one-time setup after the component mounts, and onCleanup to tear down intervals, subscriptions and listeners. onCleanup inside an effect runs before the effect re-executes as well as on unmount.
Control flow: <Show>, <For>, <Index>, <Switch>
Because components don’t re-render, ternaries and .map() in JSX work but re-run their entire expression. Solid’s control flow components exist to update only what changed.
<Show>
import { Show } from 'solid-js';
<Show when={user()} fallback={<LoginButton />}>
{(u) => <Profile name={u().name} />}
</Show>
The function child receives an accessor for the truthy when value, which gives TypeScript a non-null type without !. <Show> only recreates its children when when switches between truthy and falsy, not every time the value changes (add the keyed prop if you want recreation on every change).
<For> vs .map() vs <Index>
import { For, Index } from 'solid-js';
<ul>
<For each={todos()}>
{(todo, index) => <li>{index() + 1}. {todo.text}</li>}
</For>
</ul>
<For>keys rows by object reference. When the array changes, it moves, creates or disposes only the affected rows.indexis a signal because an item’s position can change.{todos().map(t => <li>{t.text}</li>)}is valid JSX, but the whole expression is one effect: any change to the array rebuilds every<li>, losing focus, input state and CSS transitions in the process.<Index>keys by position; the item is a signal and the index is a number. It suits lists of primitives or fixed-length arrays where the values change but the slots don’t.
A subtle <For> pitfall: if you replace the array with freshly created objects on every update (for example setTodos(await fetchTodos())), every reference is new, so every row is recreated anyway. For server data that refreshes, a store with reconcile (below) diffs by key and keeps the DOM stable.
<Switch> / <Match>
import { Switch, Match } from 'solid-js';
function StatusBadge(props: { status: 'active' | 'pending' | 'inactive' }) {
return (
<Switch fallback={<span>Unknown</span>}>
<Match when={props.status === 'active'}><span class="badge-green">Active</span></Match>
<Match when={props.status === 'pending'}><span class="badge-yellow">Pending</span></Match>
</Switch>
);
}
Note class, not className: Solid sets DOM properties and attributes directly, and its docs use the HTML attribute names.
Stores for nested state
A signal holding an object is all-or-nothing: changing one nested field means replacing the object, and everything reading any part of it updates. Stores give per-property reactivity through a proxy.
import { createStore, produce, reconcile } from 'solid-js/store';
import { For } from 'solid-js';
type Todo = { id: number; text: string; completed: boolean };
function TodoApp() {
const [todos, setTodos] = createStore<Todo[]>([]);
const add = (text: string) =>
setTodos(todos.length, { id: Date.now(), text, completed: false });
// Path syntax: filter function, then key, then updater
const toggle = (id: number) =>
setTodos((t) => t.id === id, 'completed', (c) => !c);
// produce: mutate a draft, Immer-style
const clearDone = () =>
setTodos(produce((list) => {
for (let i = list.length - 1; i >= 0; i--) if (list[i].completed) list.splice(i, 1);
}));
// reconcile: apply fresh server data while keeping existing row identities
const refresh = async () => {
const fresh: Todo[] = await (await fetch('/api/todos')).json();
setTodos(reconcile(fresh, { key: 'id' }));
};
return (
<For each={todos}>
{(todo) => (
<label>
<input type="checkbox" checked={todo.completed} onChange={() => toggle(todo.id)} />
{todo.text}
</label>
)}
</For>
);
}
Toggling one todo updates only that checkbox. The store is read-only from outside: assigning todos[0].completed = true directly does not update anything (in development Solid warns Cannot mutate a Store directly). All writes go through the setter.
Context: pass the reactive thing, not its value
import { createContext, useContext, createSignal, type ParentProps } from 'solid-js';
type User = { name: string };
const UserContext = createContext<() => User | undefined>();
function UserProvider(props: ParentProps) {
const [user] = createSignal<User>({ name: 'Ada' });
return <UserContext.Provider value={user}>{props.children}</UserContext.Provider>;
}
function UserName() {
const user = useContext(UserContext);
return <span>{user?.()?.name}</span>;
}
Passing value={user()} would hand consumers a snapshot. Since the provider component never re-runs, that snapshot would never change. Pass the signal (or a store, or an object of getters and actions) so consumers can track it.
A related pitfall involves ownership. Solid tracks which component “owns” each effect so it can dispose it on unmount. Code after an await has lost that owner, so useContext returns undefined there and effects created there warn computations created outside a `createRoot` or `render` will never be disposed. I’ve lost time to this when an async onMount created an effect after fetching data; the fix is to capture getOwner() before the await and use runWithOwner, or better, fetch with createResource and keep the reactive code synchronous.
Async data with createResource
import { createResource, createSignal, Show, Suspense, ErrorBoundary } from 'solid-js';
const fetchUser = async (id: number) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ name: string }>;
};
function UserProfile() {
const [userId, setUserId] = createSignal<number | null>(1);
const [user, { refetch }] = createResource(userId, fetchUser);
return (
<ErrorBoundary fallback={(err) => <p>Error: {err.message}</p>}>
<Suspense fallback={<p>Loading...</p>}>
<h1>{user()?.name}</h1>
</Suspense>
</ErrorBoundary>
);
}
The first argument is the source signal. Whenever it changes, the fetcher runs again; if the source is null, undefined or false, the fetch is skipped, which is how you express “wait until we have an ID”. Reading user() inside a <Suspense> boundary suspends it until the data arrives. Without Suspense, check user.loading and user.error (or user.state) explicitly. Note that reading user() after the fetch has failed throws the error, which is why an <ErrorBoundary> belongs around it.
Routing with @solidjs/router
npm install @solidjs/router
import { Router, Route, A, useParams, type RouteSectionProps } from '@solidjs/router';
function Layout(props: RouteSectionProps) {
return (
<>
<nav>
<A href="/">Home</A>
<A href="/about">About</A>
</nav>
<main>{props.children}</main>
</>
);
}
function UserPage() {
const params = useParams(); // params.id is reactive; don't destructure it
return <h1>User {params.id}</h1>;
}
export default function App() {
return (
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/users/:id" component={UserPage} />
</Router>
);
}
In current versions of the router, the children of <Router> are route definitions only; shared UI such as a nav bar goes in the root layout. Older tutorials that put <nav> directly inside <Router> or use a <Routes> wrapper were written for a previous API. useParams() returns a reactive object, so the destructuring rule applies here as well: const { id } = useParams() freezes the first ID, and navigating from /users/1 to /users/2 reuses the component without updating it.
When Solid is a good fit, and when it isn’t
Solid suits UIs with lots of small, frequent updates (dashboards, editors, real-time feeds), because updating one text node costs the same no matter how big the component tree is. The runtime is small, and the core concepts fit in one afternoon.
The trade-offs are real, though:
- Ecosystem: far fewer component libraries, and no React Native equivalent for mobile.
- Hiring and familiarity: the syntax is familiar, but the mental model is not, and code reviewers used to React will miss destructuring and tracking bugs.
- Debugging: there is no re-render to put a breakpoint in. When something doesn’t update, you debug the dependency graph (“who reads this signal, and is that read inside a tracking scope?”) rather than render output.
If you are comparing signal-based designs, Svelte 5’s runes and Preact Signals take similar ideas in different directions; the linked articles below cover how their tracking rules differ.