The State of React State Management in 2026
TL;DR
Zustand for global state. TanStack Query for server state. Jotai for atomic/derived state. The biggest shift in React state management since 2023: server state and client state are now clearly separated disciplines. TanStack Query (~5M weekly downloads) owns server state — caching, refetching, loading states. Zustand (~4M) owns client state — UI state, settings, auth. Redux Toolkit (~4M) is still dominant in large enterprise apps but rarely chosen for new projects in 2026.
Key Takeaways
- TanStack Query: ~5M weekly downloads — server state standard, cache, background refetch
- Zustand: ~4M downloads — minimal global store, ~1KB, no boilerplate
- Redux Toolkit: ~4M downloads — legacy dominant, still enterprise standard
- Jotai: ~2M downloads — atomic model, derived state, no store concept
- Server components — React Server Components shift more state to the server in 2026
The 2026 State Management Philosophy
Server State vs Client State
The mental model that clarified everything:
Server State Client State
─────────────────────────────────────────────────
• Data from APIs • UI state (modals, drawers)
• User's posts, settings • Form drafts (before submit)
• Package health scores • Selected items
• Needs: caching, refetch, • Auth session (once fetched)
loading states, deduplication • Theme preferences
• Multi-step wizard state
→ Use TanStack Query → Use Zustand / Jotai
This separation is why the "which state library?" question got simpler: you probably need both, for different things.
TanStack Query (Server State)
// TanStack Query — server state management
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
useInfiniteQuery,
} from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 min: fresh → stale
gcTime: 5 * 60 * 1000, // 5 min: remove from cache
retry: 3, // Retry failed requests
refetchOnWindowFocus: true, // Auto-refetch when tab refocuses
},
},
});
// Query: fetch and cache
function PackageDetails({ name }: { name: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['package', name], // Cache key
queryFn: () => fetchPackage(name),
staleTime: 5 * 60 * 1000, // Override: 5min fresh
});
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <div>{data.name} v{data.version}</div>;
}
// Two components using same queryKey: ONE network request
function DownloadCount({ name }: { name: string }) {
const { data } = useQuery({
queryKey: ['package', name], // Same key — uses cache!
queryFn: () => fetchPackage(name),
});
return <span>{data?.downloads?.toLocaleString()}</span>;
}
// TanStack Query — mutations with cache invalidation
function AddToWatchlist({ packageName }: { packageName: string }) {
const queryClient = useQueryClient();
const addMutation = useMutation({
mutationFn: (name: string) => api.addToWatchlist(name),
onSuccess: () => {
// Invalidate: next render will refetch
queryClient.invalidateQueries({ queryKey: ['watchlist'] });
},
onMutate: async (name) => {
// Optimistic update: add immediately before server responds
await queryClient.cancelQueries({ queryKey: ['watchlist'] });
const previous = queryClient.getQueryData(['watchlist']);
queryClient.setQueryData(['watchlist'], (old: string[]) => [...old, name]);
return { previous };
},
onError: (err, name, context) => {
// Roll back on error
queryClient.setQueryData(['watchlist'], context?.previous);
},
});
return (
<button onClick={() => addMutation.mutate(packageName)}>
{addMutation.isPending ? 'Adding...' : 'Add to Watchlist'}
</button>
);
}
// TanStack Query — infinite scroll
function PackageList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['packages'],
queryFn: ({ pageParam = 0 }) => fetchPackages({ offset: pageParam }),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length * 20 : undefined,
});
return (
<>
{data?.pages.flatMap(page => page.packages).map(pkg => (
<PackageCard key={pkg.name} pkg={pkg} />
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load More
</button>
)}
</>
);
}
Zustand (Global Client State)
// Zustand — global store, minimal API
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface AppState {
// UI state
sidebarOpen: boolean;
activeModal: string | null;
theme: 'light' | 'dark' | 'system';
// User state
watchlist: string[];
// Actions
toggleSidebar: () => void;
openModal: (modal: string) => void;
closeModal: () => void;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
addToWatchlist: (pkg: string) => void;
removeFromWatchlist: (pkg: string) => void;
}
export const useAppStore = create<AppState>()(
devtools(
persist(
(set) => ({
sidebarOpen: false,
activeModal: null,
theme: 'system',
watchlist: [],
toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
openModal: (modal) => set({ activeModal: modal }),
closeModal: () => set({ activeModal: null }),
setTheme: (theme) => set({ theme }),
addToWatchlist: (pkg) => set(state => ({
watchlist: [...state.watchlist, pkg],
})),
removeFromWatchlist: (pkg) => set(state => ({
watchlist: state.watchlist.filter(p => p !== pkg),
})),
}),
{
name: 'app-storage', // localStorage key
partialize: (state) => ({ // Only persist these
theme: state.theme,
watchlist: state.watchlist,
}),
}
)
)
);
// Usage — no Provider needed!
function Sidebar() {
const isOpen = useAppStore(state => state.sidebarOpen);
const toggle = useAppStore(state => state.toggleSidebar);
return <nav className={isOpen ? 'open' : 'closed'} onClick={toggle} />;
}
// Selector prevents unnecessary re-renders
function WatchlistCount() {
const count = useAppStore(state => state.watchlist.length);
return <span>{count}</span>; // Only re-renders when count changes
}
Jotai (Atomic State)
// Jotai — atoms composable like hooks
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
// Primitive atoms
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'dark');
const searchQueryAtom = atom('');
const selectedPackagesAtom = atom<string[]>([]);
// Derived atom (computed from others)
const searchResultsAtom = atom(async (get) => {
const query = get(searchQueryAtom);
if (!query) return [];
return await searchPackages(query); // Async derived!
});
// Write-only atom (action)
const addPackageAtom = atom(null, (get, set, name: string) => {
const current = get(selectedPackagesAtom);
set(selectedPackagesAtom, [...current, name]);
});
// Usage
function SearchBar() {
const [query, setQuery] = useAtom(searchQueryAtom);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
function SearchResults() {
const results = useAtomValue(searchResultsAtom); // Suspense-compatible!
return <ul>{results.map(r => <li key={r.name}>{r.name}</li>)}</ul>;
}
function ThemeToggle() {
const [theme, setTheme] = useAtom(themeAtom);
return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>
{theme}
</button>;
}
Best for: Apps with many small, independent pieces of state that derive from each other.
Redux Toolkit (Enterprise)
// Redux Toolkit — still dominant in large apps
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
// Async thunk for API calls
const fetchPackage = createAsyncThunk(
'packages/fetch',
async (name: string) => {
const response = await api.getPackage(name);
return response.data;
}
);
const packagesSlice = createSlice({
name: 'packages',
initialState: {
items: {} as Record<string, Package>,
status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed',
},
reducers: {
// Sync actions
addToWatchlist(state, action) {
state.watchlist.push(action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchPackage.pending, (state) => { state.status = 'loading'; })
.addCase(fetchPackage.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items[action.payload.name] = action.payload;
});
},
});
Best for: Enterprise apps where Redux devtools, time-travel debugging, and existing Redux codebase are valuable.
Comparison: When to Use What
| Library | For | Complexity | Bundle |
|---|---|---|---|
| TanStack Query | Server/API state | Low | ~15KB |
| Zustand | Global UI state | Minimal | ~1KB |
| Jotai | Atomic / derived state | Low | ~3KB |
| Redux Toolkit | Large enterprise apps | Medium-High | ~15KB |
| React Context | Simple / localized state | None | 0 |
| useState / useReducer | Local component state | None | 0 |
The 2026 Pattern: Layered State
Layer 1: Server State → TanStack Query
Layer 2: Global Client State → Zustand
Layer 3: Atomic/Derived → Jotai (if needed)
Layer 4: Local Component → useState / useReducer
Layer 5: Form State → React Hook Form
Most apps in 2026 use TanStack Query + Zustand + React Hook Form and rarely need anything else.
Compare state management package health on PkgPulse.
See the live comparison
View zustand vs. jotai on PkgPulse →