Library

React State Management in 2024: Zustand, Jotai, TanStack Query & Context

A
Admin
March 9, 2026 • 6 min read • 1,111 words
8
Overall Scoreout of 10

Rating

8/10

Verdict

Use TanStack Query for all server state, Zustand for complex client state, and React Context for simple low-frequency globals (theme, locale). Avoid Redux unless you have very specific requirements.

Pros

  • TanStack Query eliminates boilerplate for server state
  • Zustand is simpler than Redux with no boilerplate
  • Jotai atom model scales from simple to complex
  • Context API is perfect for low-frequency global state

Cons

  • No single solution fits all use cases
  • Over-engineering state is a common trap
  • Learning multiple libraries adds cognitive overhead

React State Management in 2024: Zustand, Jotai, TanStack Query & Context

State management remains one of the most debated topics in React development. The ecosystem has matured dramatically — the monolithic Redux approach has given way to specialized, purpose-built libraries. The key insight of modern React state management is the server/client state distinction: these are fundamentally different problems that require different solutions.

Categorizing Your State

Before choosing a library, classify your state:

  • Server State — Data that lives on the server and is fetched asynchronously (posts, users, products). Has loading/error/stale states. Use TanStack Query.
  • Global Client State — UI state shared across many components (shopping cart, notifications, auth user). Use Zustand or Jotai.
  • Local Component State — State that belongs to one component (form values, toggle states). Use useState / useReducer.
  • URL State — State that should be shareable/bookmarkable (filters, pagination, search query). Use URL search params.

TanStack Query (React Query)

TanStack Query is the gold standard for server state management. It handles caching, deduplication, background refetching, pagination, and optimistic updates:

// 1. Setup QueryClient import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // Data fresh for 1 minute gcTime: 10 * 60 * 1000, // Cache for 10 minutes retry: 2, refetchOnWindowFocus: false, }, }, }); export default function App() { return ( ); }

// 2. Define typed query functions import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; interface Post { id: string; title: string; content: string; } const postKeys = { all: ['posts'] as const, list: (filters?: Record) => [...postKeys.all, 'list', filters] as const, detail: (id: string) => [...postKeys.all, 'detail', id] as const, }; async function fetchPosts(page = 1): Promise<{ posts: Post[]; total: number }> { const res = await fetch(`/api/posts?page=${page}`); if (!res.ok) throw new Error('Failed to fetch posts'); return res.json(); } // 3. Use in components export function PostList() { const [page, setPage] = useState(1); const { data, isLoading, isError, isFetching } = useQuery({ queryKey: postKeys.list({ page }), queryFn: () => fetchPosts(page), placeholderData: (prev) => prev, // Keep previous page data while fetching next }); if (isLoading) return ; if (isError) return ; return (

{isFetching && } {data?.posts.map(post =>

)}

); }

// 4. Mutations with optimistic updates export function useCreatePost() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: Omit

) => fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }).then(r => r.json()), onMutate: async (newPost) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: postKeys.all }); // Optimistically add the post const optimisticPost = { ...newPost, id: 'temp-' + Date.now() }; queryClient.setQueryData(postKeys.list(), (old: any) => ({ ...old, posts: [optimisticPost, ...(old?.posts ?? [])], })); return { optimisticPost }; }, onError: (err, _, context) => { // Roll back on error queryClient.setQueryData(postKeys.list(), (old: any) => ({ ...old, posts: old?.posts?.filter((p: Post) => p.id !== context?.optimisticPost.id), })); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: postKeys.all }); }, }); }

Zustand for Client State

Zustand is a minimal state management library with a simple API — no providers, no reducers, just stores:

import { create } from 'zustand'; import { persist, devtools } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; interface CartItem { id: string; name: string; price: number; quantity: number; } interface CartStore { items: CartItem[]; addItem: (item: Omit) => void; removeItem: (id: string) => void; updateQuantity: (id: string, quantity: number) => void; clearCart: () => void; total: () => number; } export const useCartStore = create()( devtools( persist( immer((set, get) => ({ items: [], addItem: (item) => set((state) => { const existing = state.items.find((i) => i.id === item.id); if (existing) { existing.quantity += 1; } else { state.items.push({ ...item, quantity: 1 }); } }), removeItem: (id) => set((state) => { state.items = state.items.filter((i) => i.id !== id); }), updateQuantity: (id, quantity) => set((state) => { const item = state.items.find((i) => i.id === id); if (item) item.quantity = Math.max(0, quantity); }), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0), })), { name: 'cart-storage' } ) ) ); // Usage — no Provider needed! export function CartButton() { const { items, total } = useCartStore(); return ( ); }

Jotai for Atomic State

Jotai uses an atomic state model — small pieces of state that compose together:

import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; // Primitive atoms const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light'); const searchQueryAtom = atom(''); const selectedTagsAtom = atom([]); // Derived atoms (computed from other atoms) const filteredPostsAtom = atom((get) => { const query = get(searchQueryAtom).toLowerCase(); const tags = get(selectedTagsAtom); return allPosts.filter(post => (query === '' || post.title.toLowerCase().includes(query)) && (tags.length === 0 || tags.some(t => post.tags.includes(t))) ); }); // Write-only atoms (actions) const toggleTagAtom = atom(null, (get, set, tag: string) => { const current = get(selectedTagsAtom); set(selectedTagsAtom, current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag] ); }); function SearchFilters() { const [query, setQuery] = useAtom(searchQueryAtom); const selectedTags = useAtomValue(selectedTagsAtom); const toggleTag = useSetAtom(toggleTagAtom); const filtered = useAtomValue(filteredPostsAtom); return (

setQuery(e.target.value)} /> {ALL_TAGS.map(tag => ( ))}

{filtered.length} results

); }

React Context: When to Use It

Context is ideal for low-frequency global values that rarely change (theme, locale, auth user). Avoid it for high-frequency state — every context change re-renders all consumers:

// Good context usage: auth user (changes once on login/logout) interface AuthContextType { user: User | null; login: (credentials: LoginCredentials) => Promise; logout: () => void; } const AuthContext = createContext(null); export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used inside AuthProvider'); return ctx; } export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const login = async (credentials: LoginCredentials) => { const user = await authService.login(credentials); setUser(user); }; const logout = () => { authService.logout(); setUser(null); }; return ( {children} ); }

Conclusion

Modern React state management isn't about picking one library — it's about using the right tool for each category of state. TanStack Query for server state eliminates an enormous amount of boilerplate. Zustand for client state is simpler than anything that came before it. Jotai's atoms shine for highly granular, reactive state. And Context remains the right choice for simple global values. Combine them intelligently and your React applications will be both performant and maintainable.