React9 min read
React State Management in 2026: Zustand vs Redux vs Context
React state management in 2026: when to use Context, Zustand, Redux Toolkit or TanStack Query, and why splitting server state from client state matters most.
The best React state management setup in 2026 is usually not one library but two decisions: put server state (data from your API) in TanStack Query, and keep client state (UI state) in useState, Context, or Zustand. Reach for Redux Toolkit when a large team needs strict conventions, middleware and time-travel debugging. Most "which library?" debates disappear once you stop storing API responses in a global store.
Key takeaways
- Split state into server state and client state first. The library choice follows from that split.
- TanStack Query (v5) handles caching, refetching, deduplication and mutations for server data. Do not rebuild that in Redux or Zustand.
- Context is a dependency injection tool, not a performance-tuned store. Use it for values that rarely change.
- Zustand 5 gives you a tiny global store with selector-based subscriptions and almost no boilerplate.
- Redux Toolkit 2.x still shines in big codebases that benefit from enforced patterns and Redux DevTools.
- Start with local
useState. Lift state only when two components actually need it.
Server state vs client state: the split that matters
Server state is a cached copy of data that lives somewhere else. You do not own it, it can go stale, and other users can change it. Examples: a product list, the logged-in user's orders, notifications.
Client state is owned by the browser session. Examples: whether a modal is open, the current step of a wizard, a selected tab, an unsaved filter.
Most messy React codebases I have reviewed mix these two. A Redux slice holds products, isLoading, error, and lastFetched, plus hand-written thunks to refetch. That is a cache, and you are maintaining it by hand. TanStack Query exists to do exactly that job, with stale times, background refetching, retries and request deduplication built in. I go deeper on this in the TanStack Query data fetching guide.
Once server state moves out, what is left is usually small. Often small enough that useState and one or two contexts cover it.
React Context vs Zustand vs Redux Toolkit vs TanStack Query
| Tool | Best for | Re-render behavior | Boilerplate | Devtools |
|---|---|---|---|---|
useState / useReducer |
State used by one component or a small subtree | Only the owning component and its children | None | React DevTools |
| Context | Rarely changing values: theme, locale, auth user | Every consumer re-renders when the value changes | Low | React DevTools |
| Zustand 5 | Shared client state that changes often | Only components whose selected slice changed | Very low | Optional devtools middleware |
| Redux Toolkit 2 | Large apps, many contributors, complex workflows | Only components whose useSelector result changed |
Medium | Redux DevTools (excellent) |
| TanStack Query 5 | Anything fetched from a server | Tracks which fields you read | Low | TanStack Query Devtools |
TanStack Query is in the table because people compare it to Redux, but it is not a general state manager. It is an async state cache. You can pair it with any of the others.
When React Context is enough
Context solves prop drilling. It does not optimise updates. When the value you pass to a provider changes, every component calling useContext (or use) for that context re-renders.
That is fine for a theme toggle that changes twice a session. It is not fine for a value that updates on every keystroke.
import { createContext, use, useMemo, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const value = useMemo(
() => ({
theme,
toggleTheme: () => setTheme((t) => (t === "light" ? "dark" : "light")),
}),
[theme],
);
return <ThemeContext value={value}>{children}</ThemeContext>;
}
export function useTheme() {
const ctx = use(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
return ctx;
}Two React 19 details here: you can render <ThemeContext> directly as the provider, and use(ThemeContext) works like useContext. The useMemo keeps the value object stable between unrelated parent renders. If you use the React Compiler, it can handle that memoization for you (see React Compiler and useMemo).
Context gotchas
- One giant AppContext. Putting user, cart, theme and modals into one context means a cart update re-renders the theme switcher. Split contexts by how often they change.
- New object every render.
value={{ theme, setTheme }}without memoization creates a new object each render, so every consumer re-renders. - Using Context as a cache for API data. You lose stale-while-revalidate, retries and deduplication.
Zustand: shared client state without the ceremony
Zustand 5 gives you a hook-based store. Components subscribe with a selector and only re-render when the selected value changes.
npm install zustandimport { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
type Filters = {
search: string;
category: string | null;
sidebarOpen: boolean;
setSearch: (search: string) => void;
setCategory: (category: string | null) => void;
toggleSidebar: () => void;
reset: () => void;
};
const initial = { search: "", category: null, sidebarOpen: true };
export const useFilterStore = create<Filters>()((set) => ({
...initial,
setSearch: (search) => set({ search }),
setCategory: (category) => set({ category }),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
reset: () => set(initial),
}));
// Subscribes only to `search`
export function SearchBox() {
const search = useFilterStore((s) => s.search);
const setSearch = useFilterStore((s) => s.setSearch);
return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}
// Selecting several values: wrap with useShallow
export function ActiveFilters() {
const { search, category } = useFilterStore(
useShallow((s) => ({ search: s.search, category: s.category })),
);
return (
<p>
{search || "All"} in {category ?? "every category"}
</p>
);
}Note the curried create<Filters>()(...) call. That extra () is what the Zustand docs recommend for TypeScript so middleware types infer correctly.
Zustand gotchas
- Selecting the whole store.
const store = useFilterStore()subscribes to everything, which defeats the point. - Returning a new object from a selector without
useShallow. In Zustand 5 this can cause an infinite render loop, because the selector returns a new reference every time. UseuseShallowor select primitives one by one. - Storing fetched data in Zustand. Same problem as Context: you end up hand-writing a worse cache.
Redux Toolkit: when structure is the feature
Redux Toolkit (RTK) 2.x is what "Redux" means today. createSlice removes most of the old boilerplate, Immer lets you write "mutating" reducers safely, and RTK Query gives you a data-fetching layer if you prefer to stay inside Redux.
import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
import { Provider, useDispatch, useSelector } from "react-redux";
import type { ReactNode } from "react";
type CartItem = { id: string; name: string; qty: number };
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] as CartItem[] },
reducers: {
addItem(state, action: PayloadAction<Omit<CartItem, "qty">>) {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) existing.qty += 1;
else state.items.push({ ...action.payload, qty: 1 });
},
removeItem(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export const store = configureStore({
reducer: { cart: cartSlice.reducer },
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export function StoreProvider({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
export function CartCount() {
const count = useAppSelector((s) =>
s.cart.items.reduce((sum, i) => sum + i.qty, 0),
);
return <span>{count}</span>;
}Why pick this over Zustand? Not performance. Pick it for:
- Enforced patterns. Actions, reducers and selectors look the same across 40 feature folders and 15 developers.
- Redux DevTools. Action logs and time-travel debugging are genuinely useful for complex flows like checkout or multi-step approvals.
- Middleware and listeners.
createListenerMiddlewarehandles side effects in response to actions. - An existing Redux codebase. Migrating a working Redux app to something else rarely pays for itself.
On a service booking platform I worked on, we used both Redux and Zustand in the same app. That mix is fine as long as each piece of state has one clear owner and the team agrees on which tool holds what.
Redux gotchas
- Selectors that build new arrays or objects.
useSelector((s) => s.cart.items.filter(...))returns a new array every time and re-renders on every store update. UsecreateSelectorfor derived data. - Server data in slices. If you are on Redux, use RTK Query instead of hand-written thunks with
isLoadingflags. - Legacy
createStoretutorials. UseconfigureStore. Old patterns withswitchreducers and action type constants are not needed.
TanStack Query for server state
Here is the piece that removes most global state from a typical app:
import { useQuery } from "@tanstack/react-query";
type Product = { id: string; name: string; price: number };
async function fetchProducts(category: string | null): Promise<Product[]> {
const url = category ? `/api/products?category=${category}` : "/api/products";
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
export function ProductList({ category }: { category: string | null }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["products", category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Notice how the Zustand category filter (client state) feeds into the query key. Change the filter, and TanStack Query fetches and caches the new list automatically. That is the pattern: client state drives query keys, server state lives in the query cache.
If you are on Next.js App Router, React Server Components also reduce client state, because a lot of data never needs to reach the client as state at all. See React Server Components explained.
A decision guide you can actually use
Ask these questions in order:
- Does the data come from a server? Use TanStack Query (or RTK Query if you are already on Redux).
- Is it used by one component or a small subtree? Use
useStateoruseReducer. Lift it up only as far as needed. - Does it change rarely and need to reach many components? Use Context.
- Does it change often and get read in many places? Use Zustand.
- Is it a large app with many teams, complex workflows, and a need for strict conventions or time-travel debugging? Use Redux Toolkit.
Also consider the URL. Search terms, pagination and active tabs often belong in query parameters, which makes them shareable and survives a refresh. That is state management too, and it costs you nothing.
Common mistakes in React state management
- Choosing the library before mapping the state. List every piece of state and label it server or client first.
- Duplicating server data into client stores. Two sources of truth will drift apart.
- Global by default. A form's draft input does not need to live in a global store.
- Derived state stored as state. If
totalcan be calculated fromitems, calculate it during render. Storing it invites bugs and extra effects (more in you might not need an effect). - Ignoring re-render cost until it hurts. Use the React DevTools Profiler to check which components re-render when state changes.
For the official reasoning behind keeping state minimal, the React docs page on choosing the state structure is worth a read.
FAQ
What is the best state management library for React in 2026?
There is no single best library. Use TanStack Query for server data, useState or Context for small amounts of local and shared UI state, Zustand for shared client state with frequent updates, and Redux Toolkit when a large team needs strict structure and devtools-driven debugging.
Is Redux still worth learning in 2026?
Yes, if you work on large or long-lived codebases. Redux Toolkit 2.x is actively maintained, many existing apps use it, and RTK Query covers data fetching. For new small and medium apps, Zustand plus TanStack Query is usually less code.
Should I use React Context instead of Zustand?
Use Context for values that change rarely, like theme, locale or the current user. Every consumer of a context re-renders when its value changes, so for state that updates often and is read by many components, Zustand with selectors is a better fit.
Can I use Zustand and TanStack Query together?
Yes, and it is a common pairing. TanStack Query owns everything that comes from the server (caching, refetching, mutations), and Zustand holds only client state such as UI toggles, filters and wizard steps.
Wrapping up
Separate server state from client state, and most of the library debate goes away. TanStack Query takes the server side, and useState, Context or Zustand covers the rest. Keep Redux Toolkit for the apps where its structure pays for itself, and do not feel bad about mixing tools as long as every piece of state has one owner.
- #react
- #state-management
- #zustand
- #redux
- #tanstack-query
Keep reading
React7 min read
TanStack Query Guide: React Data Fetching Done Right
A practical TanStack Query (React Query v5) guide: query keys, caching with staleTime and gcTime, mutations, optimistic updates, pagination and infinite scroll.
Read articleReact7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
Read articleReact8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read article