Skip to main content
Published / updated

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.

WhereForSurvives refresh
Local (useState)One component's own concernNo
Lifted to a parentTwo or three related componentsNo
URL (query params)Filters, paging, sorting, the current recordYes
ContextGenuinely global, rarely changingNo
Server cacheAnything the API ownsN/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 stateKept in the URL
Lost on refreshSurvives refresh
Not shareableShareable
Back button does nothing usefulBack button works
Cannot be bookmarkedCan 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 fitPoor fit
The signed-in userA form's field values
ThemeA list's data
LocaleAnything changing per keystroke
A toast queueAnything 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 stateClient state
Owned by the APIOwned by the browser
Can be staleAlways current
Shared between usersPer user, per session
Needs caching and refetchingDoes not
Students, fees, exam resultsWhich 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

LibrarySuits
ZustandSmall global client state, minimal boilerplate
Redux ToolkitLarge applications, teams wanting strict conventions, time-travel debugging
Jotai / RecoilFine-grained atomic state
TanStack QueryServer 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:

StateWhereWhy
Student listQuery cacheServer owns it
Search term, class filter, pageURLShareable, refresh-safe
Signed-in userContextGlobal, rarely changes
ThemeContextGlobal, rarely changes
Form field valuesLocalOne component
Which row is expandedLocalOne component
Sidebar open, toastsZustand or contextGlobal UI, changes often

Diagnosing state problems

SymptomCause
Everything re-renders on any changeOne context holding unrelated concerns
Consumers re-render constantlyContext value not memoised
Two views of the same data disagreeThe same server data stored in two places
Filters lost on refreshState in a component instead of the URL
A list does not refresh after a createNo cache invalidation, or manual state not updated
A component silently behaves as signed-outUsed outside its provider with a non-undefined default
Derived value out of syncStored 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 seeCauseFix
Every consumer re-renders on any changeOne large contextSplit contexts by concern
useContext returns undefinedComponent outside the providerMove it inside
Two copies of the same data drift apartServer data duplicated into local stateKeep one source of truth
Prop drilling through five levelsState too high, or context not usedLift or contextualise
Adding Redux made it slower to changeReached for a big tool earlyStart 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 undefined default on a context
  • Derived values stored as state
  • Adding Redux to a CRUD application by default
  • Never profiling before optimising

Practice

  1. Build a card with local expanded state. Confirm nothing outside it re-renders.
  2. Lift selectedId into a parent shared by a list and a detail panel.
  3. Move filters and paging into useSearchParams. Refresh and confirm they survive.
  4. Copy the filtered URL into a new tab and confirm it opens identically.
  5. Build one context holding user, theme and filters. Enable "Highlight updates" and type in a filter. Observe the whole page flash.
  6. Split it into three contexts and repeat. Compare.
  7. Remove useMemo from a context value. Count consumer re-renders in the Profiler.
  8. Add it back and compare.
  9. Split a context into state and dispatch. Confirm a dispatch-only component never re-renders on data change.
  10. Store the student list in a context and also in a component. Change one and confirm they disagree.
  11. Replace it with TanStack Query. Confirm two components with the same query key share one request.
  12. Add invalidateQueries after a create and confirm every list refreshes with no manual update.
  13. Use a context hook outside its provider with a non-undefined default. Confirm the silent wrong behaviour, then add the throw.
  14. Add Zustand for sidebar and toast state. Use a selector and confirm a toast change does not re-render the sidebar.
  15. 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

  1. What are the five places state can live, and in what order should you consider them?
  2. Why does a context value need useMemo, and what does that not fix?
  3. What is the difference between server state and client state?
  4. Why is invalidateQueries better than manually updating a global store?

Next: Styling and accessibility