
Mykola Nesterchuk - March 16, 2026
For years, the React state management question had one uncomfortable answer: Redux. It worked, but it came with a mountain of boilerplate - action types, reducers, selectors, middleware - and it forced you to treat data fetched from an API the same way you treated a toggle flag in a modal.
In 2026, the landscape looks completely different. The community has largely converged on a clearer mental model: server state and client state are different problems that need different tools. Once you internalize that split, the right library choices become obvious.
This article covers the current toolkit - Zustand, TanStack Query, Jotai, and plain React Context - with practical guidance on when to reach for each.
Before picking a library, it helps to categorize what you are actually managing.
Server state is data that lives on a backend. Your component does not own it - it just displays a local copy. It can become stale. It needs to be refetched. Other users or processes can change it independently.
Server state examples:
- User profiles, posts, products fetched from an API
- Paginated lists, search results
- Dashboard metrics that update over time
Client state is data your app owns entirely. There is no remote source of truth. It does not go stale.
Client state examples:
- Modal open/closed
- Active tab, selected items, sidebar collapsed
- Auth session (once fetched and stored locally)
- Theme preference
- Multi-step form draft
The mistake most codebases make: storing server state in a client state library. You fetch a list of posts into Redux, then write custom logic to invalidate it, refetch on focus, deduplicate concurrent requests, and handle background updates. TanStack Query does all of that for you out of the box.
TanStack Query (formerly React Query) is the default choice for anything that comes from an API. It handles caching, background refetching, deduplication, loading and error states, pagination, and optimistic updates.
// app/providers.tsx 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { useState } from 'react'; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // data is fresh for 1 minute retry: 2, }, }, })); return ( <QueryClientProvider client={queryClient}> {children} <ReactQueryDevtools /> </QueryClientProvider> ); }
import { useQuery } from '@tanstack/react-query'; interface Post { id: number; title: string; body: string; } const fetchPost = (id: number): Promise<Post> => fetch(`/api/posts/${id}`).then(res => { if (!res.ok) throw new Error('Failed to fetch'); return res.json(); }); function PostDetail({ id }: { id: number }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['posts', id], queryFn: () => fetchPost(id), }); if (isLoading) return <Skeleton />; if (isError) return <p>Error: {error.message}</p>; return <article><h1>{data.title}</h1><p>{data.body}</p></article>; }
The queryKey array is how TanStack Query identifies cached data. Change the id and it automatically refetches. Multiple components using the same key share one request.
import { useMutation, useQueryClient } from '@tanstack/react-query'; function DeletePost({ id }: { id: number }) { const queryClient = useQueryClient(); const deleteMutation = useMutation({ mutationFn: (postId: number) => fetch(`/api/posts/${postId}`, { method: 'DELETE' }).then(r => r.json()), // Optimistic update - remove from list before server responds onMutate: async (postId) => { await queryClient.cancelQueries({ queryKey: ['posts'] }); const previous = queryClient.getQueryData(['posts']); queryClient.setQueryData(['posts'], (old: Post[]) => old.filter(p => p.id !== postId) ); return { previous }; }, // Roll back on error onError: (err, postId, context) => { queryClient.setQueryData(['posts'], context?.previous); }, // Sync with server after success onSettled: () => { queryClient.invalidateQueries({ queryKey: ['posts'] }); }, }); return ( <button onClick={() => deleteMutation.mutate(id)}> {deleteMutation.isPending ? 'Deleting...' : 'Delete'} </button> ); }
Zustand is a tiny (~1KB) state library with a hook-based API and no provider required. It replaces the cases where you used to reach for Redux: global UI state, auth session, user preferences, anything that multiple components need to read and write but is not fetched from a server.
import { create } from 'zustand'; interface UIStore { sidebarOpen: boolean; activeTheme: 'light' | 'dark'; toggleSidebar: () => void; setTheme: (theme: 'light' | 'dark') => void; } export const useUIStore = create<UIStore>((set) => ({ sidebarOpen: false, activeTheme: 'dark', toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), setTheme: (theme) => set({ activeTheme: theme }), }));
Always select only the slice you need. This prevents the component from re-rendering when unrelated parts of the store change:
// ✅ Only re-renders when sidebarOpen changes const sidebarOpen = useUIStore((state) => state.sidebarOpen); const toggleSidebar = useUIStore((state) => state.toggleSidebar); // ❌ Re-renders on any store change const store = useUIStore();
For apps with more state, split into focused slices combined into one store:
import { create, StateCreator } from 'zustand'; interface AuthSlice { user: { id: string; name: string } | null; setUser: (user: AuthSlice['user']) => void; logout: () => void; } interface CartSlice { items: string[]; addItem: (id: string) => void; removeItem: (id: string) => void; } const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = (set) => ({ user: null, setUser: (user) => set({ user }), logout: () => set({ user: null }), }); const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = (set) => ({ items: [], addItem: (id) => set((state) => ({ items: [...state.items, id] })), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i !== id) })), }); export const useStore = create<AuthSlice & CartSlice>()((...args) => ({ ...createAuthSlice(...args), ...createCartSlice(...args), }));
Zustand has a built-in persist middleware:
import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export const usePreferencesStore = create( persist( (set) => ({ theme: 'dark' as 'light' | 'dark', language: 'en', setTheme: (theme: 'light' | 'dark') => set({ theme }), }), { name: 'user-preferences' } ) );
Jotai takes a different approach. Instead of a single store, state is broken into small independent units called atoms. Components subscribe only to the atoms they use, which makes re-renders highly granular.
It shines in two scenarios: complex derived state (values computed from multiple sources) and async atoms with Suspense integration.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; // Primitive atoms const searchQueryAtom = atom(''); const filtersAtom = atom<string[]>([]); // Derived atom - computed from other atoms // Only re-runs when searchQuery or filters change const activeFilterCountAtom = atom( (get) => get(filtersAtom).length ); // Async derived atom - integrates with Suspense const searchResultsAtom = atom(async (get) => { const query = get(searchQueryAtom); const filters = get(filtersAtom); if (!query) return []; return await fetchSearch(query, filters); }); // Persisted atom const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'dark');
function SearchInput() { const [query, setQuery] = useAtom(searchQueryAtom); const filterCount = useAtomValue(activeFilterCountAtom); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <span>{filterCount} filters active</span> </div> ); } // SearchResults only re-renders when searchResultsAtom changes function SearchResults() { const results = useAtomValue(searchResultsAtom); return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>; }
For most apps, Jotai is optional. If Zustand covers your client state well and you do not need atomic granularity or async atoms, you do not need Jotai. It is a tool for specific patterns, not a replacement for Zustand.
Context is not a state management library - it is a dependency injection mechanism. It is the right choice for state that changes infrequently and is consumed by many components at once.
// Good use of Context: theme, locale, auth user (read-heavy, rarely updates) const ThemeContext = createContext<'light' | 'dark'>('dark'); export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<'light' | 'dark'>('dark'); return ( <ThemeContext.Provider value={theme}> {children} </ThemeContext.Provider> ); }
The problem with Context for frequently updating state: every consumer re-renders whenever the context value changes. If you store a cart, a form, or any state that changes often in Context, you will get unnecessary re-renders throughout the tree - unless you add careful memoization that undoes the simplicity of using Context in the first place.
The rule: use Context for low-frequency state (themes, locale, auth user). Use Zustand for anything that updates often or needs to be written from multiple places.
A practical layered model for most React apps in 2026:
Layer Tool
-------------------------------------------------
Server state TanStack Query
Global UI state Zustand
Atomic/derived Jotai (only if needed)
Local component useState / useReducer
Form state React Hook Form
DI / config React Context
Here is what that looks like in a real component that touches multiple layers:
'use client'; import { useQuery } from '@tanstack/react-query'; import { useUIStore } from '@/stores/ui-store'; function ProductsPage() { // Server state - TanStack Query owns this const { data: products, isLoading } = useQuery({ queryKey: ['products'], queryFn: fetchProducts, }); // Global client state - Zustand owns this const sidebarOpen = useUIStore((s) => s.sidebarOpen); const toggleSidebar = useUIStore((s) => s.toggleSidebar); // Local state - useState is fine here const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); if (isLoading) return <ProductsSkeleton />; return ( <div> <button onClick={toggleSidebar}> {sidebarOpen ? 'Close' : 'Open'} filters </button> <button onClick={() => setViewMode(v => v === 'grid' ? 'list' : 'grid')}> {viewMode} view </button> <ProductGrid products={products} mode={viewMode} /> </div> ); }
Notice there is no Redux anywhere. Each kind of state uses the simplest tool that handles it correctly.
A pattern that causes bugs in almost every codebase that uses both libraries:
// ❌ Don't do this - you now have two sources of truth const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser, }); useEffect(() => { if (data) setUserInZustand(data); // duplicating server state }, [data]);
Now you have to keep TanStack Query's cache and your Zustand store in sync manually. When TanStack Query refetches in the background and gets new data, your Zustand store stays stale.
The fix: read server data directly from TanStack Query. Put only genuine client state in Zustand.
// ✅ Each library handles its own domain const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); const preferences = usePreferencesStore((s) => s.preferences); // client-only
Redux Toolkit remains a solid choice for large teams with specific needs:
For new projects started in 2026, most teams reach for Zustand first and only add Redux when they hit a problem Zustand cannot solve cleanly.
The practical guide to React state management in 2026: