Last 30 Days
No notifications
As applications grow, managing state across many components becomes complex. React provides Context API built-in, while libraries like Zustand and Redux Toolkit offer more powerful solutions.
| Solution | Best For | Complexity |
useState | Local component state | Low |
| Lifting state | 2-3 sibling components | Low |
| Context API | Theme, auth, locale (low-frequency updates) | Medium |
| Zustand | Medium apps, simple global store | Low-Medium |
| Redux Toolkit | Large apps, complex logic, middleware | High |
const ThemeContext = createContext("light");// Provider wraps the tree
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
// Consumer reads the value anywhere in the tree
const theme = useContext(ThemeContext);
import { create } from "zustand";const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// In any component:
const count = useStore((s) => s.count);
Uses slices (reducers + actions in one file), configureStore, and useSelector/useDispatch hooks.
You already know useState. Why is there a whole library industry around state? Because as an app grows you start running into:
Before choosing a library, label what you're storing:
| Kind | Example | Where it belongs |
| Local UI state | input value, modal open | useState in the component |
| Shared client state | theme, current user, cart | Context / Zustand / Redux |
| Server cache state | list of users from API | TanStack Query / SWR |
| URL state | filters, page number, tab | URL search params |
A common mistake is shoving server data into Redux. Server data already lives on the server — what you really want is a *cache* with refetch logic. That's what TanStack Query does.
If only one component (or a couple of close children) need it, plain useState is the answer. Don't reach for a library yet.
If two siblings need the same value, lift it to their nearest common parent (covered in State & Props). Free, zero dependencies.
function Page() {
const [filter, setFilter] = useState("");
return (
<>
<SearchBox filter={filter} onChange={setFilter} />
<Results filter={filter} />
</>
);
}When data must reach deeply nested components (theme, current user, locale), use Context.
import { createContext, useContext, useState } from "react";const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
Wrap your app once with ; any descendant calls useAuth().
When state is updated *frequently* and read in *many* places, Context starts re-rendering too aggressively. Pick one:
import { create } from "zustand";const useCart = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter(x => x.id !== id) })),
total: () => 0, // see selectors below
}));
function CartCount() {
const count = useCart((s) => s.items.length); // selector
return <span>{count}</span>;
}
The selector matters: this component only re-renders when items.length changes — not when *any* part of the cart changes.
import { configureStore, createSlice } from "@reduxjs/toolkit";const counter = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (s) => { s.value += 1; }, // looks like mutation, safe via Immer
addBy: (s, action) => { s.value += action.payload; },
},
});
export const { increment, addBy } = counter.actions;
export const store = configureStore({ reducer: { counter: counter.reducer } });
// In a component:
const value = useSelector((s) => s.counter.value);
const dispatch = useDispatch();
dispatch(increment());
1. Reaching for Redux on day one — try useState → lift → Context → Zustand → Redux, in that order.
2. Putting server data in client state — use TanStack Query / SWR; they handle caching, refetching, retries.
3. One giant Context holding theme + user + cart — splitting into focused contexts avoids needless re-renders.
4. Mutating Redux state without Immer's slice helpers — outside Redux Toolkit it'll silently break.
5. Not memoising the Context value — creates a new object every render, re-rendering all consumers.
Whenever a Context's value changes, every consuming component re-renders — even if it only reads one field. Workarounds:
AuthContext, ThemeContext, CartContext — components subscribe only to what they need.const value = useMemo(() => ({ user, setUser }), [user]);
return <AuthContext.Provider value={value}>…</AuthContext.Provider>;
React.memo consumers that don't need to re-render on every change.Selecting an object re-renders on every store change because {a:1} !== {a:1}. Use a shallow comparator:
import { shallow } from "zustand/shallow";const { addItem, remove } = useCart(
(s) => ({ addItem: s.addItem, remove: s.remove }),
shallow,
);
Or pull each value with its own one-liner selector — keeps re-renders minimal.
| Concept | Description |
| Store | Single source of truth |
| Slice | Reducer + actions for one feature |
| Dispatch | Sends actions to the store |
| Selector | Reads specific state from the store |
| Thunk | Async middleware for side effects (createAsyncThunk) |
Modern Redux Toolkit also ships RTK Query, which competes head-to-head with TanStack Query for server cache.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";function Users() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error err={error} />;
return <List items={data} />;
}
You get for free:
Filters, sort order, pagination, search — all great in the URL. Use useSearchParams (covered in Router) so refresh, share, and back-button work for free. Don't duplicate them in client state.
A selector is just a function state => value. Two reasons to extract them:
1. Reuse — every component reading isLoggedIn uses the same logic.
2. Memoisation — for derived values, use createSelector (Reselect / RTK) so the same inputs return the same reference, preventing re-renders.
import { createSelector } from "reselect";const selectItems = (s) => s.cart.items;
const selectTotal = createSelector([selectItems], (items) =>
items.reduce((sum, it) => sum + it.price, 0)
);
Pretty much every library has a "persist" plugin that mirrors state to localStorage:
persist middlewareredux-persistpersistQueryClientReact 18's concurrent rendering can render a component twice in the background. External stores must use useSyncExternalStore to stay consistent. Major libraries already do this internally — but if you build a custom store, you must too.
Just one component? → useState
A few siblings? → Lift state
Cross-tree, low-frequency? → Context
Cross-tree, high-frequency? → Zustand
Big team, devtools, conventions? → Redux Toolkit
Server data? → TanStack Query / SWR / RTK Query
Filters / pagination? → URL search paramsYou'll often combine several: TanStack Query for server data + Zustand (or Context) for the rest.
1. Build a theme toggle with Context, then split it into and a useTheme() hook.
2. Build a cart with Zustand. Make re-render *only* when the count changes (verify with React DevTools profiler).
3. Replace a manual useEffect + fetch flow with TanStack Query. Remove all the loading/error boolean state.
4. Move pagination + search filter from useState into URL search params; refresh and confirm the page restores.