โ† Back to DevBytes

State Management in Qwik: Patterns and Libraries

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:

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles