Redux Toolkit, Zustand or Context: where React state belongs
Server state, shared client state, local UI state and URL state each have a natural home. When to use TanStack Query, Redux Toolkit, Zustand or Context.
By M. Adnan Saleem · · 7 min read
I have shipped Redux Toolkit on Qatar's national event platforms and on a generative AI analytics product, and Zustand on a payments platform. None of them was the wrong pick, because each was chosen for the kind of state it held. The question "Redux or Zustand?" usually has a better question underneath it: who owns this state, and who needs to know when it changes?
Four kinds of state, not one
Most React apps hold four different kinds of state, and each has a natural home:
- Server state. Data that lives in a database and is only borrowed by the browser: orders, users, search results. It goes stale, needs refetching and is shared by many screens.
- Shared client state. Data that exists only in the browser but matters to several components: a cart, dashboard filters, a multi-step form in progress.
- Local UI state. Whether a menu is open, what is typed in an input. One component owns it.
- URL state. The tab, page or filter a user might bookmark or share.
Most arguments about state libraries come from putting the first kind into a tool built for the second.
Server state belongs in a cache
Copying API responses into a global store means writing loading flags, error flags, refetching and invalidation by hand, for every endpoint. A server-state library does that work once. With TanStack Query, the cache key decides when two components share one request:
import { useQuery } from '@tanstack/react-query';
function Orders({ status }: { status: string }) {
const { data, isPending, isError } = useQuery({
queryKey: ['orders', status], // the cache key: same key, same request
queryFn: () => fetchOrders(status),
});
if (isPending) return <p>Loading</p>;
if (isError) return <p>Could not load orders</p>;
return <OrderTable rows={data} />;
}Its own documentation is clear that it does not replace client state management. It removes the largest share of what used to sit in Redux, and what is left is usually small. If an app already uses Redux Toolkit, RTK Query does the same job inside the same store.
Zustand: shared state with little ceremony
Zustand is a store created with one function. Components subscribe to the slice they select, and re-render only when that slice changes:
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
type Filters = {
range: '24h' | '7d' | '30d';
region: string;
setRange: (range: Filters['range']) => void;
setRegion: (region: string) => void;
};
export const useFilters = create<Filters>()((set) => ({
range: '7d',
region: 'all',
setRange: (range) => set({ range }),
setRegion: (region) => set({ region }),
}));
function RangeLabel() {
// Re-renders only when range changes, not when region does
const range = useFilters((state) => state.range);
return <span>{range}</span>;
}
function RegionPicker() {
// Several values at once: useShallow keeps the pair stable between renders
const [region, setRegion] = useFilters(
useShallow((state) => [state.region, state.setRegion] as const),
);
return <RegionSelect value={region} onChange={setRegion} />;
}The selector is the part to get right. A selector that builds a new array or object returns a different value every time, so the component re-renders on every store change. In Zustand 5 it can be worse: the library's migration guide warns that such a selector may cause an infinite loop, which shows up as React's "Maximum update depth exceeded" error. useShallow compares the contents instead of the reference and fixes both.
Redux Toolkit: when changes need a paper trail
Redux Toolkit costs more code than Zustand and buys structure for it. Every change is a named action passing through one reducer, so the Redux DevTools show what happened, in which order, and what the state was after each step. That pays off when many screens write to the same state, or when someone has to explain a state bug from a production report.
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
type Cart = { items: { id: string; quantity: number }[] };
const initialState: Cart = { items: [] };
const cart = createSlice({
name: 'cart',
initialState,
reducers: {
itemAdded: (state, action: PayloadAction<string>) => {
const item = state.items.find((i) => i.id === action.payload);
if (item) item.quantity += 1; // Immer turns this into an immutable update
else state.items.push({ id: action.payload, quantity: 1 });
},
itemRemoved: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const { itemAdded, itemRemoved } = cart.actions;
export const store = configureStore({ reducer: { cart: cart.reducer } });
export type RootState = ReturnType<typeof store.getState>;
export const useAppDispatch = useDispatch.withTypes<typeof store.dispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();createSlice writes the action creators, and Immer lets reducers look like mutations while producing immutable updates. The typed hooks at the bottom are what the Redux documentation recommends using everywhere instead of plain useDispatch and useSelector.
Context: for values that rarely change
Context is not a state manager; it is a way to pass a value down without threading props. When the value changes, React re-renders every component that reads it, and wrapping those components in memo does not stop it. That is exactly right for a theme, a locale or the signed-in user, and expensive for anything that changes on every keystroke.
type Auth = { user: User | null; signOut: () => void };
const AuthContext = createContext<Auth | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const signOut = useCallback(() => setUser(null), []);
// A new object on every render would re-render every consumer; memoize it
const value = useMemo(() => ({ user, signOut }), [user, signOut]);
return <AuthContext value={value}>{children}</AuthContext>;
}The useMemo matters. Without it, the provider passes a new object whenever it re-renders, and every consumer re-renders with it even when the user has not changed. The example uses React 19, where the context itself is the provider; on React 18 write AuthContext.Provider.
Keep shareable state in the URL
A selected tab or an applied filter that lives only in a store disappears on reload and cannot be sent to a colleague. Search parameters already do both:
// Inside a component: the URL already is shared state.
// It survives a reload and can be sent to a colleague.
const [params, setParams] = useSearchParams();
const tab = params.get('tab') ?? 'overview';
const showTab = (next: string) => setParams({ tab: next });The example uses React Router's hook; Next.js has its own useSearchParams and router for the same job.
Choosing, in one table
| State | Start with | Why |
|---|---|---|
| Data from an API | TanStack Query, or RTK Query on Redux | Caching, refetching and shared requests come built in. |
| Shared client state, many writers | Redux Toolkit | Named actions and DevTools history make changes traceable. |
| Shared client state, few writers | Zustand | Selector subscriptions with very little setup. |
| App-wide values that rarely change | Context | No library needed; every reader updates together. |
| One component's state | useState or useReducer | Nothing else needs to know. |
| Bookmarkable view state | URL search parameters | Survives a reload and can be shared. |
Two of these in one app is normal. A server cache plus one small client store covers most products, and the rows above can be adopted one at a time.
Mistakes I would check for first
- API data copied into a global store. It goes stale silently. Move it to a server-state cache and delete the loading flags.
- One large Context holding fast-changing values. Every consumer re-renders on every change. Split it, or move the fast part to a store with selectors.
- Selectors that build new objects. Return primitives, or wrap the selector in
useShallowin Zustand orcreateSelectorin Redux. - Derived values stored as state. A total that is saved next to the items it sums will eventually disagree with them. Compute it in a selector.
- Every keystroke in a global store. Keep form input local until it is submitted, unless another screen really needs it live.
Large lists are their own problem regardless of where the state lives; rendering 20,000 rows without freezing the page covers that part.
Quick answers
The short version
Should I use Redux Toolkit or Zustand?
Zustand when a few components share client state and you want little setup. Redux Toolkit when many screens write to the same state and you need named actions and DevTools history to trace changes.
Is React Context a replacement for Redux?
No. Context passes a value down the tree, and every component that reads it re-renders when it changes. It suits values that rarely change, such as a theme, a locale or the signed-in user.
Where should data from an API live?
In a server-state cache such as TanStack Query, or RTK Query if the app already uses Redux Toolkit. They handle caching, refetching and shared requests, which a client store makes you write by hand.
Why does my Zustand selector cause an infinite loop?
In Zustand 5, a selector that returns a new array or object on every call can trigger React's "Maximum update depth exceeded" error. Return primitives, or wrap the selector in useShallow.
Can Redux and Zustand be used in the same app?
Yes. Each store owns different state, and many apps pair a server-state cache with one small client store. What matters is that each piece of state has one owner.
Keep reading
Related articles
- A real-time React dashboard that keeps up with WebSocketsWhy a live dashboard slows down over time, and the fixes: render once per frame, cap the list, aggregate as events arrive, and plan for reconnects.
- React micro frontends with Module Federation: split a monolithWhen micro frontends are worth it, how a shell and its remotes fit together, and the contracts that keep independent deploys from breaking each other.
- Rendering 20,000 rows in React without freezing the pageA reproducible React virtualization benchmark with 20,000 synthetic rows, raw runs, median filter delays, fixed-height windowing code and measurement limits.
The project behind this
Read the case study
What I built, where, and the numbers that came out of it.