Choosing Between Qwik’s useStore and useSignal for Component State
Decide between Qwik's useStore and useSignal: compare reactivity, serialization, memory, and developer ergonomics. Follow the table, trade‑offs, and concrete example to pick the right state API for your component.
09 Nov 2025, 05:44 UTC

Problem Statement
In Qwik, two core primitives expose reactive state: useStore creates a mutable object that tracks nested changes, while useSignal represents a single immutable value that triggers re‑renders when updated. Choosing the right primitive is critical for performance, hydration size, and developer ergonomics.
Decision Context
You’re building a component that needs to manage either:
- A simple flag or numeric counter that changes infrequently.
- A complex, hierarchical data structure that will be mutated in several places.
Both patterns must work with Qwik’s lazy hydration, server‑side rendering (SSR), and the framework’s mutation tracking system.
Options
- useStore – Reactive, mutable object. Tracks changes to any nested property and can be serialized for SSR.
- useSignal – Lightweight, immutable value. Ideal for primitive state; requires manual wrapping for objects.
Comparison Table
| Feature | useStore | useSignal |
|---|---|---|
| State Shape | Mutable object; nested properties are tracked automatically. | Single value; objects must be wrapped or flattened. |
| Reactivity Granularity | Whole store becomes dirty on any nested change; fine‑grained updates need careful design. | Only the signal’s value triggers a re‑render; minimal overhead. |
| Serialization for SSR | Supported via toJSON or custom logic; Qwik can serialize the store’s state. |
Primitive values are automatically serializable; objects need manual conversion. |
| Memory Footprint | Higher due to proxy overhead and change tracking metadata. | Lower; just a value and a change flag. |
| Developer Ergonomics | Convenient for complex data, but requires disciplined mutation patterns (e.g., spread syntax). | Simple API; risk of accidental mutation if .value is modified directly. |
| Hydration Size | Potentially larger if the store is deep; Qwik can trim unused parts via lazy hydration. | Smaller because only primitive values are stored. |
Trade‑Offs
- Complexity vs. Size –
useStorehandles nested updates out of the box but adds overhead.useSignalkeeps the bundle lean but forces manual structure management. - Mutation Safety – Direct assignment to
store.foocan bypass Qwik’s change detection; use the spread operator orstore.foo = {...}to trigger reactivity. - SSR Compatibility –
useStorerequires atoJSONimplementation for complex objects;useSignalworks out of the box for primitives. - Fine‑Grained Updates – If you need to re‑render only a part of the component, consider splitting the state into multiple
useSignals or usinguseStorewith nested signals.
Concrete Implementation Example
Scenario: A Todo List
We’ll implement a simple Todo list that supports adding, toggling, and removing items. The list is a nested structure (array of objects), making it a good candidate for useStore. We’ll also show how the same logic could be expressed with useSignal for comparison.
Using useStore
import { component$, useStore, $ } from '@builder.io/qwik';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
export const TodoList = component$(() => {
const store = useStore({
todos: [] as TodoItem[],
nextId: 1,
});
const addTodo = $(() => {
const newTodo: TodoItem = {
id: store.nextId,
text: `Task ${store.nextId}`,
completed: false,
};
store.nextId++;
// Use spread to ensure change detection
store.todos = [...store.todos, newTodo];
});
const toggleTodo = $((id: number) => {
const todo = store.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed; // Qwik tracks this mutation
}
});
const removeTodo = $((id: number) => {
store.todos = store.todos.filter(t => t.id !== id);
});
return (
Add Todo
{store.todos.map(todo => (
-
toggleTodo(todo.id)}
/>
{todo.text}
removeTodo(todo.id)}>Remove
))}
);
});
Using useSignal (Alternative)
import { component$, useSignal, $ } from '@builder.io/qwik';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
export const TodoListSignal = component$(() => {
const todos = useSignal([] as TodoItem[]);
const nextId = useSignal(1);
const addTodo = $(() => {
const newTodo: TodoItem = {
id: nextId.value,
text: `Task ${nextId.value}`,
completed: false,
};
nextId.value++;
todos.value = [...todos.value, newTodo];
});
const toggleTodo = $((id: number) => {
const idx = todos.value.findIndex(t => t.id === id);
if (idx !== -1) {
const updated = { ...todos.value[idx], completed: !todos.value[idx].completed };
todos.value = [...todos.value.slice(0, idx), updated, ...todos.value.slice(idx + 1)];
}
});
const removeTodo = $((id: number) => {
todos.value = todos.value.filter(t => t.id !== id);
});
return (
Add Todo
{todos.value.map(todo => (
-
toggleTodo(todo.id)}
/>
{todo.text}
removeTodo(todo.id)}>Remove
))}
);
});
Validation Checklist
- Run
npm run devand verify that adding, toggling, and removing todos updates the UI without full re‑render of unrelated components. - Open the browser’s Performance tab and confirm that the number of re‑renders matches the number of state changes.
- Use Qwik DevTools to inspect the state tree. For
useStore, the store should appear as a single node with nested properties. ForuseSignal, each signal appears as a leaf node. - Bundle the application with
vite buildand compare the output JavaScript size. TheuseSignalversion should be slightly smaller. - Render the component to a string on the server (
renderToString) and measure payload size. Verify thatuseStorecan serialize the nested array correctly.
Limitations and Caveats
- Both APIs depend on Qwik’s internal mutation tracking; using plain JavaScript objects or arrays without the proxies will break reactivity.
- Large
useStoreobjects may trigger many re‑renders if nested changes are frequent. Consider splitting state into smaller stores or combining withuseSignalfor fine‑grained updates. - When serializing
useStore, you must provide atoJSONmethod if the store contains non‑serializable values (e.g., Date, Map). - Accidental mutation of
signal.valueoutside of a Qwik event handler can bypass change detection. Always mutate within a$callback.
Conclusion
Use useStore when you need a mutable, nested data structure that benefits from Qwik’s change tracking and can be serialized for SSR. Opt for useSignal when the state is a single primitive or when you want to keep the bundle lean and the reactivity surface minimal. By following the validation checklist, you can ensure that the chosen pattern behaves correctly under Qwik’s lazy hydration and server‑side rendering pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.