State Management in React: From useState to Zustand
⚓︎State Management in React: From useState to Zustand
State management is one of the first hurdles every React developer faces. Should you lift state up? Use Context? Reach for a library like Zustand? This article walks through each approach, when to use it, and when to move on to the next.
⚓︎Local State with useState
The simplest form of state is local component state. The useState hook gives you a state variable and a setter, and React re-renders the component when the state changes.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}Use when: The state only matters to this component and its direct children.
Limitation: When sibling components need to share the same state, you have to "lift it up" to a common parent and pass it down via props. This leads to prop drilling.
⚓︎useReducer for Complex State Logic
When your state has multiple related values or complex update logic, useReducer is cleaner than multiple useState calls.
import { useReducer } from "react";
type Action =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + action.amount;
case "decrement":
return state - action.amount;
case "reset":
return 0;
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: "increment", amount: 1 })}>
+1
</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}Use when: You have multiple state transitions, or the next state depends on the previous one in non-trivial ways.
⚓︎The Prop Drilling Problem
As your component tree grows, passing state through intermediate components that don't use it becomes painful.
function App() {
const [user, setUser] = useState({ name: "Alice" });
return (
<Layout user={user} setUser={setUser}>
<Sidebar user={user} />
<MainContent user={user} setUser={setUser}>
<Profile user={user} setUser={setUser} />
{/* Profile needs user, but MainContent and Layout
don't — they're just pass-through */}
</MainContent>
</Layout>
);
}Every component in the chain must accept and forward props it doesn't care about. This makes refactoring harder and components less reusable.
Solutions:
- Context API for medium-depth trees
- Zustand (or similar) for truly global state
⚓︎Context API for Shared State
React's Context API lets you broadcast state to any component in a subtree without explicit prop passing.
import { createContext, useContext, useState, type ReactNode } from "react";
interface User {
name: string;
theme: "light" | "dark";
}
const UserContext = createContext<User | null>(null);
const SetUserContext = createContext<((user: User) => void) | null>(null);
function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>({ name: "Alice", theme: "light" });
return (
<UserContext.Provider value={user}>
<SetUserContext.Provider value={setUser}>
{children}
</SetUserContext.Provider>
</UserContext.Provider>
);
}
function useUser() {
const user = useContext(UserContext);
if (!user) throw new Error("useUser must be used within UserProvider");
return user;
}
function useSetUser() {
const setUser = useContext(SetUserContext);
if (!setUser) throw new Error("useSetUser must be used within UserProvider");
return setUser;
}Now any descendant can consume the state:
function Profile() {
const user = useUser();
const setUser = useSetUser();
return (
<div>
<p>{user.name}</p>
<button onClick={() => setUser({ ...user, theme: "dark" })}>
Switch Theme
</button>
</div>
);
}Context triggers a re-render in every consumer when the value changes. If you have frequently updating state (like a live cursor position or a real-time form), this can cause performance issues. Context also doesn't prevent unnecessary re-renders — it only solves prop drilling.
⚓︎Zustand for Global State
For global state that many unrelated components need, a dedicated state management library like Zustand is often a better choice than Context.
import { create } from "zustand";
interface CartStore {
items: string[];
addItem: (item: string) => void;
removeItem: (item: string) => void;
clear: () => void;
}
const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (item) =>
set((state) => ({
items: state.items.filter((i) => i !== item),
})),
clear: () => set({ items: [] }),
}));Components consume the store directly — no providers needed:
function Cart() {
const items = useCartStore((state) => state.items);
const addItem = useCartStore((state) => state.addItem);
return (
<div>
<p>Items: {items.join(", ")}</p>
<button onClick={() => addItem("Apple")}>Add Apple</button>
</div>
);
}Why Zustand over Context:
- No provider wrapper needed
- Components only re-render when the specific slice they subscribe to changes
- Works outside React components (e.g., in API interceptors or utility files)
- Minimal boilerplate — no reducers, actions, or dispatch patterns required
Zustand is framework-agnostic at its core — the same store can be used in
React, Vue, or vanilla JS. The create function returns a hook when called
inside a React project, but the store pattern itself is pure TypeScript.
⚓︎When to Use What
Here's a quick decision guide:
| Situation | Approach |
|---|---|
| State used by one component | useState |
| Complex update logic, multiple sub-values | useReducer |
| State shared by a few related components | Lift state up + props |
| State needed by many components in a subtree | Context API |
| State needed by unrelated components across the app | Zustand |
| Frequently updating global state (real-time, form) | Zustand (avoids Context re-render issues) |
Rule of thumb: Start with the simplest solution that works. Reach for useState first. If prop drilling becomes annoying, try Context. If Context causes performance issues or you need state outside React, bring in Zustand.
⚓︎Conclusion
State management in React isn't one-size-fits-all. Each tool — useState, useReducer, Context, and Zustand — has a specific job. The key is knowing when to graduate from one to the next.
Start small, stay pragmatic, and let your app's architecture guide your choice — not the other way around.
Happy Coding! 🎉
