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:
- Faster HMR (Hot Module Replacement): State preservation during edits becomes more reliable, which changes how you reason about ephemeral UI state.
- Stricter module graph resolution: Tree-shaking is more aggressive, so importing entire state libraries can have a measurable cost if not done carefully.
- Tighter Next.js App Router integration: Server Components and Client Components require you to be explicit about where state lives.
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
- Start small. Reach for
useStateand Context first. Add a library only when you feel the pain of prop drilling or excessive re-renders. - Separate server and client state. Use TanStack Query (or RSC fetch + caching) for server data; use Zustand or Jotai for client interactions.
- Use selectors. Subscribe to the smallest slice of state possible to avoid unnecessary re-renders.
- Keep stores framework-agnostic where possible. Plain stores are easier to test and reuse outside React.
- Mark client components explicitly. In a Turbopack-compiled App Router, the
"use client"directive is what enables hooks and stores; omitting it causes build errors. - Avoid global singletons in Server Components. Server Components are shared across requests; a module-level store there can leak state between users.
- Profile bundle output. Run
next buildwith Turbopack and inspect the bundle to confirm your state library isn't pulling in unexpected code. - Preserve state across HMR. Most modern libraries do this automatically, but if you write a custom store, store values outside the module's hot-replaced scope or use the HMR lifecycle hooks.
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.