State Management
Before you start
You need: authentication (Article 07).
Time: about 45 minutes, plus the practice.
Learning objective
Decide where each piece of state belongs, and use context without re-rendering half the application.
Topics
- The five places state can live
- Local and lifted state
- URL state
- Context, and its performance trap
- Splitting contexts
- Server state versus client state
- When a state library helps
- Diagnosing state problems
The five places
Most "state management" problems are really "state in the wrong place" problems.
| Where | For | Survives refresh |
|---|---|---|
Local (useState) | One component's own concern | No |
| Lifted to a parent | Two or three related components | No |
| URL (query params) | Filters, paging, sorting, the current record | Yes |
| Context | Genuinely global, rarely changing | No |
| Server cache | Anything the API owns | N/A |
Work down this list in order. Reaching for a global store when the state belongs in one component is the most common structural mistake in React applications.
Local state
export function StudentCard({ student }: { student: Student }) {
const [expanded, setExpanded] = useState(false);
return (
<article>
<button type="button" onClick={() => setExpanded(prev => !prev)}
aria-expanded={expanded}>
{student.name}
</button>
{expanded && <StudentSummary student={student} />}
</article>
);
}
Nothing outside this card cares whether it is expanded. Keep state as close to where it is used as possible — it is easier to reason about, and it cannot be corrupted by anything else.
Lifted state
export function StudentScreen() {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<div className="split">
<StudentList selectedId={selectedId} onSelect={setSelectedId} />
<StudentDetail publicId={selectedId} />
</div>
);
}
Two siblings need the same value, so it lives in their nearest common parent.
This is the default answer to "how do these components share data" — before context, and long before a state library.
Prop drilling only becomes a problem when a value passes through four or five levels that do not use it. Two levels is not prop drilling; it is normal.
URL state
const [searchParams, setSearchParams] = useSearchParams();
const term = searchParams.get('term') ?? '';
const className = searchParams.get('className') ?? '';
const page = Number(searchParams.get('page') ?? '1');
Filters, paging, sorting and the currently-viewed record belong in the URL.
| Kept in state | Kept in the URL |
|---|---|
| Lost on refresh | Survives refresh |
| Not shareable | Shareable |
| Back button does nothing useful | Back button works |
| Cannot be bookmarked | Can be bookmarked |
Users share filtered URLs. A search stored only in a component means a colleague opening the link sees an unfiltered list.
This is the most under-used place to put state, and it removes an entire category of "how do I keep this in sync" problems.
Context
Context passes a value down the tree without props. It is for genuinely global, rarely changing data.
| Good fit | Poor fit |
|---|---|
| The signed-in user | A form's field values |
| Theme | A list's data |
| Locale | Anything changing per keystroke |
| A toast queue | Anything one screen owns |
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used inside a ThemeProvider');
}
return context;
}
The undefined default plus the throw means a component used outside its provider fails with a clear message rather than silently receiving a default.
The performance trap
Every consumer re-renders whenever the context value changes.
// Wrong — a new object every render, so every consumer re-renders every time
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
The object literal is new on every render of the provider, so React sees a changed value and re-renders every consumer — even ones that only read theme when user changed.
// Right — memoised
const value = useMemo(() => ({ user, setUser, theme, setTheme }), [user, theme]);
That fixes the identity problem. It does not fix the second problem: a theme consumer still re-renders when user changes, because they share one context.
Splitting contexts
const AuthContext = createContext<AuthValue | undefined>(undefined);
const ThemeContext = createContext<ThemeValue | undefined>(undefined);
<AuthProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</AuthProvider>
Now a theme change re-renders only theme consumers.
One context per concern. A single "app context" holding user, theme, notifications and the current filters re-renders the entire tree on any change — and it is the most common performance problem in a context-heavy application.
Splitting state from setters helps further, because setters never change:
const StudentStateContext = createContext<Student[] | undefined>(undefined);
const StudentDispatchContext = createContext<React.Dispatch<Action> | undefined>(undefined);
A component that only dispatches never re-renders when the data changes.
Context with a reducer
type FilterState = { term: string; className: string; page: number };
type FilterAction =
| { type: 'termChanged'; term: string }
| { type: 'classChanged'; className: string }
| { type: 'pageChanged'; page: number }
| { type: 'cleared' };
function filterReducer(state: FilterState, action: FilterAction): FilterState {
switch (action.type) {
case 'termChanged': return { ...state, term: action.term, page: 1 };
case 'classChanged': return { ...state, className: action.className, page: 1 };
case 'pageChanged': return { ...state, page: action.page };
case 'cleared': return { term: '', className: '', page: 1 };
default: {
const exhaustive: never = action;
return exhaustive;
}
}
}
export function FilterProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(filterReducer, { term: '', className: '', page: 1 });
return (
<FilterStateContext.Provider value={state}>
<FilterDispatchContext.Provider value={dispatch}>
{children}
</FilterDispatchContext.Provider>
</FilterStateContext.Provider>
);
}
dispatch is stable across renders, so it needs no useMemo and never causes a re-render.
Note that resetting page to 1 on a filter change is in the reducer — a rule that would otherwise be forgotten in one of the three places filters change.
Server state versus client state
This distinction removes most perceived state-management complexity.
| Server state | Client state |
|---|---|
| Owned by the API | Owned by the browser |
| Can be stale | Always current |
| Shared between users | Per user, per session |
| Needs caching and refetching | Does not |
| Students, fees, exam results | Which row is expanded, form values, theme |
Most of what people put in a global store is server state, and a global store is the wrong tool for it — you end up hand-writing caching, invalidation and refetching.
// Manual server state — every screen repeats this
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { /* fetch, cancel, set three states */ }, [term, page]);
// TanStack Query — caching, deduplication, cancellation, refetching
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['students', term, page],
queryFn: ({ signal }) => studentApi.search({ term, page, pageSize: 20 }, signal)
});
const queryClient = useQueryClient();
const createStudent = useMutation({
mutationFn: studentApi.create,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['students'] })
});
invalidateQueries is what removes the synchronisation problem. Creating a student refreshes every list showing students, automatically — no global store, no manual setStudents in three places, no chance of one going stale.
Two components asking for the same query key share one request and one cache entry, so a header count and a list body do not fetch twice.
With server state in a query cache, what remains for client state is usually small enough for useState and one or two contexts.
When a state library helps
| Library | Suits |
|---|---|
| Zustand | Small global client state, minimal boilerplate |
| Redux Toolkit | Large applications, teams wanting strict conventions, time-travel debugging |
| Jotai / Recoil | Fine-grained atomic state |
| TanStack Query | Server state — the common real need |
import { create } from 'zustand';
interface UiStore {
sidebarOpen: boolean;
toggleSidebar: () => void;
toasts: Toast[];
addToast: (toast: Toast) => void;
dismissToast: (id: string) => void;
}
export const useUiStore = create<UiStore>(set => ({
sidebarOpen: true,
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
toasts: [],
addToast: toast => set(state => ({ toasts: [...state.toasts, toast] })),
dismissToast: id => set(state => ({ toasts: state.toasts.filter(t => t.id !== id) }))
}));
// Subscribes to sidebarOpen only — a toast change does not re-render this
const sidebarOpen = useUiStore(state => state.sidebarOpen);
Zustand's selector subscription is its advantage over context: a component re-renders only when the slice it selected changes.
Add a library when you have a demonstrated problem, not at the start. A new application should reach for local state, lifted state, the URL and a query cache first — and most never need more.
Redux Toolkit is the right answer for a large team wanting one prescribed way to do things, and its DevTools time-travel is genuinely useful on a complex application. It is heavy for a CRUD frontend.
Choosing
Does the API own it?
→ TanStack Query (or your own fetching hook)
Should it survive a refresh or be shareable?
→ The URL
Is it needed by many unrelated components, and rarely changes?
→ Context (one per concern)
Is it needed by two or three related components?
→ Lift it to their parent
Otherwise
→ useState in the component
Applied to the school portal:
| State | Where | Why |
|---|---|---|
| Student list | Query cache | Server owns it |
| Search term, class filter, page | URL | Shareable, refresh-safe |
| Signed-in user | Context | Global, rarely changes |
| Theme | Context | Global, rarely changes |
| Form field values | Local | One component |
| Which row is expanded | Local | One component |
| Sidebar open, toasts | Zustand or context | Global UI, changes often |
Diagnosing state problems
| Symptom | Cause |
|---|---|
| Everything re-renders on any change | One context holding unrelated concerns |
| Consumers re-render constantly | Context value not memoised |
| Two views of the same data disagree | The same server data stored in two places |
| Filters lost on refresh | State in a component instead of the URL |
| A list does not refresh after a create | No cache invalidation, or manual state not updated |
| A component silently behaves as signed-out | Used outside its provider with a non-undefined default |
| Derived value out of sync | Stored as state instead of calculated |
React DevTools Profiler shows which components re-rendered and why. Enable "Highlight updates when components render" to see context problems immediately — a whole page flashing on one keystroke is a context that needs splitting.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Every consumer re-renders on any change | One large context | Split contexts by concern |
useContext returns undefined | Component outside the provider | Move it inside |
| Two copies of the same data drift apart | Server data duplicated into local state | Keep one source of truth |
| Prop drilling through five levels | State too high, or context not used | Lift or contextualise |
| Adding Redux made it slower to change | Reached for a big tool early | Start with local state |
Most state is local. Reach for context when several distant components genuinely need the same value, and for a library only when context is measurably not enough.
Common mistakes
- Reaching for a global store before trying local, lifted and URL state
- One "app context" holding every concern
- Context value not wrapped in
useMemo - Context for high-frequency state such as form input
- Server data in a global store, then hand-writing caching
- The same server data stored in two places
- Filters in component state instead of the URL
- No
undefineddefault on a context - Derived values stored as state
- Adding Redux to a CRUD application by default
- Never profiling before optimising
Practice
- Build a card with local
expandedstate. Confirm nothing outside it re-renders. - Lift
selectedIdinto a parent shared by a list and a detail panel. - Move filters and paging into
useSearchParams. Refresh and confirm they survive. - Copy the filtered URL into a new tab and confirm it opens identically.
- Build one context holding user, theme and filters. Enable "Highlight updates" and type in a filter. Observe the whole page flash.
- Split it into three contexts and repeat. Compare.
- Remove
useMemofrom a context value. Count consumer re-renders in the Profiler. - Add it back and compare.
- Split a context into state and dispatch. Confirm a dispatch-only component never re-renders on data change.
- Store the student list in a context and also in a component. Change one and confirm they disagree.
- Replace it with TanStack Query. Confirm two components with the same query key share one request.
- Add
invalidateQueriesafter a create and confirm every list refreshes with no manual update. - Use a context hook outside its provider with a non-
undefineddefault. Confirm the silent wrong behaviour, then add the throw. - Add Zustand for sidebar and toast state. Use a selector and confirm a toast change does not re-render the sidebar.
- Write down where every piece of state in your application lives, and justify each against the decision list.
Exercises 5 and 11 are the two that change how you structure an application.
You can now
- Decide where each piece of state belongs
- Use context without causing needless re-renders
- Keep one source of truth for server data
- Say when a state library earns its place
- Recognise prop drilling and fix its cause
Review questions
- What are the five places state can live, and in what order should you consider them?
- Why does a context value need
useMemo, and what does that not fix? - What is the difference between server state and client state?
- Why is
invalidateQueriesbetter than manually updating a global store?