SolidJS State Architecture: Signals, Memos, and Effects
Own SolidJS UI state with createSignal, createMemo, and createEffect while avoiding stale DOM and broken reactivity.
05 Sept 2026, 10:24 UTC

The problem
Solid components run once. Unlike frameworks that re-run a render function on every state change, Solid compiles JSX into fine-grained updates: only the DOM expressions that read a changed signal are touched. The failure mode is treating Solid like a virtual-DOM framework. If you lift all state into a single top-level object, mutate arrays directly, or place rendering logic inside createEffect, the UI stops updating, logs stale values, or performs unnecessary work.
Useful takeaway: declare state with createSignal, update it only through the returned setter, derive values with createMemo, and confine non-UI side effects to createEffect. Keep each signal scoped to the component that owns it.
Terms used here
- Signal: a reactive cell created by
createSignal. It returns a getter function to read the value and a setter function to change it. - Fine-grained reactivity: tracking individual reads so only dependent computations re-run when a signal changes.
- Owner: the component or context that creates a reactive computation. When the owner disposes, its subscriptions are cleaned up.
Requirements
- Declarative JSX UI that reflects state without manual DOM calls.
- Automatic dependency tracking: reading a signal inside a tracked context subscribes that context to the signal.
- Minimal boilerplate: no manual subscription management or render-phase side effects.
- Compatibility with standard JSX tooling, such as Vite with
@solidjs/plugin.
Smallest suitable design
A root component owns a counter signal, derives a doubled value, and logs changes through an effect.
import { createSignal, createMemo, createEffect } from "solid-js";
function Counter() {
const [count, setCount] = createSignal(0);
const doubled = createMemo(() => count() * 2);
createEffect(() => {
console.log("count changed:", count());
});
return (
Count: {count()}
Doubled: {doubled()}
setCount((c) => c + 1)}>
Increment
);
}
export default Counter;
createSignal(0) returns count, a getter, and setCount, a setter. Reading count() inside createMemo or createEffect registers a dependency. The memo recomputes only when count changes. The effect runs after render and after any tracked dependency changes. The setter uses a functional updater, (c) => c + 1, so the new value does not depend on a stale closure.
Where to run and what to check
- Run in a local project scaffolded with Vite and
@solidjs/plugin. No special file-system permissions are required. - Placeholder: replace
AppinApp.jsxwith<Counter />. - Expected check: open the dev server, click Increment, and confirm the paragraph text updates and the console log prints the new count.
- Risk: mutating the signal value directly instead of calling the setter breaks reactivity and leaves the DOM stale.
Trust and data boundaries
Signals are scoped to the component tree. When a component is removed from the DOM, Solid disposes its owner and cleans up subscriptions created by createMemo and createEffect. Mutable state should not leave a signal unless it is explicitly passed as a prop.
Passing a setter across the boundary lets a child mutate parent state. That is sometimes necessary, but prefer passing the current value and a callback function:
function Child({ value, onChange }) {
return onChange(value + 1)}>Next;
}
function Parent() {
const [step, setStep] = createSignal(1);
return ;
}
Avoid storing signals in global modules unless you intentionally want singleton state shared across unrelated sub-trees. Global signals make ownership harder to trace and can outlive the components that read them.
Operational checks
- In development, enable Solid DevTools to inspect the reactive graph and confirm that derived values and effects subscribe to the expected signals.
- When rendering lists, pass a stable
keyto<For>. Missing keys can cause DOM node reuse and loss of local state. - For asynchronous data, use
createResourcewith<Suspense>. Fetching inside an effect without a resource can produce race conditions and missing loading or error states. - The production build strips development instrumentation through the compiler plugin. Verify the build runs without runtime warnings before assuming the development behavior carries over.
Failure modes and conditions that change the design
| Failure mode | Why it happens | Design change |
|---|---|---|
| Direct mutation of signal value | Calling list().push(item) instead of setList([...list(), item]) does not notify subscribers. | Wrap mutations in the setter, or switch to createStore for nested collections. |
| Rendering inside an effect | createEffect is for side effects, not JSX. DOM updates inside effects bypass the compiler's fine-grained updates. | Move derived UI into JSX expressions or createMemo; reserve effects for analytics, timers, or third-party widgets. |
| Asynchronous fetch without a resource | Effects do not coordinate loading, error, or cancellation states for promises. | Adopt createResource and <Suspense>. |
| State needed by many unrelated components | Prop-drilling a signal through intermediate components couples the tree and obscures ownership. | Use Solid Context or a store so components subscribe directly to shared state. |
Limitations
- Reactivity tracks synchronous reads inside tracking contexts. Asynchronous callbacks lose the tracking scope unless the signal is read again inside a new tracked context.
- Signals are not deeply reactive. Mutating a property on an object inside a signal requires replacing the object through the setter.
createEffectruns after the initial render, so it cannot be used to compute the initial markup.- These behaviors assume the Solid compiler plugin is active. Without it, runtime proxies add overhead and some optimizations do not apply.
Practical verification
- Add the
Countercomponent to the app root and runnpm run devfrom the project directory. - Click Increment and confirm the DOM text and effect log both reflect the new count.
- Open Solid DevTools, select the component, and verify
doubledappears as a dependency ofcount. - Run
npm run buildand preview the production build. Repeat the interaction and confirm no reactive warnings appear.
This note describes component design, not a migration or infrastructure change, so there is no rollback procedure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.