updated
React State Management in 2026: Zustand, TanStack Query, Jotai, and When to Use Each
The state management landscape has settled. Server state and client state are now separate disciplines with different tools. Here is the practical breakdown of Zustand, TanStack Query, Jotai, and Context - when to use each and how they fit together.

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.
Server state and client state are different problems
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 timeClient 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 draftThe 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 owns everything that comes from the server
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.
Basic setup
// 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>
);
}Fetching with useQuery
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.
Mutations with cache invalidation
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
// (see https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates)
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 holds the client state that's truly global
Zustand is a tiny 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.
Creating a store
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 }),
}));Reading with selectors
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();Organizing larger stores with slices
For apps with more state, split into focused slices combined into one store (slices pattern):
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),
}));Persisting state across sessions
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, when state is better split into atoms
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 enough for values that rarely change
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, and memo doesn't stop it. 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.
Try it: press Volume +1 in each column. Both columns hold the same two values, but with Context the theme row re-renders too, because every consumer re-renders when the value changes. With a Zustand selector, only the row that reads volume does.
import { createContext, useContext, useRef, useState } from 'react';
import { create } from 'zustand';
import './styles.css';
function Row({ label, value, children }) {
// Counting renders in a ref is fine for a demo; don't do it in real code.
const renders = useRef(0);
renders.current += 1;
return (
// A new key on every render replays the flash animation.
<div className="row" key={renders.current}>
<span>{label}: <b>{value}</b></span>
{children}
<small>renders: {renders.current}</small>
</div>
);
}
/* ---------- Context: one value with both fields ---------- */
const SettingsContext = createContext(null);
function SettingsProvider({ children }) {
const [settings, setSettings] = useState({ theme: 'dark', volume: 5 });
const louder = () => setSettings((s) => ({ ...s, volume: s.volume + 1 }));
return <SettingsContext.Provider value={{ ...settings, louder }}>{children}</SettingsContext.Provider>;
}
function ContextTheme() {
const { theme } = useContext(SettingsContext);
return <Row label="theme" value={theme} />;
}
function ContextVolume() {
const { volume, louder } = useContext(SettingsContext);
return (
<Row label="volume" value={volume}>
<button onClick={louder}>Volume +1</button>
</Row>
);
}
/* ---------- Zustand: components pick what they read ---------- */
const useSettings = create((set) => ({
theme: 'dark',
volume: 5,
louder: () => set((s) => ({ volume: s.volume + 1 })),
}));
function StoreTheme() {
const theme = useSettings((s) => s.theme);
return <Row label="theme" value={theme} />;
}
function StoreVolume() {
const volume = useSettings((s) => s.volume);
const louder = useSettings((s) => s.louder);
return (
<Row label="volume" value={volume}>
<button onClick={louder}>Volume +1</button>
</Row>
);
}
export default function App() {
return (
<main>
<section>
<h2>Context</h2>
<SettingsProvider>
<ContextTheme />
<ContextVolume />
</SettingsProvider>
</section>
<section>
<h2>Zustand + selectors</h2>
<StoreTheme />
<StoreVolume />
</section>
</main>
);
}How the pieces fit in one app
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 ContextHere 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.
Don't copy server state into Zustand
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-onlyWhen Redux still earns its place
Redux Toolkit remains a solid choice for large teams with specific needs (the Redux docs themselves say not every app needs Redux):
- More than 10 developers on the frontend - Redux's strict patterns prevent inconsistent state mutations across a large team
- Time-travel debugging is a hard requirement for your workflow
- You are already on Redux and the cost of migration outweighs the benefits
- The app has genuinely complex state transitions that benefit from Redux's action/reducer model
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.
Which tool for which state
- Split server and client state first - this is the core mental model
- TanStack Query for anything fetched from an API: caching, refetching, mutations, optimistic updates
- Zustand for global UI state: auth session, sidebar, theme, anything that does not come from a server
- Jotai when you need atomic granularity or async derived state
- Context for low-frequency dependency injection: theme, locale
- useState / useReducer for local component state - do not lift it unless you have to
- Never sync server state into Zustand - you will create two sources of truth
- Redux only for large teams that need its strict structure, or existing codebases where migration cost is too high
Frontend Developer · React, TypeScript, Next.js
Have a question, spotted a mistake or see it differently? I'd genuinely like to hear it.
interview questions on this
view all- What is the difference between local, global, server, and URL state?State Management
- What is Zustand and how does it differ from Redux?State Management
- What is React Query and why is it not a traditional state manager?State Management
- What is Jotai and what is the atomic state model?State Management
- What is the Context + useReducer pattern and when should you use it over a library?State Management
related posts
view all- React Commit Phase Deep Dive: Sub-phases, Effects Order, and Practical TrapsA deep dive into the React commit phase - what actually happens after reconciliation ends. Covers all three sub-phases, the exact execution order of useInsertionEffect, useLayoutEffect, and useEffect, cleanup sequencing, and the bugs that follow when you get it wrong.Mar 23, 2026 · 10 min
- React Compiler: Automatic Memoization and the End of useMemo Boilerplate (2026)React Compiler v1.0 hit production in October 2025 and changed how we think about performance. Here is what it actually does, when you still need useMemo and useCallback, and how to migrate an existing codebase.Mar 16, 2026 · 9 min
- Next.js App Router vs Pages Router: A Practical Guide (2026)A practical comparison of Next.js App Router and Pages Router - routing, data fetching, layouts, caching, and when to actually use each one.Mar 11, 2026 · 8 min