Concept
The beginner framing: as an app grows, deciding where a piece of state should live, which component owns it, and how far it needs to travel, becomes one of the most consequential architecture decisions in a React codebase.
The precise mental model: state placement is a ladder, and the right rung depends on how widely a value is actually needed and how often it changes. Reaching for a heavier tool than the value's actual sharing needs warrant costs simplicity for no benefit; reaching for a lighter tool than it needs causes prop drilling or broad re-render cascades.
Rung 1: Colocation, keep state as local as possible
function SearchBox() {
const [query, setQuery] = useState(""); // used ONLY here, stays local
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}The default. If a value is only read and written by one component (and its direct children via props), it has no business living any higher, every level a piece of state climbs, the more of the tree potentially re-renders when it changes (see Rendering Lifecycle).
Rung 2: Lifting state up
function Parent() {
const [selectedId, setSelectedId] = useState(null); // now owned by the common ancestor
return (
<>
<ItemList selectedId={selectedId} onSelect={setSelectedId} />
<ItemDetail selectedId={selectedId} />
</>
);
}Once two sibling components genuinely need the same piece of state, it moves up to their nearest common ancestor, which owns it and passes it (plus a way to change it) down as props to both.
Rung 3: useReducer + Context, centralizing more complex, widely-shared state
const TodosContext = createContext(null);
function todosReducer(state, action) {
switch (action.type) {
case "add": return [...state, { id: crypto.randomUUID(), text: action.text }];
case "remove": return state.filter((t) => t.id !== action.id);
default: return state;
}
}
function TodosProvider({ children }) {
const [todos, dispatch] = useReducer(todosReducer, []);
When state transitions get complex enough that a pile of setX calls becomes hard to follow, useReducer centralizes the transition logic into one function, and pairing it with Context (see Context API) distributes both the state and the dispatch function to however many components need them, without prop drilling through every intermediate layer.
Rung 4: A selector-based external store (Zustand, Redux), when Context's re-render model becomes the actual bottleneck
Context's fundamental limitation, covered in depth in Context API, is that every consumer re-renders whenever the Provider's value reference changes, there's no way for a consumer to say "only re-render me if this specific field changes." A selector-based store fixes exactly this: each component subscribes to a selector function, and only re-renders when the specific slice it selected actually changes.
const useStore = create((set) => ({
user: null,
theme: "light",
setTheme: (theme) => set({ theme }),
}));
function ThemeToggle() {
const theme = useStore((state) => state.theme); // subscribes ONLY to `theme`
// re-renders when theme changes, NOT when user changes, unlike a single shared Context value would
return <button>{theme}</button>;
}Try It
Predict the re-render behavior before checking the solution.
// Version A: one Context holding { user, theme }
const AppContext = createContext();
function ThemeToggleA() {
const { theme } = useContext(AppContext); // reads only theme, but subscribes to the WHOLE value
return <button>{theme}</button>;
}
// Version B: a selector-based store
function ThemeToggleB() {
const theme = useStore((state) => state.theme); // subscribes ONLY to theme
return <button>{theme}</button>;
}If user changes (and theme doesn't), does ThemeToggleA re-render? Does ThemeToggleB?
Solution
ThemeToggleA does re-render, Context has no per-field subscriptions; any change to the Provider's value object re-renders every consumer, regardless of which field each consumer actually reads (see Context API). ThemeToggleB does not re-render, its selector subscribes specifically to state.theme, and the store only notifies subscribers whose selected slice actually changed. This is the precise mechanical difference that makes selector-based stores the better fit once many components need fine-grained access to frequently-changing shared state.
Implement It Yourself
Build a minimal selector-based store to see exactly how fine-grained subscriptions work:
function createStore(initialState) {
let state = initialState;
const listeners = new Set(); // each entry: { selector, callback, lastValue }
function getState() {
return state;
}
function setState(partial) {
state = { ...state, ...partial };
listeners.forEach((listener) => {
const nextValue = listener.selector(state);
if (!Object.is(nextValue, listener.lastValue)) {
listener.lastValue = nextValue;
This is the core mechanism every selector-based store (Zustand, Redux with useSelector) is built on: compare each subscriber's selected slice before and after, and only notify the ones whose slice actually differs, precisely the fine-grained control Context's all-or-nothing model lacks.
Under the Hood
A selector-based store is a direct application of the observer pattern from Design Patterns, a central subject (the store) maintaining a list of observers (subscribers), each notified only when the specific thing they care about changes, rather than broadcasting every change to every observer indiscriminately. This is the actual fix for the exact re-render-bypasses-memo problem raised in Context API, not a different framework feature, but a different, more granular notification mechanism entirely.
Common Mistakes
1. Reaching for a global store or Context for state that's only used in one place
const useGlobalStore = create((set) => ({ isModalOpen: false, ... })); // ❌ only ONE component ever reads thisThis violates colocation for no benefit, the state now has global visibility and indirection with nothing gained, since nothing outside that one component ever needed access to it.
2. Reaching for Context for high-frequency, widely-read state instead of a selector-based store
Covered above and in Context API, a Context value that changes often (mouse position, a frequently-updating counter) and is read by many components at once is exactly the case where Context's all-consumers-re-render behavior becomes a real, measurable performance problem, and a selector-based store's fine-grained subscriptions are the actual fix.
3. One monolithic reducer+Context for the entire app's state
Just as with Context generally, bundling every unrelated piece of state into one giant reducer means any single action re-renders every consumer of that one Context, splitting by concern (a TodosContext separate from a UserContext) applies here exactly as it does for plain Context.
Best Practices
- Colocate first. Default to local
useState; lift only once a value is genuinely needed by more than one component. - Reach for Context for lower-frequency, broadly-needed values, theme, authenticated user, locale, where all-consumers-re-render is an acceptable cost given how rarely those values actually change.
- Reach for a selector-based external store when many components need fine-grained access to frequently-changing shared state, this is the point where Context's limitations stop being theoretical and start being measurable.
- Split state by concern regardless of which mechanism you choose, one giant reducer, one giant Context, and one giant global store all share the exact same over-broad-re-render problem.
Performance Tips
- The ladder above is, at its core, a re-render-scope ladder: each rung trades some simplicity for more precise control over exactly which components re-render when a value changes.
- Selector-based subscriptions are the direct, purpose-built fix for the specific performance problem raised in Context API, reach for one the moment profiling shows a Context is causing broad, avoidable re-renders on a frequently-changing value.
