Articles › React
State Management in React: From useState to Context, Zustand and TanStack Query
A practical guide to choosing the right state tool in React — local state, lifting state, Context, reducers, Zustand, Redux Toolkit and server state with TanStack Query.
Every React application is ultimately a state machine. The hard question isn't how to store state — it's where. Put it too high and every keystroke re-renders half the app; put it too low and components can't share it; choose a heavy library too early and simple features drown in boilerplate. This guide gives you a decision framework, from useState to dedicated libraries.
The four kinds of state
Most confusion disappears once you classify the state you're dealing with:
| Kind | Examples | Best home |
|---|---|---|
| Local UI state | input value, modal open, active tab | useState in the component |
| Shared client state | theme, language, cart, auth user | lifted state, Context or a store |
| Server state | articles, products, user profile from an API | TanStack Query / SWR / framework loaders |
| URL state | filters, page number, selected tab you can share | the URL (search params) |
Each kind has different needs. Server state needs caching, refetching and invalidation; local state needs none of that.
1. Start local with useState
function NewsletterForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("idle"); // idle | sending | done | error
// ...
}
The most common mistake is making state global "just in case". State used by one component belongs in that component. It's easier to understand, test and delete.
2. Lift state up when siblings need it
When two components need the same data, move the state to their closest common parent and pass it down:
function ProductPage() {
const [quantity, setQuantity] = useState(1);
return (
<>
<QuantityPicker value={quantity} onChange={setQuantity} />
<PriceSummary quantity={quantity} />
</>
);
}
Lifting state is simple and explicit. It only becomes painful when props travel through many layers that don't use them ("prop drilling").
3. useReducer for complex transitions
When updates follow rules — a multi-step form, a cart with discounts — a reducer keeps transitions in one place:
function cartReducer(state, action) {
switch (action.type) {
case "add": {
const existing = state.items.find((i) => i.id === action.item.id);
const items = existing
? state.items.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i))
: [...state.items, { ...action.item, qty: 1 }];
return { ...state, items };
}
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "clear":
return { ...state, items: [] };
default:
return state;
}
}
4. Context for rarely-changing global data
Context removes prop drilling for data many components read:
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, login: setUser, logout: () => setUser(null) }), [user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext);
The catch: every consumer re-renders when the context value changes. Context is great for theme, language, authenticated user and feature flags — things that change rarely. For fast-changing values (a text input, a timer, mouse position), it causes unnecessary re-renders.
Mitigations:
- Split contexts by concern (
ThemeContext,AuthContext) instead of one giantAppContext. - Memoize the value object with
useMemo. - Separate state and dispatch into two contexts so components that only dispatch don't re-render.
5. Server state is different — use a server-state library
Data from an API isn't really "yours": it lives on the server, can change without you knowing, and needs caching. Managing it with useState + useEffect means re-implementing loading flags, errors, caching, deduplication, retries and race conditions in every component.
TanStack Query solves this:
function ArticleList() {
const { data, isLoading, error } = useQuery({
queryKey: ["articles"],
queryFn: () => fetch("/api/articles").then((r) => r.json()),
staleTime: 60_000,
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return data.map((a) => <ArticleCard key={a.id} article={a} />);
}
function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (article) => api.create(article),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articles"] }),
});
}
Once server state moves into a query cache, most applications discover they have surprisingly little global client state left.
6. A store for complex client state: Zustand
For shared client state that changes often — a shopping cart, an editor, a dashboard layout — a small store library avoids Context re-render problems. Zustand is minimal and lets components subscribe to slices:
import { create } from "zustand";
export const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
}));
function CartBadge() {
const count = useCart((s) => s.items.length); // re-renders only when count changes
return <span>{count}</span>;
}
7. Redux Toolkit for large teams
Redux Toolkit is still a solid choice when you want strict conventions, powerful DevTools (time-travel debugging) and a single, predictable data flow across a large team. Modern Redux with createSlice is far less verbose than classic Redux — and RTK Query covers server state too.
8. Don't forget the URL
Filters, search terms, pagination and selected tabs often belong in the URL, so users can bookmark and share them:
const [params, setParams] = useSearchParams();
const tag = params.get("tag") ?? "all";
A decision checklist
- Used by one component? →
useState. - Shared by a few nearby components? → lift state up.
- Complex transitions? →
useReducer. - Comes from an API? → TanStack Query (or your framework's data layer).
- Should be shareable/bookmarkable? → URL.
- Global and rarely changing? → Context.
- Global, frequently changing, complex? → Zustand or Redux Toolkit.
Summary
Start with the simplest tool and move up only when you feel real pain. Keep state local by default, lift it when needed, use Context for low-frequency global data, a query library for server data, the URL for shareable state, and a store like Zustand or Redux Toolkit only for genuinely complex client state. Classifying your state correctly is 90% of good React architecture.
FAQ
Is Context a replacement for Redux?
Context is a dependency-injection mechanism, not a state manager with selectors and devtools. For low-frequency data it's enough; for complex, high-frequency state, a store performs better.
Should form state be global?
Almost never. Keep it local (or use a form library like React Hook Form) and submit it to the server.
Zustand or Redux Toolkit?
Zustand for small-to-medium apps that want minimal boilerplate; Redux Toolkit for large teams that value strict structure and tooling.