Choosing React State Management: From useState to Zustand
State management is a perennial React topic, and too many choices can create confusion. Learn the right layer for useState, useReducer, Context, Zustand, and Redux; client vs. server state with React Query/SWR; and when Context or a global store is appropriate.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Choosing React State Management: From useState to Zustand
Whenever you take over an old React project, you almost always see the same thing: the root component is wrapped in a huge Redux Store filled with theme colors, user information, temporary form input, list data, pagination page numbers... Then everyone complains that it is too heavy and convoluted, and changing one field means touching five files.
This is not Redux's fault; it is a tool-selection problem. State management has never been about “choosing the most powerful option”; it is about “choosing the lightest one at the right layer.” This article thoroughly explains React state management: how many layers state has, what to use for each, when to bring in a library, when Context is enough, and why server state should not go into Redux at all.
I. State Management Is Not Better Because It Is More Powerful: Choose the Lightest Option for the Scenario
A common misconception is: “Once a project gets large, you need Redux.” That was true in 2018; today, it is basically wrong.
The reason is simple: ever since React introduced Hooks, its built-in state capabilities have become very powerful. useState, useReducer, and useContext can solve 80% of state needs. Add React Query / SWR for server state, and the scenarios that truly require global libraries such as Redux / Zustand have been narrowed even further.
There is only one core principle for choosing: use the lightest solution that solves the problem. Unnecessary abstraction is future debt. If useState can handle something and you insist on putting it into Redux, you do not get “maintainability”; you get “jumping across five files to change one line of code.”
So the right question is not “which state management library should I use?” but “which layer does this state belong to?” Once you classify the layer correctly, the library choice is almost automatic.
II. State Layers: Put State at the Right Level
I personally divide state in React applications into four layers, from light to heavy:
| Layer | Scope | Typical tools | Examples |
|---|---|---|---|
| Component-local state | Inside a single component | useState / useReducer | Input values, expanded/collapsed state, loading flags |
| Shared state across components | Within a subtree | Context + useState | Theme, current user, i18n language |
| Global application state | The entire App | Zustand / Redux / Jotai | Shopping cart, multi-step form aggregation, complex UI orchestration |
| Server state | Backend data cache | React Query / SWR | API lists, details, user profiles |
There is one very important rule across these four layers: first determine which layer the state belongs to, then choose the tool. The problem in many teams is not that they chose the wrong tool, but that they never distinguished “is this local state or server state?” They put list data returned by APIs into Redux, then hand-write loading / error / refetch / pagination / invalidation, and ultimately build a stripped-down React Query themselves.
Let us unpack each layer below.
III. useState/useReducer: Do Not Add a Library When Built-Ins Suffice
This is where 90% of state belongs. If a piece of state is only read and written by the current component (or its direct child components through callbacks), keep it in the component.
function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSearch(query)}
/>
);
}
There is no reason for state like query to go into Context, much less Redux. It lives in this one component, is destroyed when the component unmounts, and stays clean and straightforward.
When local state becomes complex (multiple fields interact and state transitions follow rules), move up to useReducer. A typical signal is that you have written three or four useState calls and their updates depend on one another, such as “opening a modal must reset the form, and closing it must clear errors.” In that case, useReducer is much clearer than a pile of useState calls:
type State = { step: number; data: FormData | null; error: string | null };
type Action =
| { type: 'NEXT'; payload: FormData }
| { type: 'RESET' }
| { type: 'ERROR'; payload: string };
function wizardReducer(state: State, action: Action): State {
switch (action.type) {
case 'NEXT':
return { ...state, step: state.step + 1, data: action.payload, error: null };
case 'RESET':
return { step: 0, data: null, error: null };
case 'ERROR':
return { ...state, error: action.payload };
default:
return state;
}
}
function MultiStepForm() {
const [state, dispatch] = useReducer(wizardReducer, { step: 0, data: null, error: null });
// ...
}
Notice an important decision point: if useReducer state is only used in the current component, do not pair it with useContext. Many people see a reducer and reflexively wrap it in a Context Provider to broadcast the state. The result is that every dispatch re-renders the entire subscribed subtree. This is classic “over-engineering.”
IV. Context: Share Across Components, but Do Not Abuse It
Context solves prop drilling. When some state needs to be shared across multiple component levels but is not large enough to warrant global management, Context is the most natural solution. The two classic scenarios are theme and the currently logged-in user.
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
Context's sweet spot is global values that change infrequently: theme switching, language switching, user identity, and feature flags. These values change very rarely, so even if all consumers re-render together when they change, the performance cost is barely noticeable.
But Context has a repeatedly overlooked trap: whenever a Context's value changes, every component consuming it re-renders, regardless of which field in the value it uses. This means that if you put a frequently changing value into Context, such as the current text in an input, mouse position, or scroll position, the entire subscribed tree will re-render frantically and performance will collapse.
So there is a hard rule: do not use Context for frequently changing state. As soon as you find that a Context value changes on every user interaction, consider two things:
- Split Contexts: split high-frequency and low-frequency fields into two Contexts so consumers take only what they need.
- Switch to Zustand: use selector subscriptions (selective subscription), so a component re-renders only when the field it cares about changes.
Also, Context is a tool for localized sharing, not a “single global instance.” A Provider can wrap a particular subtree and serve only that small area. Do not put every Context at the root of the App from the start. Place them close to their scopes: component boundaries become clearer, and testing becomes easier.
V. Zustand: The Sweet Spot for Most Projects
If your global state starts becoming complex, such as a shopping cart that must store product lists, calculate totals, and support undo, or a multi-tab editor that must share editing state across several non-adjacent components, Context starts to fall short (mainly because of performance and boilerplate), while Redux is too heavy. Zustand is precisely the sweet spot in the middle.
Its advantages are very straightforward:
- Minimal API: one
createfunction handles everything, with no action types, reducers, or dispatch. - Selector subscriptions: a component can subscribe only to the fields it cares about, and changes to other fields do not trigger a re-render. Context does not have this capability.
- No intrusion into the component tree: no Provider wrapper is needed; any component can call
useStore()directly.
import { create } from 'zustand';
interface CartItem { id: string; name: string; price: number; qty: number; }
interface CartStore {
items: CartItem[];
add: (item: CartItem) => void;
remove: (id: string) => void;
clear: () => void;
total: () => number;
}
const useCart = create<CartStore>((set, get) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
The key pattern on the consuming side: subscribe only to the fields you need:
// 只在 items 变化时重渲染
const items = useCart((s) => s.items);
const add = useCart((s) => s.add);
// 不要这样写,会让组件在任何状态变化时都重渲染
// const { items, add, remove, clear } = useCart();
That one-line selector is where Zustand is stronger than Context. In Context, it is difficult to achieve “only update when items changes.” You either hand-write a combination of memoization and Context splitting, or accept full re-renders. Zustand makes this its default behavior.
When should you choose Zustand? My criteria are:
- State needs to be shared among several non-adjacent components (prop passing is not viable, and wrapping them in one Context is not appropriate).
- State changes relatively frequently (for example, a shopping cart, rich-text editor state, or complex form aggregation).
- State logic has a certain level of complexity, but has not reached the point of “needing a complete framework.”
When all three apply, Zustand is basically the answer. For most medium-sized applications, the combination of Zustand + React Query is enough to support the entire state layer.
VI. Redux: Heavy, and You Need a Reason to Choose It
Redux is not bad; most projects simply do not need something that heavy.
Redux's real value lies in two things:
- Predictable state evolution: the action → reducer → new state chain is one-way and traceable. With middleware (
redux-thunk,redux-saga), it can handle highly complex side-effect orchestration. - Time-travel debugging: DevTools lets you replay every state change, making problem diagnosis extremely efficient.
But the cost is: a lot of boilerplate, a steep learning curve, and high change costs. Adding a simple field might mean changing one place in Zustand, but in classic Redux it may require touching an action type, action creator, and reducer in three places. Redux Toolkit has greatly reduced this boilerplate, but the mental model is still heavy.
Redux is therefore truly suited to: very large applications, multi-person team collaboration, and scenarios with strict requirements for state traceability (finance, healthcare, complex workflows). In these cases, Redux's constraints are an advantage: they force everyone to write code using the same paradigm and avoid the looseness where every Zustand store is written differently.
If you are unsure whether you need Redux, you probably do not. A tool you cannot use well is worse than no tool, especially Redux.
VII. Use React Query/SWR for Server State, Not Redux
This is one of the most important shifts in thinking in the React community in recent years: server state and client state are two fundamentally different things and should not be managed with the same tool.
Server state has several characteristics that client state does not:
- It is asynchronous (the three states of loading / success / error).
- It needs caching (the same API used in several places should not be requested multiple times).
- It can become stale (someone else may have changed the data, so you need to invalidate and refetch it).
- It needs optimistic updates and rollback on failure.
- It must handle pagination, infinite scrolling, and race conditions.
Managing these with Redux means hand-writing React Query yourself, and you will probably not write it as well. React Query provides all of the above as built-in capabilities:
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 60_000, // 1 分钟内不重新请求
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return data.map((u) => <UserRow key={u.id} user={u} />);
}
Mutations and automatic invalidation are declarative as well:
const mutation = useMutation({
mutationFn: (newUser) => api.createUser(newUser),
onSuccess: () => {
// 创建成功后,自动让 users 列表失效并重新拉取
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
SWR has a similar role with a more minimal API and is suitable for small and medium projects. React Query has a fuller feature set (mutations, dependent queries, parallel queries, pagination) and suits more complex scenarios. Either is fine; the key is to stop hand-rolling loading / error / refetch.
After handing server state to React Query, you can slim down Redux / Zustand by half: they only need to manage genuine client state (UI state, form aggregation, and locally edited drafts). That is what they were meant to do.
VIII. Selection Decision Tree
Compress the preceding seven sections into an actionable decision chain. When adding a new piece of state, ask yourself these questions in order:
- Is this server data? (from an API, requiring caching / invalidation / retry) → React Query / SWR. Stop there; do not put it into any client-state library.
- Is it only used by the current component? →
useState. If multiple related fields interact, useuseReducer. - Does it need to be shared within a subtree? (and change infrequently) → Context. Put the Provider close to the subtree.
- Does it need to be shared across multiple non-adjacent components and change frequently? → Zustand (the default recommendation), unless the team already has an established Redux foundation.
- Is it a very large application that needs time-travel debugging and strongly constrained team collaboration? → Redux Toolkit.
Note that the order of this decision chain is itself the answer: start with the lightest option, and upgrade only when it is not enough. Do not skip levels. If useState can solve the problem, do not introduce Zustand early just because “it might need to be shared in the future.” YAGNI (You Aren't Gonna Need It) is especially important in state management; premature abstraction is harder to dismantle than late abstraction.
There is also a frequently overlooked middle option: state lifting. Do two sibling components need to share state? First lift it to their common parent and pass it down with props + callbacks. Only consider Context or an external store when the parent hierarchy is too deep and the cost of prop passing is genuinely high. Do not jump to a global library as soon as you see shared state.
Summary
- Choose tools by layer, not by “which one is strongest.” State has four layers: local, cross-component, global, and server state. Each has its most suitable tool.
- Prioritize
useState/useReducerfor local state. Do not add a library when built-ins suffice, and a reducer does not necessarily need Context. - Context is suitable for low-frequency global values (theme, user, i18n). Do not put frequently changing state in it, or the entire tree will re-render.
- Zustand is the sweet spot for most projects: its API is simple and selector subscriptions perform well, making it the first choice for global state in medium-sized applications.
- Reserve Redux for very large applications and strongly constrained scenarios. For most projects, it is over-engineering.
- Hand server state to React Query / SWR. Caching, invalidation, retries, and loading should not be hand-written, and Redux / Zustand should not carry that burden.
Key takeaway: State management is not about choosing the strongest tool; it is about choosing the lightest tool that solves the problem. A truly senior engineer does not make simple problems complicated, but makes complex problems look simple.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.