I've reached for Redux out of sheer habit on projects that never actually needed its structure, and skipped it entirely on projects that genuinely would have benefited, mostly because I was matching tools to familiarity rather than to the actual problem. Here's the decision process I actually use now instead.
Context works well for data that changes infrequently but needs to be accessible broadly, theme settings, authenticated user info, locale preferences, since every consumer re-renders whenever the context value changes, regardless of whether that specific consumer actually cares about the part that changed.
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("light");
return (
);
}
Using Context for frequently updating state, form inputs, a live-updating counter, real-time data, causes every component consuming that context to re-render on every single change, a performance problem I hit directly building a dashboard with frequently updating metrics before understanding why the whole page felt sluggish.
Zustand's minimal API, no providers wrapping your component tree, no boilerplate action types or reducers, has become my starting point for most client-side state that's more complex than Context comfortably handles but doesn't need Redux's more elaborate structure.
import { create } from "zustand";
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
}
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
}));
Components subscribe only to the specific slice of state they actually use, meaning a component watching cart item count doesn't re-render when an unrelated piece of state changes, directly solving the exact performance issue that pushed me away from Context for that dashboard project.
const itemCount = useCartStore((state) => state.items.length);
For genuinely large applications with a team of many developers touching shared state, Redux's strict, predictable pattern, actions, reducers, a single source of truth, and its mature dev tools for time-travel debugging and state inspection, provide real value specifically at that scale and team size that lighter tools don't match.
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
},
},
});
On a smaller project with two developers, Redux's action-reducer boilerplate for even simple state updates added real friction without a corresponding benefit, since the coordination problems Redux's strict structure solves for large teams simply didn't exist at that project's scale.
Rarely-changing, broadly-needed data: Context. Moderately complex client state on a small-to-medium team: Zustand. Genuinely large application state with many contributors needing strict predictability and time-travel debugging: Redux, specifically with Redux Toolkit rather than legacy hand-written boilerplate.
None of these three tools are actually built for server state, data fetched from an API that needs caching, background refetching, and invalidation. I use TanStack Query for that specifically, and mixing server state into a general client state tool, something I did early in my career, consistently produced stale-data bugs that a dedicated server-state library handles correctly by default.
Using Zustand for client UI state and TanStack Query for server state within the same application isn't a compromise, it's matching each tool to the specific kind of state it's actually built for, and I've stopped treating "pick one state management library" as a single, universal decision that has to cover every kind of state a real application handles.