State Management in Qwik: Patterns and Libraries
State management is one of the most critical aspects of building modern web applications. Qwik, with its unique resumability model and fine-grained reactivity, introduces a fundamentally different approach to state compared to traditional frameworks like React or Vue. In this tutorial, we'll explore how state works in Qwik, the built-in primitives available, common patterns, and the libraries you can leverage for larger applications.
Why State Management Matters in Qwik
In most frameworks, the entire application state is hydrated on the client after the initial HTML is sent from the server. This means downloading and executing JavaScript to rebuild the component tree and reattach event listeners. Qwik flips this model by serializing state directly into the HTML and resuming execution only when needed. This has profound implications for how you structure and manage state.
Because Qwik lazily loads JavaScript only for the interactions that actually occur, your state management strategy directly impacts performance. Bloated or poorly organized state can lead to unnecessary downloads, while well-structured state keeps bundles minimal and interactions instant. Understanding Qwik's primitives helps you build applications that are both fast and maintainable.
Core State Primitives in Qwik
Qwik provides several built-in primitives for managing state. Each serves a specific purpose, and knowing when to use each is the foundation of effective state management.
useSignal
The useSignal hook is the most basic reactive primitive. It creates a signal that holds a single value and triggers re-renders when that value changes. Signals are ideal for primitive values like strings, numbers, booleans, or simple objects.
import { component$, useSignal } from '@builder.io/qwik';
export default component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
<button onClick$={() => count.value--}>Decrement</button>
</div>
);
});
Notice that you access and mutate the value through the .value property. Unlike React's useState, you can mutate the signal directly without calling a setter function. This makes signals ergonomic and easy to reason about.
useStore
When you need to manage a complex object with multiple properties, useStore is the right choice. It creates a deeply reactive store where any property change triggers updates to components that read those properties.
import { component$, useStore } from '@builder.io/qwik';
export default component$(() => {
const user = useStore({
name: 'Ada Lovelace',
email: 'ada@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
});
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<label>
Theme:
<select
value={user.preferences.theme}
onChange$={(e) => {
user.preferences.theme = (e.target as HTMLSelectElement).value;
}}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={user.preferences.notifications}
onChange$={(e) => {
user.preferences.notifications = (e.target as HTMLInputElement).checked;
}}
/>
Enable notifications
</label>
</div>
);
});
By default, useStore uses deep reactivity, meaning nested objects and arrays are also tracked. You can opt into shallow reactivity by passing a second argument: useStore(initialState, { deep: false }). Shallow stores are more performant when you only need top-level property tracking.
useContext
For sharing state across deeply nested components without prop drilling, Qwik provides useContext and useContextProvider. This is Qwik's equivalent of React's Context API but with fine-grained reactivity built in.
import {
component$,
createContextId,
useContext,
useContextProvider,
useStore,
type Signal,
} from '@builder.io/qwik';
// Define a context ID with a type
export const CartContext = createContextId<{ items: string[]; total: number }>(
'cart-context'
);
const CartDisplay = component$(() => {
const cart = useContext(CartContext);
return (
<div>
<h4>Cart ({cart.items.length} items)</h4>
<ul>
{cart.items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
<p>Total: ${cart.total}</p>
</div>
);
});
const AddItemButton = component$(() => {
const cart = useContext(CartContext);
return (
<button
onClick$={() => {
cart.items.push(`Item ${cart.items.length + 1}`);
cart.total += 9.99;
}}
>
Add Item
</button>
);
});
export default component$(() => {
const cartStore = useStore({ items: [], total: 0 });
useContextProvider(CartContext, cartStore);
return (
<div>
<h2>Shopping Cart</h2>
<CartDisplay />
<AddItemButton />
</div>
);
});
The context is created using createContextId, which must be defined at module level (outside components). The provider sets the value in the component tree, and any descendant can consume it with useContext. Because the context value is a store, changes to its properties automatically trigger re-renders in consuming components.
Derived and Computed State
Often, you need state that depends on other state. In Qwik, derived values can be computed inline in JSX because signals and stores are reactive. However, for expensive computations or values reused across a component, you can use useComputed$.
import { component$, useSignal, useComputed$ } from '@builder.io/qwik';
export default component$(() => {
const price = useSignal(100);
const quantity = useSignal(2);
const taxRate = useSignal(0.08);
const subtotal = useComputed$(() => price.value * quantity.value);
const tax = useComputed$(() => subtotal.value * taxRate.value);
const total = useComputed$(() => subtotal.value + tax.value);
return (
<div>
<label>
Price: $
<input
type="number"
value={price.value}
onInput$={(e) => (price.value = +(e.target as HTMLInputElement).value)}
/>
</label>
<label>
Quantity:
<input
type="number"
value={quantity.value}
onInput$={(e) => (quantity.value = +(e.target as HTMLInputElement).value)}
/>
</label>
<p>Subtotal: ${subtotal.value.toFixed(2)}</p>
<p>Tax: ${tax.value.toFixed(2)}</p>
<p><strong>Total: ${total.value.toFixed(2)}</strong></p>
</div>
);
});
useComputed$ memoizes the result and only recomputes when its dependencies change. This is more efficient than recalculating on every render, especially for complex derivations.
Task-Based State with useTask$
For side effects and state synchronization, Qwik offers useTask$. This hook watches signals and stores and runs a callback when they change. It's similar to React's useEffect but designed around Qwik's reactivity model.
import { component$, useSignal, useStore, useTask$ } from '@builder.io/qwik';
export default component$(() => {
const searchQuery = useSignal('');
const results = useStore({ items: [] as string[], loading: false });
useTask$(({ track }) => {
// Track the signal so this task re-runs when it changes
const query = track(() => searchQuery.value);
if (!query) {
results.items = [];
results.loading = false;
return;
}
results.loading = true;
// Simulate an API call
const timer = setTimeout(() => {
results.items = [
`Result for "${query}" #1`,
`Result for "${query}" #2`,
`Result for "${query}" #3`,
];
results.loading = false;
}, 500);
// Cleanup function
return () => clearTimeout(timer);
});
return (
<div>
<input
type="text"
placeholder="Search..."
value={searchQuery.value}
onInput$={(e) => (searchQuery.value = (e.target as HTMLInputElement).value)}
/>
{results.loading && <p>Loading...</p>}
<ul>
{results.items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
);
});
The track function explicitly declares which reactive values the task depends on. This explicit tracking is more predictable than dependency arrays and avoids stale closure bugs.
Global State Patterns
For application-wide state, Qwik doesn't force a single pattern. However, there are several well-established approaches.
Root-Level Context
The simplest approach is to provide a global store at the root of your application. In a Qwik City app, this is typically done in the root layout component.
// src/routes/layout.tsx
import {
component$,
createContextId,
useContextProvider,
useStore,
} from '@builder.io/qwik';
import { Slot } from '@builder.io/qwik';
export interface AppState {
user: { id: string; name: string } | null;
theme: 'light' | 'dark';
locale: string;
}
export const AppContext = createContextId<AppState>('app-context');
export default component$(() => {
const state = useStore<AppState>({
user: null,
theme: 'light',
locale: 'en-US',
});
useContextProvider(AppContext, state);
return (
<div class={state.theme === 'dark' ? 'dark-theme' : 'light-theme'}>
<Slot />
</div>
);
});
Any component rendered within this layout can access the global state via useContext(AppContext). This pattern is simple, type-safe, and works well for small to medium applications.
Signal-Based Global Stores
For more granular control, you can create module-level signals that act as global stores. This pattern avoids the component tree entirely and works well for truly global values like feature flags or configuration.
// src/stores/featureFlags.ts
import { signal } from '@builder.io/qwik';
export const featureFlags = signal<Record<string, boolean>>({
newDashboard: false,
betaFeatures: false,
advancedSearch: true,
});
export function toggleFlag(name: string) {
featureFlags.value = {
...featureFlags.value,
[name]: !featureFlags.value[name],
};
}
// Usage in a component
import { component$ } from '@builder.io/qwik';
import { featureFlags, toggleFlag } from '../stores/featureFlags';
export default component$(() => {
return (
<div>
{featureFlags.value.newDashboard && (
<div>New Dashboard Content</div>
)}
<button onClick$={() => toggleFlag('newDashboard')}>
Toggle New Dashboard
</button>
</div>
);
});
This approach is powerful but should be used judiciously. Module-level signals persist across the entire application lifecycle and are not scoped to any particular route or component.
State Management Libraries for Qwik
While Qwik's built-in primitives are sufficient for most use cases, several libraries provide additional abstractions for complex state management scenarios.
Qwik-Auth and Server State
When dealing with server-side state, Qwik City provides route loaders and actions that integrate seamlessly with Qwik's reactivity. For data fetching, the routeLoader$ and useResource$ patterns are the recommended approach.
import {
component$,
useResource$,
Resource,
} from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useUserData = routeLoader$(async (requestEvent) => {
const res = await fetch(
`https://api.example.com/users/${requestEvent.params.id}`
);
return res.json();
});
export default component$(() => {
const userData = useUserData();
const postsResource = useResource$(async ({ track }) => {
// This resource depends on the loaded user data
const user = userData.value;
if (!user) return [];
const res = await fetch(
`https://api.example.com/users/${user.id}/posts`
);
return res.json();
});
return (
<div>
<h1>{userData.value?.name}</h1>
<Resource
value={postsResource}
onPending={() => <p>Loading posts...</p>}
onRejected={() => <p>Failed to load posts</p>}
onResolved={(posts) => (
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
/>
</div>
);
});
Using Third-Party State Libraries
Some developers prefer established state libraries. While libraries like Redux or Zustand were designed for React, Qwik's signal-based system can interoperate with them through adapters or by wrapping external stores in Qwik signals.
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
import { createStore } from 'redux';
// A simple Redux-like store
function counterReducer(state = { count: 0 }, action: any) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state;
}
}
const reduxStore = createStore(counterReducer);
export default component$(() => {
// Bridge Redux state to a Qwik signal
const state = useSignal(reduxStore.getState());
useTask$(() => {
const unsubscribe = reduxStore.subscribe(() => {
state.value = reduxStore.getState();
});
return () => unsubscribe();
});
return (
<div>
<p>Count: {state.value.count}</p>
<button onClick$={() => reduxStore.dispatch({ type: 'INCREMENT' })}>
+
</button>
<button onClick$={() => reduxStore.dispatch({ type: 'DECREMENT' })}>
-
</button>
</div>
);
});
While this bridging approach works, it's generally recommended to lean into Qwik's native primitives. They are optimized for Qwik's resumability model and avoid the overhead of syncing two reactivity systems.
Best Practices
To get the most out of Qwik's state management, keep these principles in mind:
- Prefer signals for primitive values. Signals have lower overhead than stores and are perfect for single values like counters, toggles, and text inputs.
- Use stores for grouped, related state. When multiple properties logically belong together, a store keeps them organized and ensures consistent reactivity.
- Keep state as local as possible. Don't lift state to a global context unless multiple distant components truly need it. Local state stays in Qwik's lazy-loading boundaries and minimizes JavaScript downloads.
- Use shallow stores when deep reactivity isn't needed. Passing
{ deep: false }touseStoreavoids the overhead of proxying nested objects. - Leverage
useTask$for side effects, not for deriving state. UseuseComputed$for derived values anduseTask$for effects like fetching data or updating the DOM. - Always define context IDs at module level. Defining
createContextIdinside a component creates a new context on every render, breaking the provider-consumer relationship. - Serialize only what's necessary. Since Qwik serializes state into HTML, avoid storing non-serializable values like functions or DOM references in stores. Use
useSignalfor transient, non-serialized values when appropriate. - Type your stores and contexts. Qwik's TypeScript support is excellent. Always provide type parameters to
useStoreandcreateContextIdto catch errors at compile time.
Common Pitfalls and How to Avoid Them
Mutating Store Objects Incorrectly
Qwik stores use proxies to track mutations. Replacing an entire object instead of mutating a property can break reactivity in some cases. Always mutate properties directly:
// โ Avoid: replacing the whole object
store.value = { ...store.value, count: store.value.count + 1 };
// โ
Better: mutate the property directly
store.count = store.count + 1;
Forgetting to Track in useTask$
If you read a signal inside useTask$ without wrapping it in track(), the task won't re-run when that signal changes:
useTask$(({ track }) => {
// โ Won't trigger re-runs
const query = searchQuery.value;
// โ
Will trigger re-runs
const trackedQuery = track(() => searchQuery.value);
});
Overusing Global State
It's tempting to put everything in a global context for convenience, but this defeats Qwik's lazy-loading benefits. Components that consume global state pull in that state's serialization, potentially increasing the initial HTML payload. Reserve global state for truly cross-cutting concerns like authentication, theme, and locale.
Conclusion
State management in Qwik is built around fine-grained reactivity and resumability, offering a refreshingly simple yet powerful model. By mastering useSignal, useStore, useContext, useComputed$, and useTask$, you can handle everything from local component state to application-wide data flows without heavy external libraries. The key is to embrace Qwik's philosophy: keep state local when possible, track dependencies explicitly, and let the framework's lazy execution handle the rest. As your application grows, the patterns and practices outlined here will help you maintain a clean, performant, and maintainable state architecture that takes full advantage of everything Qwik has to offer.