Local vs Global State
Choosing where state lives is one of the highest-leverage decisions in a React app. Put it too low and you end up duplicating and synchronizing values; reach for a global store too early and you trade a small inconvenience for app-wide coupling, harder testing, and needless re-renders. The guiding principle is colocation: keep each piece of state as close as possible to where it is used, and only widen its scope when a concrete need forces you to.
What “local” and “global” actually mean
Local state is owned by a single component (or a small subtree) via useState or useReducer. It is born when the component mounts and dies when it unmounts. Global state is shared across distant parts of the tree — typically through Context, or a library such as Redux Toolkit, Zustand, or Jotai — and outlives any single component.
| Aspect | Local state | Global state |
|---|---|---|
| Owner | One component / subtree | The whole app (or a feature) |
| Lifetime | Tied to the component | Tied to the app session |
| Access | Direct props/hooks | Store, Context, or selector |
| Best for | UI toggles, form fields, hover/focus | Auth, theme, cart, cross-route data |
| Cost when overused | Prop drilling | Coupling, re-renders, harder tests |
Start local: colocation
Most state is genuinely local. A modal’s open flag, an input’s value, or whether a row is expanded only concerns the component rendering it. Keep it there.
import { useState } from "react";
function SearchBox({ onSearch }) {
const [query, setQuery] = useState("");
function handleSubmit(e) {
e.preventDefault();
onSearch(query.trim());
}
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Go</button>
</form>
);
}
The query value never leaves this component, so promoting it anywhere else would only add noise. Colocation keeps related logic together, makes the component easy to reason about, and lets it be deleted or moved without ripple effects.
Lift when siblings must agree
When two or more components need to read or write the same value, move that state up to their nearest common parent — the classic “lifting state up” pattern. The parent owns the state and passes it down with a setter.
import { useState } from "react";
function TemperatureConverter() {
const [celsius, setCelsius] = useState(20);
return (
<div>
<CelsiusInput value={celsius} onChange={setCelsius} />
<FahrenheitDisplay celsius={celsius} />
</div>
);
}
function CelsiusInput({ value, onChange }) {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
);
}
function FahrenheitDisplay({ celsius }) {
return <p>{(celsius * 9) / 5 + 32}°F</p>;
}
Lifting is the right answer surprisingly often. Before reaching for a global store, ask whether a shared parent already exists — it usually does.
When to go global
Lifting breaks down when the common ancestor is far away and the value has to be threaded through many intermediate components that do not care about it (prop drilling), or when state is truly app-wide. Reach for global state when:
- The data is needed by many, distant components (current user, theme, locale, feature flags).
- It must survive navigation between routes (a shopping cart, a draft document).
- Multiple unrelated features read and mutate the same source of truth.
- You need it accessible outside the render tree (e.g., in event handlers, interceptors, or background tasks).
For low-frequency, read-mostly values, Context is enough. For frequently updated or large state, a dedicated store with selectors avoids re-rendering every consumer.
import { create } from "zustand";
const useCartStore = create((set) => ({
items: [],
add: (item) =>
set((state) => ({ items: [...state.items, item] })),
clear: () => set({ items: [] }),
}));
function CartBadge() {
// Subscribes only to length — other state changes won't re-render this.
const count = useCartStore((s) => s.items.length);
return <span className="badge">{count}</span>;
}
The cost of premature global state
Going global “just in case” is a common, expensive mistake.
Gotcha: Putting form input or a hover flag into a global store means every keystroke can re-render unrelated consumers, and your store fills up with transient UI noise that obscures the data that actually matters.
Premature globalization also couples components to a store’s shape, making them harder to reuse and unit-test in isolation, and it turns “where did this value change?” into an app-wide investigation. Local state, by contrast, has an obvious owner and an obvious blast radius.
Tip: Server data (fetched from an API) usually does not belong in your global UI store at all. Tools like RTK Query or React Query own caching, deduping, and refetching far better than hand-rolled global state.
A decision checklist
Walk down this list and stop at the first match:
- Is the value used by only one component? → Local state (
useState/useReducer). - Is it shared by a few nearby components? → Lift it to the common parent.
- Is it server data (lists, entities, queries)? → A data-fetching cache (RTK Query, React Query).
- Is it needed by many distant components and read-mostly? → Context.
- Is it app-wide, frequently updated, or accessed outside render? → A global store (Redux Toolkit, Zustand, Jotai).
Best Practices
- Default to local state; treat every promotion to global as a decision that needs justification.
- Lift state only as high as the closest common ancestor — no higher.
- Keep transient UI state (open/closed, hover, focus, input drafts) out of global stores.
- Separate server cache from client UI state; let a query library own remote data.
- Use selectors so consumers subscribe to the narrowest slice they need.
- When prop drilling becomes painful through 3+ layers, prefer Context before a full store, and a store only when Context re-renders or ergonomics bite.
- Co-locate related state, derived values, and the logic that updates them in the same place.