React Server Components changed the state management equation in ways that haven't fully settled yet. Server state — data fetched from a database or API — increasingly lives in Server Components rather than client-side stores. Async state — loading, error, and cache management for remote data — moved into TanStack Query and SWR years ago. What's left for a global state library to manage is narrower than it was in 2020: true client-side UI state that multiple components need to share.
That narrowing hasn't eliminated the need for state libraries. It has made the choice clearer. Here's a technical breakdown of the three libraries developers actually use in 2026 and a decision framework grounded in project characteristics, not hype.
What State Actually Needs Managing in 2026
Before comparing libraries, it's worth being precise about what you're managing. The three categories of state in a modern React app have different homes:
- Server state (data from APIs, databases): Belongs in TanStack Query, SWR, or React Server Components. Not in a global store.
- Async UI state (loading, error, cache invalidation): Also TanStack Query or SWR.
- Client UI state (sidebar open/closed, selected tab, form state across steps, notifications queue, user preferences persisted to localStorage): This is what Zustand, Redux Toolkit, and Jotai are for.
If most of your Redux store contained server-fetched data, RSC and TanStack Query can replace it without a global state library at all. The question of which library to pick becomes relevant when you identify genuine client UI state that multiple components need to share across the component tree.
Zustand: The Pragmatic Default for Most Projects
Zustand's appeal is that it has no ceremony. No providers (optional), no reducers, no action types, no boilerplate. A store is a function that returns state and actions:
import { create } from 'zustand';
interface SidebarStore {
isOpen: boolean;
activeSection: string | null;
openSidebar: (section: string) => void;
closeSidebar: () => void;
}
const useSidebarStore = create<SidebarStore>((set) => ({
isOpen: false,
activeSection: null,
openSidebar: (section) => set({ isOpen: true, activeSection: section }),
closeSidebar: () => set({ isOpen: false, activeSection: null }),
}));
// In any component — no Provider needed
function NavButton({ section }) {
const openSidebar = useSidebarStore((state) => state.openSidebar);
return <button onClick={() => openSidebar(section)}>Open</button>;
}
The selector pattern (state => state.openSidebar) is Zustand's performance story. Components only re-render when the specific slice they select changes. If NavButton only selects openSidebar, it won't re-render when activeSection changes.
Zustand's bundle footprint is 1.3KB minified + gzipped. It has zero peer dependencies. It works with React DevTools for basic state inspection. For persistence (localStorage, sessionStorage), the persist middleware adds about 10 lines of configuration:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const usePreferencesStore = create(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (lang) => set({ language: lang }),
}),
{ name: 'user-preferences' } // localStorage key
)
);
Where Zustand shows its limits: there's no built-in time-travel debugging, no middleware pipeline as flexible as Redux's, and no canonical pattern for complex state machines. For applications where those things matter, Redux Toolkit is the better fit.
Redux Toolkit: Still the Enterprise Standard
Redux Toolkit (RTK) is Redux with all the boilerplate removed. It includes createSlice (combining reducers and action creators), createAsyncThunk (async action handling), and optionally RTK Query (a full server state solution). If you're building a new project and choosing RTK, you're choosing it for specific architectural reasons, not because you're unaware of alternatives.
The legitimate reasons to choose RTK in 2026:
- Large team where consistent patterns across slices matter more than flexibility
- Complex state machines with many transitions that benefit from explicit action types
- Requirement for Redux DevTools time-travel debugging (genuinely useful for complex UIs)
- Existing Redux codebase that needs modernisation without a full rewrite
- Need for Redux middleware (logging, analytics, side-effect management via
redux-thunkorredux-saga)
A practical async action with createAsyncThunk:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserOrders = createAsyncThunk(
'orders/fetchByUser',
async (userId: string, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}/orders`);
if (!response.ok) throw new Error('Fetch failed');
return await response.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const ordersSlice = createSlice({
name: 'orders',
initialState: { items: [], status: 'idle', error: null },
reducers: {
clearOrders: (state) => { state.items = []; },
},
extraReducers: (builder) => {
builder
.addCase(fetchUserOrders.pending, (state) => { state.status = 'loading'; })
.addCase(fetchUserOrders.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchUserOrders.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
},
});
For projects already handling server state with TanStack Query, this async pattern is largely redundant — TanStack Query handles loading/error states better with less code. RTK's async handling is most valuable when you need async operations to also mutate global store state in complex ways.
Jotai: Atomic State for Granular Subscriptions
Jotai takes a different philosophical approach: instead of a single store, state is composed of atoms — small independent units of state that components subscribe to individually. This is closer to Recoil's model, but Jotai is simpler and actively maintained.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// Define atoms
const searchQueryAtom = atom('');
const selectedFiltersAtom = atom<string[]>([]);
// Derived atom — computed from other atoms
const filteredResultsAtom = atom((get) => {
const query = get(searchQueryAtom);
const filters = get(selectedFiltersAtom);
// Filtering logic here
return computeFilteredResults(query, filters);
});
// Components subscribe to exactly what they need
function SearchInput() {
const [query, setQuery] = useAtom(searchQueryAtom);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
function FilterPanel() {
const setFilters = useSetAtom(selectedFiltersAtom); // Only triggers on selectedFiltersAtom changes
return <FilterCheckboxes onChange={setFilters} />;
}
function ResultsList() {
const results = useAtomValue(filteredResultsAtom);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
The performance advantage of Jotai is that FilterPanel and SearchInput are entirely independent. Typing in the search input only causes SearchInput and ResultsList to re-render — not FilterPanel. With a single Zustand store, a selector in each component achieves similar granularity, but Jotai's atom model makes it structurally impossible to create the over-subscription mistakes that Zustand selectors can silently allow.
Jotai's sweet spot: component-level shared state where multiple components need different slices of a related state graph without prop drilling. It's also the natural fit for state that's derived from other state (like the filtered results example above), where Jotai's atom getter composition replaces useMemo chains.
TanStack Query: The Category That Doesn't Overlap
TanStack Query deserves mention not as a global state library but as the reason global state libraries became smaller in scope. If your project doesn't already use TanStack Query or SWR, adding it alongside your chosen state library handles the server-state category definitively:
- Automatic caching with configurable stale times
- Background refetching without user-visible loading states
- Deduplication of concurrent requests for the same data
- Optimistic updates with automatic rollback on error
- Pagination and infinite scroll with built-in cursor management
The standard 2026 stack for a Next.js Pages Router app or a Vite React app: TanStack Query for server state + Zustand for client UI state. For Next.js App Router: Server Components for server state + Zustand for client UI state. Neither combination needs Redux unless the complexity warrants it.
Decision Matrix: Which to Choose
| Scenario | Recommended |
|---|---|
| New project, small-to-mid team | Zustand |
| Large enterprise, many developers, complex state | Redux Toolkit |
| Component-level shared state, avoid prop drilling | Jotai |
| Derived/computed state from other state | Jotai |
| Existing Redux codebase, modernising it | Redux Toolkit (RTK migration) |
| Server-fetched data that components share | TanStack Query (not a global store) |
| Next.js App Router project | Zustand for client UI state only |
Frequently Asked Questions
Should a new React project in 2026 use Redux?
Only if the project's complexity justifies it. Redux Toolkit is well-maintained and still right for large enterprise apps with many developers, complex state machines, branching async workflows, and a need for time-travel debugging. For a new mid-size project — a SaaS dashboard, an e-commerce frontend, a content platform — Zustand handles 95% of the use cases with a fraction of the boilerplate. The question to ask: does your app need reducers, action types, and middleware? If the answer is no, Redux adds ceremony without payoff. Start with Zustand and upgrade if you genuinely hit the ceiling.
Can I use Zustand alongside React Server Components?
Zustand stores are client-only state — they cannot be initialised in Server Components because Server Components do not have access to browser APIs or React's client-side runtime. In a Next.js App Router project, Zustand stores must live in Client Components (files marked with "use client"). The typical pattern is to initialise the store in a client-side Provider component wrapping your app, then access the store in any Client Component child. Server Components that need shared state must receive it as props from a parent Client Component, or fetch the data themselves from the server. The architectural split is clean: Zustand manages client UI state; Server Components manage server data.
What happened to React Context for global state management?
React Context is still available and appropriate for low-frequency state — theme, locale, authenticated user, feature flags. These values rarely change, so Context's re-render behaviour (every consumer re-renders when the value changes) is acceptable. The problem with Context is high-frequency state: if you put a shopping cart or a multi-field form into Context, every subscribed component re-renders on every change. Zustand and Jotai use subscription-based models where components only re-render when their specific slice of state changes — a more granular model. Context remains in the toolkit for the right use cases; it shouldn't be used as a general-purpose mutable state container.