State Management Overview
State is any data that changes over time and affects what a component renders — a counter’s current value, a form’s input text, whether a modal is open, or a list fetched from a server. React re-renders a component whenever its state changes, which is what makes UIs declarative: you describe what the UI should look like for a given state, and React figures out how to update the DOM to match. As an app grows past a handful of components, deciding where each piece of state should live and how it should move between components becomes the single biggest design question in React — that decision is what “state management” means. This lesson is a map of the whole landscape: local state, lifting state up, the Context API, and when to reach for a dedicated state or server-cache library, so the rest of this section has a place to fit.
Overview: What State Management Means in React
Every component can hold its own state with useState or useReducer. That covers a huge number of cases — a toggle, a text input, a counter — because the state and the UI that reads it live in the same place. The problems start when two components that are not parent and child need to see the same value, or when a deeply nested component needs data that only a distant ancestor has. React doesn’t have a built-in way for components to talk sideways or reach up the tree; data only flows down, through props. So “state management” is really the practice of choosing the right home for each piece of state so that it reaches everywhere it’s needed without becoming tangled, duplicated, or out of sync.
There is a spectrum of solutions, roughly ordered from “cheapest and most local” to “most global and most powerful”:
| Approach | Where the state lives | Good for |
|---|---|---|
Local state (useState / useReducer) |
Inside one component | UI state that only that component (and maybe its direct children) needs — a form field, a toggle, a counter |
| Lifting state up | The nearest common parent | Two or more sibling components need to read or change the same value |
Context API (createContext / useContext) |
A Provider high in the tree | App-wide data many components at different depths need — theme, current user, locale — without passing props through every level |
| External state libraries (Redux, Zustand, Jotai, Recoil) | A store outside the component tree | Large apps with complex, frequently-updated global state, or state that must be read/written outside React (middleware, devtools, persistence) |
| Server/cache state (React Query, SWR) | A cache keyed by request | Data that actually lives on a server, not the client — fetching, caching, revalidation, background refresh |
A common beginner mistake is treating every one of these as interchangeable. They aren’t: reaching for Redux to hold a single dropdown’s open/closed state is as wrong as trying to store a list of blog posts fetched from an API in local useState and manually refetching it everywhere it’s needed. The rest of this section (Context, useReducer, and the state-library lessons) goes deep on each rung of this ladder; this lesson’s job is to help you recognize which rung you’re on.
Syntax: The Building Blocks
Every approach above is built from a small set of syntactic pieces you’ll use throughout this section:
const [state, setState] = useState(initialValue);— declares one piece of local state and a setter. CallingsetStateschedules a re-render with the new value.const [state, dispatch] = useReducer(reducer, initialState);— for state whose updates involve multiple related fields or transitions; areducer(state, action)function computes the next state from the current one.- Lifting state up has no special syntax of its own — it’s simply moving a
useStatecall from a child component up to the nearest shared parent, then passing the value and a setter (or a handler that calls the setter) down as props. const MyContext = createContext(defaultValue);— creates a Context object outside any component, usually in its own module.<MyContext.Provider value={...}>...</MyContext.Provider>— makes a value available to every descendant, no matter how deeply nested.const value = useContext(MyContext);— reads the nearest matching Provider’s value from inside any descendant component.
External libraries add their own syntax on top of this — a create() store in Zustand, a configureStore() call in Redux Toolkit — which later lessons in this section cover individually.
Examples
Example 1: Local State
The simplest and most common case: state that only one component needs.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
export default Counter;
This renders a paragraph reading “Count: 0” and a button below it. Clicking the button calls setCount(count + 1), which schedules a re-render; React re-runs Counter, computes the new JSX with the updated count, and patches only the text node that actually changed in the real DOM. No other component is involved — the state is fully local, created and consumed in the same function.
Output: initial render shows “Count: 0”; after two clicks it shows “Count: 2”.
Example 2: Lifting State Up
When two sibling components need to share the same piece of data, neither one should own it — it belongs in their closest common parent, which then passes it down to both.
import { useState } from "react";
function SearchBar({ searchTerm, onSearchChange }) {
return (
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search fruits..."
/>
);
}
function ResultsList({ items, searchTerm }) {
const filtered = items.filter((item) =>
item.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<ul>
{filtered.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
function FilterableFruitList() {
const [searchTerm, setSearchTerm] = useState("");
const fruits = ["Apple", "Banana", "Cherry", "Mango"];
return (
<div>
<SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
<ResultsList items={fruits} searchTerm={searchTerm} />
</div>
);
}
export default FilterableFruitList;
SearchBar and ResultsList are siblings — neither can read the other’s state directly. FilterableFruitList owns searchTerm as the single source of truth, hands it to ResultsList to filter with, and hands SearchBar both the current value and a callback to change it. Typing in the input never mutates anything directly; it calls onSearchChange, which is really setSearchTerm, which triggers a re-render of the parent and both children with fresh props.
Output: typing “an” into the search box narrows the rendered list to just “Banana” and “Mango”.
Example 3: Prop Drilling and the Context Solution
Lifting state up works cleanly for one or two levels. It gets painful when the value has to pass through several components that don’t use it themselves, only forward it — this is called “prop drilling.”
function App() {
const theme = "dark";
return <Toolbar theme={theme} />;
}
function Toolbar({ theme }) {
return <ThemedButton theme={theme} />;
}
function ThemedButton({ theme }) {
return (
<button className={theme === "dark" ? "btn-dark" : "btn-light"}>
Save
</button>
);
}
Toolbar has no use for theme at all — it only exists to relay it to ThemedButton. In a real app the chain is often five or six components deep, and adding one more consumer means touching every intermediate component’s props. Context removes the relay entirely by letting ThemedButton read the value directly, no matter how many components sit between it and the Provider:
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext.Provider value={theme}>
<Toolbar />
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Toggle theme
</button>
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />;
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return (
<button className={theme === "dark" ? "btn-dark" : "btn-light"}>
Save
</button>
);
}
export default App;
Toolbar no longer mentions theme at all. ThemedButton calls useContext(ThemeContext) and gets whatever value the nearest ThemeContext.Provider above it is holding — currently "dark".
Output: renders a “Save” button styled with the btn-dark class and a “Toggle theme” button; clicking the toggle switches the Save button to btn-light immediately.
Under the Hood: How React Propagates State Changes
Every state update, regardless of which rung of the ladder it lives on, goes through the same three phases. First, render: calling a setter (from useState, useReducer, or a value flowing through Context) schedules a render. React calls the component function again and every descendant in its subtree, producing a new tree of React elements (the Virtual DOM). Second, reconcile: React diffs the new element tree against the previous one, element by element, to figure out the minimal set of changes needed. Third, commit: React applies just those changes to the real DOM — updating a text node, adding an attribute, inserting or removing an element — rather than rebuilding the page.
This is why lifting state up works: when the parent’s state changes, the parent re-renders, and by default so does its entire subtree, including both sibling children, each with fresh props computed from the new state. React’s diffing then makes sure only the DOM nodes that actually differ get touched, so this is cheap even though “the whole subtree re-rendered” sounds expensive.
It’s also why Context has a sharp edge: every component that calls useContext(MyContext) re-renders whenever the Provider’s value changes — even if the part of that value the component actually reads didn’t change. A Provider’s value is a single reference; React doesn’t know or care that you only read value.theme and not value.cart. This detail matters enough that it drives one of the most common Context mistakes, covered next.
One more piece of the mental model: React 18 batches multiple state updates that happen inside the same event handler (and, unlike React 17, inside promises and timeouts too) into a single render pass. Calling two setters in a row inside one onClick still only triggers one re-render, not two.
Common Mistakes
Mistake 1: Mutating state directly
React decides whether to re-render by comparing the old and new state with Object.is. Mutating an array or object in place keeps the same reference, so React sees “no change” and skips the re-render — or renders inconsistently, since the old value has already been overwritten.
function TodoList() {
const [todos, setTodos] = useState(["Buy milk", "Walk dog"]);
function addTodo(text) {
todos.push(text);
setTodos(todos);
}
return (
<ul>
{todos.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
);
}
todos.push(text) mutates the existing array in place; setTodos(todos) then passes React the exact same array reference it already had, so React may bail out of re-rendering entirely. Always build a new array or object instead:
function addTodo(text) {
setTodos([...todos, text]);
}
Mistake 2: One giant Context causing unnecessary re-renders
Because every consumer of a Context re-renders when its value reference changes, cramming unrelated state into a single Provider means a change to any one field re-renders components that only care about a different field.
const AppContext = createContext();
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
const value = { user, setUser, theme, setTheme, cart, setCart };
return (
<AppContext.Provider value={value}>
<Page />
</AppContext.Provider>
);
}
Toggling theme here re-renders every component that reads user or cart from AppContext too, and value is a brand-new object on every render regardless, so consumers can’t even bail out via reference equality. Split unrelated concerns into separate contexts, and memoize each value so it only changes when its own inputs do:
const ThemeContext = createContext("light");
const CartContext = createContext([]);
function App() {
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
const cartValue = useMemo(() => ({ cart, setCart }), [cart]);
return (
<ThemeContext.Provider value={themeValue}>
<CartContext.Provider value={cartValue}>
<Page />
</CartContext.Provider>
</ThemeContext.Provider>
);
}
Best Practices
- Keep state as local as possible; only lift it to a parent or a Context when more than one component genuinely needs it.
- Prefer
useReducerover several relateduseStatecalls when updates involve multiple fields changing together or depend on the previous state in non-trivial ways. - Never mutate state or props — always create a new array or object (spread syntax,
.map,.filter) and pass that to the setter. - Split Context by concern (theme, auth, cart) instead of one catch-all context, and memoize the
valueobject withuseMemoso it only changes when its inputs change. - Distinguish UI state (owned by the client: is this modal open, what’s typed in this field) from server state (owned by a backend: a list of products, a user’s profile) — server state belongs in a fetching/caching tool like React Query or SWR, not in raw
useStateplus manual refetch logic. - Don’t reach for Redux, Zustand, or similar libraries until Context and prop drilling actually hurt — most apps go a long way on local state plus a couple of well-scoped contexts.
- Co-locate state with the components that render it; moving state up “just in case” makes components harder to reuse and test in isolation.
Practice Exercises
1. Build a LikeButton component that holds a boolean liked value in local state with useState and toggles between “Like” and “Liked” text when clicked.
2. You have two sibling components, ColorPicker (a set of color swatches) and PreviewBox (a box whose background should match the selected color), each currently rendered independently with no shared parent state. Refactor them so a parent component owns the selected color and passes it to both, following the lifting-state-up pattern from Example 2.
3. Take the prop-drilling example from this lesson and extend it by one more level: add a Sidebar component between App and Toolbar that also needs the theme to style its background. Refactor the whole chain to use createContext and useContext so neither Toolbar nor Sidebar needs a theme prop.
Summary
- State management is the practice of choosing the right home for each piece of data so it reaches every component that needs it without becoming duplicated or tangled.
- Local state (
useState/useReducer) is the default; use it whenever only one component needs the value. - Lifting state up moves state to the nearest common parent so sibling components can share it through props.
- Prop drilling is passing a value through components that don’t use it, just to reach a deeper one; the Context API (
createContext,Provider,useContext) solves it by letting any descendant read a value directly. - Every Context consumer re-renders when the Provider’s value reference changes, so split unrelated state into separate contexts and memoize the value object.
- Server data (fetched from an API) is a different kind of state than UI state and is usually better handled by a caching library like React Query or SWR than by manual
useStateplusfetch. - Reach for an external state library (Redux, Zustand, Jotai) only once local state, lifting, and Context genuinely stop being enough.
