Frontend
Advanced 20 min read Frontend

State Management

State lives at many layers: local, global, server and URL. Learn when to use useState, Context, external stores and server-state libraries so data lives close to where it is consumed.

stateuseReducerContextZustandTanStack Queryselectors
Introduction

What is this?

State management is deciding where each piece of mutable data lives and how it flows to consumers. Local state lives in a component; lifted state moves up to a shared ancestor; global state lives outside React in a store or Context; server state — data mirrored from an API — is cached and synchronized by a query library. Each layer has a cost in complexity, so the art is choosing the smallest layer that works.

Motivation

Why do you need it?

  • Colocated state is simpler: components that own their data are easy to read and test.
  • A single source of truth for shared data prevents duplicated state that drifts out of sync.
  • External stores with selectors let only subscribed components re-render, avoiding tree-wide cascades.
  • Server-state libraries handle caching, deduplication and invalidation that hand-rolled code rarely gets right.
  • Explicit state architecture makes bugs reproducible and teams productive as an app grows.
Think about it

Mental model

Imagine

A shared whiteboard

Think of

A global store

Everyone writes to one whiteboard and reads the same values, so nobody works from a stale copy. But if only two people need the number, a sticky note (local state) is simpler than a whiteboard for the whole office.

Mechanics

How it works

  • useState and useReducer keep state inside a component; useReducer centralizes update logic in a pure reducer.
  • Context provides values down the tree, but every consumer re-renders when the provider value changes unless it is memoized.
  • External stores (Zustand, Jotai, Redux) hold state outside React; components subscribe to slices via selector hooks.
  • Query libraries cache server data, deduplicate concurrent requests, and expose invalidation for mutations.
  • Derived data is computed during render rather than stored, so there is exactly one source of truth per fact.

Choosing where state lives.

State needed
Local useState
Lift to parent
Context or store
Server state
Query cache
Reference

Key commands

npm create vite@latest state-app -- --template react-ts

Scaffold a TypeScript React app.

npm install zustand

Add the Zustand store library.

npm install @tanstack/react-query

Add a server-state cache and data-fetching library.

npm run dev

Start the dev server.

npm run build

Type-check and build the production bundle.

Hands-on

Examples

A Zustand store with a selector

A Zustand store with a selector
import { create } from "zustand";
interface CartState {
items: string[];
add: (item: string) => void;
remove: (item: string) => void;
}
export const useCart = create<CartState>((set) => ({
items: [],
add: (item) => set((state) => ({ items: [...state.items, item] })),
remove: (item) =>
set((state) => ({ items: state.items.filter((i) => i !== item) })),
}));
function CartCount() {
const count = useCart((state) => state.items.length);
return <span>{count} items</span>;
}

Zustand holds the store outside React. CartCount subscribes with a selector that returns only items.length, so it re-renders only when that value changes — other consumers are untouched. Actions update the store with immutable updates via set.

Real world

In production

  • E-commerce carts, auth sessions and multi-step forms are modeled in external stores shared across pages.
  • Dashboards use server-state libraries so every widget dedupes its API requests against the same cache.
  • URL state encodes filters and pagination so users can share a bookmarked view.
  • Large teams enforce architecture with stores that live outside components and are tested in isolation.
Guidelines

Best practices

  • Start with the smallest layer: local state, then lift, then Context/store, then a query cache for server data.
  • Store facts once and derive everything else during render to prevent conflicting duplicates.
  • Subscribe with selectors and ensure selectors return primitives or memoized references to limit re-renders.
  • Keep stores free of components: actions are plain functions you can unit-test without a renderer.
  • Treat server data as cache: invalidate and refetch after mutations, never hand-sync it to a store.
Watch out

Common mistakes

  • Putting every value in a global store and re-rendering large subtrees on minor updates.
  • Duplicating server data into a store and writing manual sync logic that drifts out of date.
  • Storing derived values (a filtered list) as state and never updating it when the source changes.
  • Using Context for high-frequency updates and wondering why the whole tree re-renders.
Root cause analysis

Common issues

Error
Too many components re-render on one store update
Root cause

Consumers subscribe to the whole store instead of a selector, or Context is used for frequent updates.

Fix

Subscribe with a narrow selector and split Context providers that change at different rates.

Error
Stale data after a mutation
Root cause

The query cache was never invalidated, so the UI shows the old snapshot.

Fix

Call queryClient.invalidateQueries after the mutation or update the cache with setQueryData.

Error
State resets on navigation
Root cause

State lived in a component that unmounts, and nothing persisted it.

Fix

Lift the state to a store or persist it to localStorage/sessionStorage via a persist middleware.

When things break

Debugging tips

  • Enable Redux or Zustand DevTools integration to time-travel through state updates.
  • Install the React Query Devtools to watch cache entries, stale time and refetch activity.
  • Profile re-renders in React DevTools to see which components a store update touches.
  • Log reducer actions with a tiny middleware to replay the exact sequence that caused a bug.
Interviews

Interview checkpoint

Practice

Hands-on lab

Refactor Shared State into a Store
35 min · advanced
In progress
Objectives
  • Identify state duplicated across components
  • Move it into a Zustand store
  • Subscribe with narrow selectors
  • Add persistence with the persist middleware

Refactor Shared State into a Store

35 min · advanced

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which tool is designed to cache and synchronize server state?

2

All shared state should live in a global store.

3

Why do selector subscriptions in Zustand reduce re-renders?

Quick reference

Cheat sheet

Choose the layer
useStateLocal, single component
Lift to parentA few related siblings
useReducerComplex update logic
ContextStatic shared values
Store + selectorFrequent, many consumers
Query cacheData owned by an API
Patterns
selectorsSubscribe to a slice of state
invalidateQueriesRefresh server cache after mutations
persist middlewareWrite state to storage
devtools middlewareTime-travel debugging
Go deeper

Official documentation