Five kinds of state you should not mix
When people say "state management" they are bundling several different problems under one name. The table below helps you send each one to the right place:
| Kind | Example | Suitable tool |
|---|---|---|
| Local | Menu open/closed, an input value | useState |
| Shared | A tab selection read by two components | Lift state to the parent |
| URL | Filter, sort order, page number | searchParams / router |
| Server | Product list, user profile | TanStack Query or SWR |
| Global client | Cart, multi-step form draft | Zustand, Redux Toolkit |
A large share of the "state problem" disappears with this split: pull server data out of the global store and see how little is left. The official react.dev docs recommend the same approach.

Start with local state
useState covers most needs, and its best property is that the state disappears when the component does. Only when two sibling components need the same value should you move it up to the nearest common parent.
Do not store derived state
One of the most common bugs is keeping a computed value in separate state and forgetting to sync it. Compute it during render instead:
// Bad: two sources of truth
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
// Good: total is derived
const [items, setItems] = useState<Item[]>([]);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);If the calculation is genuinely heavy, useMemo can help, but measure first; most calculations are so cheap that memoisation only adds noise.
Context and useReducer: when and how
Context is a delivery mechanism, not a state manager. Whenever the Provider value changes, every consumer re-renders; that is fine for theme, language or the signed-in user, but trouble for a value that changes several times a second.
When update logic has several branches (a multi-step form, say), useReducer makes the flow explicit. Every change is a named action, which is easy to test:
type State = { step: number; email: string };
type Action = { type: 'next' } | { type: 'back' } | { type: 'setEmail'; value: string };
function reducer(s: State, a: Action): State {
switch (a.type) {
case 'next': return { ...s, step: Math.min(s.step + 1, 3) };
case 'back': return { ...s, step: Math.max(s.step - 1, 1) };
case 'setEmail': return { ...s, email: a.value };
}
}
const [state, dispatch] = useReducer(reducer, { step: 1, email: '' });Manage server data separately
API data is a copy of something the server owns. That copy goes stale, must be refetched, cached, and has loading and error states. Hand-writing all of that with useEffect and useState is verbose and full of race conditions. Libraries such as TanStack Query have solved this problem:
import { useQuery } from '@tanstack/react-query';
function Products({ page }: { page: number }) {
const { data, isPending, error } = useQuery({
queryKey: ['products', page],
queryFn: () => fetch(`/api/products?page=${page}`).then(r => r.json())
});
if (isPending) return <p>Loading…</p>;
if (error) return <p>Something went wrong.</p>;
return <ul>{data.items.map((p: any) => <li key={p.id}>{p.name}</li>)}</ul>;
}If you use a framework such as Next, much of the initial data fetching can move to the server; the details are in our Next.js 13 features article.
When do you really need a global store?
A global store is for client state shared between distant parts of the app that does not come from the server: a cart, open panels, an unsaved draft. Zustand does this with very little code, and components subscribe only to the slice they read:
import { create } from 'zustand';
type CartState = { ids: number[]; add: (id: number) => void; clear: () => void };
export const useCart = create<CartState>(set => ({
ids: [],
add: id => set(s => ({ ids: [...s.ids, id] })),
clear: () => set({ ids: [] })
}));
// Only re-renders when the count changes
const count = useCart(s => s.ids.length);Redux Toolkit is a valid choice too, especially for large teams that want uniform conventions and strong debugging tools. The choice between them is more about the team than the technology.
Checklist and common mistakes
- Before adding any store, ask: can this value come from the URL, the server, or other state?
- Keep filters and pagination in the URL so they are linkable and survive a refresh.
- Do not put large objects in Context; several small Contexts beat one big one.
- Never mutate state directly (
pushon a state array); create a new copy. - For overall component structure see component design principles, and for typing state see the TypeScript guide.
A sound state structure usually means less code and fewer renders. If you want us to review your project's front-end architecture, get in touch.
Frequently asked questions
Do I need Redux for every React project?
No. Most projects get by with useState, the URL and a server-state library. Redux or Zustand become useful when shared client state spans distant parts of the app.
Can Context replace a state library?
For rarely changing values such as theme or language, yes, but not for fast-changing state. Every value change re-renders all consumers, which limits scalability.
What is the difference between server state and client state?
Server state is a copy of data the server owns and it can go stale, like a product list. Client state exists only in the browser, like an open menu. Each has its own suitable tools.
Is Zustand better than Redux Toolkit?
Zustand is lighter with less boilerplate and suits small to mid-sized projects. Redux Toolkit has stricter conventions and stronger debugging tools that help larger teams.

Comments
No comments yet. Be the first to share your thoughts.