React State Management in 2026: What You Actually Need
Every year the React state management conversation resets. Redux, MobX, Zustand, Jotai, Recoil, Valtio — the list grows while the core problem stays the same: how do you share data between components without making the codebase unmaintainable?
The answer in 2026 is simpler than most articles suggest. You probably need less state management than you think.
The State Categories
All React state falls into four categories:
- Local state — form inputs, toggles, UI-only data →
useState - Computed state — derived from other state →
useMemo - Server state — data from APIs → React Query / SWR
- Shared UI state — theme, sidebar open, etc. → Context or Zustand
Most applications need only useState, useContext, and a server state library. Adding Zustand or Redux on top is only necessary when shared UI state becomes complex.
Server State Is Not App State
The biggest state management mistake is putting API data into Redux. Server state has different requirements:
- It is owned by the server, not the client
- It can become stale
- It needs caching, refetching, and background updates
- It has loading and error states
React Query handles all of this:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then(r => r.json()),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
});
}
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newPost: Post) =>
fetch("/api/posts", {
method: "POST",
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
React Query gives you caching, background refetching, optimistic updates, and error handling without writing any of that logic yourself. It removes 80% of the state management code in a typical application.
When Context Is Enough
React Context works well for:
- Theme (dark/light mode)
- Authentication state
- Locale/language
- Feature flags
const ThemeContext = createContext<{
theme: "dark" | "light";
toggleTheme: () => void;
}>({ theme: "dark", toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const toggleTheme = useCallback(() => {
setTheme(prev => prev === "dark" ? "light" : "dark");
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Key pattern: memoize the context value to prevent unnecessary re-renders. Without useMemo, every state change creates a new object reference, causing all consumers to re-render.
When Context Is Not Enough
Context re-renders every consumer when the value changes. If your context holds 10 values and you update one, all components consuming that context re-render — even if they only use a different value.
Signs you need Zustand:
- Context re-renders are causing performance issues
- You have frequent updates (typing, dragging, animations)
- Multiple independent state slices that different components consume
- You need state persistence across page navigations
Zustand: The Minimal Approach
import { create } from "zustand";
interface UIStore {
sidebarOpen: boolean;
toggleSidebar: () => void;
activeModal: string | null;
openModal: (id: string) => void;
closeModal: () => void;
}
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
activeModal: null,
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
}));
// Components only subscribe to what they need
function Sidebar() {
const isOpen = useUIStore((s) => s.sidebarOpen);
return isOpen ? <aside>...</aside> : null;
}
function ModalTrigger() {
const openModal = useUIStore((s) => s.openModal);
return <button onClick={() => openModal("settings")}>Settings</button>;
}
Zustand only re-renders a component when the specific slice of state it subscribes to changes. Sidebar only re-renders when sidebarOpen changes. ModalTrigger never re-renders because it only reads a function reference.
The Decision Tree
- Is the state only used in one component? →
useState - Is it derived from other state? →
useMemoor compute in render - Is it data from an API? → React Query
- Is it shared between a few nearby components? →
useContextwithuseMemo - Is it shared across many components with frequent updates? → Zustand
Anti-Patterns
Global state for everything. Putting form input values in global state adds complexity without benefit. Form state is local.
Duplicate server state. Copying API responses into Redux means you now have two sources of truth that can diverge.
Deep nesting of providers. If your root component has 12 nested providers, consider consolidating or using Zustand instead.
Takeaways
State management is a spectrum, not a binary choice. Start with the simplest tool (useState), separate server state into React Query, and only add a global store when shared UI state justifies it. Most production React applications need fewer state management tools than developers assume.