The reflex is almost automatic: state needs to be shared between two components, so reach for a global store. Most of the time that’s the wrong first move, not because global stores are bad, but because the question that actually decides what you need isn’t “is this shared,” it’s “who reads it, how often does it change, and where does it come from.” Answer that first and the library choice mostly falls out of it.
One component owns it: useState, and stop there
This sounds too obvious to write down, and it’s the branch people skip past fastest on the way to reaching for something bigger. If a piece of state is only ever read and changed inside one component, a form field, whether a dropdown is open, a hover state, that’s useState and nothing else. No context, no store. The most common over-engineering mistake in React apps isn’t a bad library choice, it’s promoting local state to global state before anything actually needed it to be global.
A few nearby components: lift it up
When two or three components that live close together in the tree need the same piece of state, the answer is usually to move that state to their nearest common parent and pass it down as props. This is unglamorous and it works, and it keeps the data flow visible: you can read the component tree and see exactly where a value comes from.
// Before: two siblings each guessing at shared state independently
function FilterPanel() {
const [query, setQuery] = useState("");
// ...
}
function ResultsList() {
const [query, setQuery] = useState(""); // now two sources of truth
}
// After: the parent owns it, children just receive and report
function SearchPage() {
const [query, setQuery] = useState("");
return (
<>
<FilterPanel query={query} onQueryChange={setQuery} />
<ResultsList query={query} />
</>
);
}Read broadly, changes rarely: Context is genuinely fine
Once state needs to reach components that are far apart in the tree, current theme, the logged-in user, active feature flags, Context earns its keep. The caveat almost nobody mentions until it bites them: every component that calls useContext on a given context re-renders whenever any value in that context changes, even the parts it doesn’t use.
// One context holding unrelated things: a change to notifications
// re-renders every component reading user, even if it only cares about user.id
const AppContext = createContext({ user, theme, notifications });
// Split by how often each piece changes and who actually needs it
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const NotificationsContext = createContext(notifications);Splitting contexts by change frequency, not by topic, is the fix that actually matters. Group things that change together, and keep anything that updates often (like a live notification count) out of a context that also holds something stable (like the user object), or every notification will re-render every component reading the user.
Changes often, read broadly: this is where an external store earns its cost
When state updates frequently and a lot of distant components need to read slices of it, live filters across a dashboard, cursor positions in a collaborative tool, Context starts to genuinely hurt, because there’s no way to subscribe to only part of it. This is the actual point where something like Zustand or Jotai pays for its added dependency: not because it’s more powerful than Context in principle, but because it supports selector-based subscriptions, so a component can read one slice of the store and only re-render when that slice changes.
const useDashboardStore = create((set) => ({
filters: { region: "all", status: "active" },
cursorPositions: {},
setFilter: (key, value) =>
set((s) => ({ filters: { ...s.filters, [key]: value } })),
}));
// This component re-renders only when `filters.status` changes,
// not when cursorPositions updates dozens of times a second.
function StatusFilter() {
const status = useDashboardStore((s) => s.filters.status);
const setFilter = useDashboardStore((s) => s.setFilter);
return (
<select value={status} onChange={(e) => setFilter("status", e.target.value)}>
<option value="active">Active</option>
<option value="all">All</option>
</select>
);
}Data from a server isn’t state, it’s a cache
This is the branch that gets missed most: something fetched from an API and shoved into useState or a global store isn’t really application state at all, it’s a cached copy of data that lives somewhere else and can go stale. Treating it like ordinary state means hand-rolling loading flags, error handling, refetching on window focus, and request deduplication, all of which a library like React Query or SWR already does. The signal that you’re in this branch: if the answer to “where does this data live” is “the server, and this is just a copy,” stop modeling it as state and start modeling it as a cache with a key.
The actual takeaway
Nobody needs a stronger opinion about which library is best. What’s missing is usually the upstream question: how often does this change, how many places need to read it, and where does it actually come from. Answer those three honestly for the specific piece of state in front of you, not for the app as a whole, and the right tool for that one piece stops being a debate.