State management in React in 2026 is unusual in that the right answer is often the smallest answer. Server Components moved a lot of data-fetching concerns off the client, React Query handles server cache, and the category people still call state management is really a shrinking island of client-only state: modal open/closed, filter widgets, optimistic updates, multi-step forms. That shift matters because the library tax for a 5-page SaaS dashboard now looks different than it did three years ago. This post compares Zustand, Jotai, Redux Toolkit, and React Context on bundle size, DX, and test ergonomics, shows the same mini-feature in each, and names the cases where each one actually wins.
The four options, side by side
Headline numbers first, then the nuance. Sizes are min+gzip from current package metadata; memory and parse numbers come from benchmarks with 1,000 subscribed components.
| Library | Bundle (gzip) | Mental model | Best at | Watch out for |
|---|---|---|---|---|
| Zustand 5 | ~3 KB | Single store, selector subscriptions | Most SaaS dashboards — tiny, fast, familiar | Thin conventions; teams must agree on structure |
| Jotai 2 | ~4 KB | Atomic state, derived atoms | Fine-grained reactivity, complex derived state | Atom sprawl in large apps without discipline |
| Redux Toolkit 2 | ~14 KB | Single store, slices, reducers, RTK Query | Large teams, complex domains, audit trails | Boilerplate tax on small apps |
| React Context | 0 KB (built in) | Provider tree, plain hooks | Stable values: theme, auth, locale | Re-renders every consumer on any change |
Bundle size matters, but it is rarely the tiebreaker. The real decision is about conventions and team size. A 14 KB library that enforces a pattern often costs less than a 3 KB library that does not — if you have eight developers and a two-year product horizon.
When Zustand wins — and why that is most SaaS dashboards
Zustand is the default pick for the SaaS work the Vertex Stack team sees most often: a signed-in dashboard with filters, modals, a sidebar, a few optimistic mutations, and React Query handling the server. The store is a plain object, selectors are one line each, and the devtools integration is good enough to debug production issues without ceremony. The test story is the best of the four — the store is just a function call, and you can spin up a fresh one per test with no provider wiring.
// zustand — a filter store for a SaaS dashboard
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type Filters = {
status: "all" | "open" | "closed";
assignee: string | null;
setStatus: (s: Filters["status"]) => void;
setAssignee: (id: string | null) => void;
reset: () => void;
};
export const useFilters = create<Filters>()(
devtools((set) => ({
status: "all",
assignee: null,
setStatus: (status) => set({ status }),
setAssignee: (assignee) => set({ assignee }),
reset: () => set({ status: "all", assignee: null }),
}))
);
// In a component — only re-renders when `status` changes
const status = useFilters((s) => s.status);Two things to note in that snippet. First, selector subscriptions mean the status chip doesn't re-render when the assignee changes — a thing React Context cannot do without splitting the provider. Second, the store lives outside the React tree, so a test can import it, call `useFilters.setState({ status: "open" })`, and assert behavior without mounting anything.
When Jotai wins — atomic state and derived values
Jotai's pitch is that state is a graph of atoms, not a single object. That pays off in two situations: when derived state has to stay in sync automatically, and when the UI needs fine-grained reactivity across dozens of small pieces — think a spreadsheet-like UI, a diagram editor, or a form with 40 interdependent fields. For a dashboard, atoms often feel like overkill. For a surface where derived values fan out in several directions, they prevent the bug where one piece of state updated and another forgot to.
// jotai — the same filter feature, atomic
import { atom, useAtom, useAtomValue } from "jotai";
export const statusAtom = atom<"all" | "open" | "closed">("all");
export const assigneeAtom = atom<string | null>(null);
// A derived atom that stays in sync automatically
export const filterSummaryAtom = atom((get) => {
const status = get(statusAtom);
const assignee = get(assigneeAtom);
return `status=${status}${assignee ? ` · assignee=${assignee}` : ""}`;
});
// In a component
const [status, setStatus] = useAtom(statusAtom);
const summary = useAtomValue(filterSummaryAtom);That derived atom is the thing worth stealing. In Zustand you'd compute the summary inside a selector and memoize carefully; in Jotai it's declarative and reactive by default. For small apps that's a rounding error. For editor-style UIs with a web of derived state, it's the whole reason to be here.
When Redux Toolkit wins — big teams, complex domains
Redux Toolkit earns its bundle weight when three conditions hold at once: the team is large enough that conventions need to be enforced, the domain is complex enough that "flexible" becomes a liability, and the product has to stay maintainable for years. A slice defines all the state transitions for one concern in one file; RTK Query replaces half of a data-fetching layer; Redux DevTools provide time-travel debugging that has saved more than one production incident. The boilerplate that feels like friction in a three-person codebase is the same boilerplate that makes a 30-person codebase stay legible.
// redux toolkit — the same filter feature as a slice
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type FiltersState = {
status: "all" | "open" | "closed";
assignee: string | null;
};
const initialState: FiltersState = { status: "all", assignee: null };
export const filtersSlice = createSlice({
name: "filters",
initialState,
reducers: {
setStatus: (state, action: PayloadAction<FiltersState["status"]>) => {
state.status = action.payload;
},
setAssignee: (state, action: PayloadAction<string | null>) => {
state.assignee = action.payload;
},
reset: () => initialState,
},
});
export const { setStatus, setAssignee, reset } = filtersSlice.actions;
export default filtersSlice.reducer;That is more code than the Zustand version — and that is the point. Every mutation is named, typed, logged, and time-travel debuggable. On a team of twelve, that structure prevents the kind of drift where two developers invent slightly different ways to update the same field.
When Context is genuinely enough
React Context is not a state management library — it is a dependency-injection channel. It is the right tool for values that rarely change and fan out wide: current user, theme, locale, feature flags, the current tenant in a multi-tenant app. It is the wrong tool for values that change often, because every consumer re-renders on every change and there is no selector to stop that.
// context — the right shape for stable values
type AuthCtx = { user: User | null; signOut: () => void };
const AuthContext = createContext<AuthCtx | null>(null);
export function AuthProvider({ children, value }: {
children: React.ReactNode;
value: AuthCtx;
}) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}If you reach for Context to hold a filter UI's state, a counter, or anything that changes more than a few times per session, every consumer re-renders on every change. That is where performance regressions sneak in. Use Context for identity-of-the-world values; use a state library for reactive UI state.
Test ergonomics — the hidden tiebreaker
A library's test story is often more important than its bundle size, because it compounds over hundreds of tests. Zustand's store is the easiest — import it, set state, assert, done. Jotai requires a `Provider` in tests for isolation, which is fine but adds a line. Redux Toolkit needs a configured store per test, which is ceremony but the ceremony is well-trodden. Context depends entirely on how you wrote the provider.
- Zustand — import the hook, call `useFilters.setState`, no provider required. Best-in-class.
- Jotai — wrap components in a `Provider`, use `useHydrateAtoms` to seed atoms for a test. Solid but verbose.
- Redux Toolkit — construct a store with `configureStore`, wrap in `<Provider store={store}>`. Familiar if you have done it before.
- Context — depends on the provider's shape; usually fine, occasionally a maze when providers are nested three deep.
The decision framework we actually use
Skip the benchmark chasing. Answer these four questions and the choice usually names itself:
- How many developers will touch this code in the next 18 months? One to five pushes toward Zustand or Jotai. Six or more pushes toward Redux Toolkit.
- Is the state a flat bag or a graph of derived values? Flat bag → Zustand. Graph with heavy derivation → Jotai.
- Is the value stable and wide-fanning, or reactive and narrow? Stable and wide → Context. Reactive → a state library.
- How much of the state is actually server data? If the answer is "most," React Query or SWR is the real answer, and the client-state library only needs to handle the rest.
Resist the instinct to pick a state library on day one. Start with React Query for server state and `useState` for local state. Introduce Zustand the day you have a legitimate shared-client-state problem — usually around week three. Most SaaS products never need more than that.
Key takeaways
- Zustand is the pragmatic default for SaaS dashboards — 3 KB, selector subscriptions, the best test story of the four.
- Jotai earns its place when derived state fans out in several directions — editor-style UIs, complex forms, diagram tooling.
- Redux Toolkit is still the right call for large teams and complex domains where enforced conventions pay off.
- Context is for stable, identity-of-the-world values — auth, theme, locale. It is not a state management library.
- Most of what used to be state management is now server cache. Pick React Query first; the client-state library is the remainder.