← Back to DevBytes

State Management in Turbopack: Patterns and Libraries

State Management in Turbopack: Patterns and Libraries

Turbopack is Vercel's Rust-based bundler, designed as a high-performance successor to Webpack and tightly integrated into Next.js. While Turbopack itself is a build tool β€” not a state management system β€” the way it bundles, tree-shakes, and hot-reloads your application has a direct impact on how state management libraries behave, bundle, and perform. This tutorial covers the practical patterns and libraries you should consider when building stateful applications on top of a Turbopack-powered Next.js project.

Why State Management Matters in a Turbopack Workflow

State management determines how data flows through your UI, how components re-render, and how predictable your application is as it scales. When you adopt Turbopack, three things change compared to a traditional Webpack setup:

Choosing the right pattern β€” and the right library β€” keeps your bundle lean and your re-renders predictable.

Core State Management Patterns

1. Local Component State

The simplest and often best pattern. Use React's useState and useReducer for state that only one component (and perhaps its direct children) needs to know about.

"use client";

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Count: {count}
    </button>
  );
}

This pattern has zero runtime overhead and is fully compatible with Turbopack's HMR. Prefer it until you have a concrete reason to reach for something larger.

2. Lifted State and Context

When several components need to share state, lift it to a common ancestor and pass it down via React Context. This avoids prop drilling without adding a dependency.

"use client";

import { createContext, useContext, useMemo, useState } from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
  return ctx;
}

Be aware that any consumer of a Context re-renders when the Context value changes. For frequently-updating state, split contexts or use a selector-based library.

3. External Stores

For state that updates often, spans many components, or needs to live outside the React tree, use an external store with a subscription model. React's useSyncExternalStore hook is the canonical bridge.

"use client";

import { useSyncExternalStore } from "react";

function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (next) => {
      state = typeof next === "function" ? next(state) : next;
      listeners.forEach((l) => l());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const store = createStore({ count: 0 });

export function useStore() {
  return useSyncExternalStore(
    store.subscribe,
    store.getState,
    store.getState
  );
}

export const increment = () =>
  store.setState((s) => ({ ...s, count: s.count + 1 }));

This is the same primitive that libraries like Zustand and Jotai build on. Understanding it helps you debug re-render issues and write your own minimal stores.

4. Server State vs. Client State

In the Next.js App Router β€” which Turbopack compiles β€” you must distinguish between server state (data fetched on the server, cached, and streamed to the client) and client state (interactions, form inputs, UI flags). Mixing them leads to hydration mismatches and stale data.

A good rule of thumb: fetch data in Server Components, pass it down as props, and only mutate it on the client through explicit actions or a server-state library.

Popular State Management Libraries

Zustand

Zustand is a tiny, hook-based store with no boilerplate. It pairs exceptionally well with Turbopack because its small surface area tree-shakes cleanly and HMR works without special configuration.

"use client";

import { create } from "zustand";

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((i) => i.id !== id),
    })),
  clear: () => set({ items: [] }),
}));

export function Cart() {
  const items = useCartStore((s) => s.items);
  const addItem = useCartStore((s) => s.addItem);

  return (
    <div>
      <ul>
        {items.map((i) => (
          <li key={i.id}>{i.name}</li>
        ))}
      </ul>
      <button onClick={() => addItem({ id: Date.now(), name: "Widget" })}>
        Add Widget
      </button>
    </div>
  );
}

The selector pattern useCartStore((s) => s.items) ensures only components that depend on items re-render when the store updates.

Jotai

Jotai takes an atomic approach: you define small, composable units of state called atoms, and components subscribe only to the atoms they need. This is ideal for fine-grained reactivity.

"use client";

import { atom, useAtom, useAtomValue } from "jotai";

const fontSizeAtom = atom(14);
const textAtom = atom("Hello, Turbopack");

export function FontControls() {
  const [fontSize, setFontSize] = useAtom(fontSizeAtom);
  return (
    <input
      type="range"
      min="10"
      max="32"
      value={fontSize}
      onChange={(e) => setFontSize(Number(e.target.value))}
    />
  );
}

export function Display() {
  const fontSize = useAtomValue(fontSizeAtom);
  const text = useAtomValue(textAtom);
  return <p style={{ fontSize }}>{text}</p>;
}

Because atoms are independent, Turbopack can split and tree-shake them effectively, and HMR preserves atom values across edits when configured with the Jotai babel or SWC plugin.

Redux Toolkit

For large applications with complex domain logic, Redux Toolkit (RTK) remains a solid choice. It provides slices, middleware, and devtools out of the box. With Turbopack, the main consideration is ensuring the SWC-compatible setup is used rather than legacy Babel plugins.

"use client";

import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";

const counterSlice = createSlice({
  name: "counter",
  initialState: { value: 0 },
  reducers: {
    incremented: (state) => { state.value += 1; },
    decremented: (state) => { state.value -= 1; },
  },
});

const { incremented, decremented } = counterSlice.actions;

const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

export function CounterApp() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

function Counter() {
  const value = useSelector((s) => s.counter.value);
  const dispatch = useDispatch();
  return (
    <div>
      <button onClick={() => dispatch(decremented())}>-</button>
      <span>{value}</span>
      <button onClick={() => dispatch(incremented())}>+</button>
    </div>
  );
}

TanStack Query for Server State

Most "state" in real apps is actually cached server data. TanStack Query handles fetching, caching, invalidation, and optimistic updates so you don't reinvent them with a client store.

"use client";

import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

async function fetchUser() {
  const res = await fetch("/api/user");
  if (!res.ok) throw new Error("Failed to load user");
  return res.json();
}

export function UserApp({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

export function UserProfile() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user"],
    queryFn: fetchUser,
  });

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <p>Hello, {data.name}</p>;
}

Pair TanStack Query with a lightweight client store like Zustand for UI-only state, and you cover the vast majority of real-world needs.

Best Practices

Conclusion

State management in a Turbopack-powered application is, at its core, the same discipline as in any modern React app β€” but Turbopack's speed, stricter module graph, and tight App Router integration make the consequences of your choices more visible. Start with local state and Context, introduce an external store like Zustand or Jotai when re-renders become a problem, lean on Redux Toolkit for complex domains, and reserve TanStack Query for server data. By keeping server and client state separate, subscribing narrowly with selectors, and marking client boundaries explicitly, you'll get an application that is fast to build, predictable to reason about, and ready to scale alongside Turbopack's evolving capabilities.

πŸ›  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