Skip to content
blog

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.

text
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.

text
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 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

tsx
// 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

tsx
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

tsx
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

tsx
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:

tsx
// ✅ 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):

tsx
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:

tsx
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.

tsx
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');
tsx
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.

tsx
// 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.

jsx
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:

text
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:

tsx
'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:

tsx
// ❌ 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.

tsx
// ✅ Each library handles its own domain const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); const preferences = usePreferencesStore((s) => s.preferences); // client-only

When 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

Mykola Nesterchuk

Frontend Developer · React, TypeScript, Next.js

Have a question, spotted a mistake or see it differently? I'd genuinely like to hear it.

discuss on X LinkedIn

interview questions on this

view all

related posts

view all