React State Management 2026: Zustand, Jotai, TanStack Query, and Context
Advertisement
React State Management 2026: Stop Overthinking It
State management was a hot debate for years. In 2026, there's clarity: TanStack Query for server state, Zustand for client state, Jotai for atomic state. Redux is legacy.
- The Mental Model
- TanStack Query: The Right Way to Fetch
- Zustand: Simple Global State
- Jotai: Atomic State (Fine-Grained Reactivity)
- When to Use Context
- Summary: State Management Decision Tree
The Mental Model
Server State (API data) → TanStack Query
Global Client State → Zustand
Atomic/Fine-grained State → Jotai
Simple Component State → useState
Shared between siblings → Context or Zustand
Form State → React Hook Form
URL State → nuqs / useSearchParams
The biggest mistake: using client state management for server data. Use TanStack Query.
TanStack Query: The Right Way to Fetch
npm install @tanstack/react-query @tanstack/react-query-devtools
// 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: 1000 * 60, // 1 minute fresh
gcTime: 1000 * 60 * 10, // 10 minutes in cache
retry: 1,
refetchOnWindowFocus: false,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
)
}
// hooks/use-posts.ts
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
// Fetch with automatic caching and refetching
export function usePosts(filters?: { tag?: string; page?: number }) {
return useQuery({
queryKey: ['posts', filters], // Unique key — changes trigger refetch
queryFn: () => fetchPosts(filters),
placeholderData: (prev) => prev, // Keep old data while loading new
})
}
// Mutations with optimistic updates
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createPost,
onMutate: async (newPost) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot the previous value
const previousPosts = queryClient.getQueryData(['posts'])
// Optimistically update
queryClient.setQueryData(['posts'], (old: Post[]) => [newPost, ...old])
return { previousPosts }
},
onError: (err, newPost, context) => {
// Rollback on error
queryClient.setQueryData(['posts'], context?.previousPosts)
},
onSettled: () => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}
// Infinite scroll
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) => fetchPosts({ page: pageParam }),
getNextPageParam: (lastPage, pages) =>
lastPage.hasMore ? pages.length + 1 : undefined,
initialPageParam: 1,
})
}
Zustand: Simple Global State
npm install zustand
// stores/use-app-store.ts
import { create } from 'zustand'
import { persist, devtools, immer } from 'zustand/middleware'
interface AppState {
// State
theme: 'light' | 'dark'
sidebarOpen: boolean
notifications: Notification[]
// Actions
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
addNotification: (notification: Omit<Notification, 'id'>) => void
removeNotification: (id: string) => void
}
export const useAppStore = create<AppState>()(
devtools(
persist(
immer((set) => ({
theme: 'light',
sidebarOpen: true,
notifications: [],
setTheme: (theme) => set((state) => { state.theme = theme }),
toggleSidebar: () => set((state) => { state.sidebarOpen = !state.sidebarOpen }),
addNotification: (notification) =>
set((state) => {
state.notifications.push({
...notification,
id: crypto.randomUUID(),
})
}),
removeNotification: (id) =>
set((state) => {
state.notifications = state.notifications.filter(n => n.id !== id)
}),
})),
{
name: 'app-storage',
partialize: (state) => ({ theme: state.theme }), // Only persist theme
}
)
)
)
// Usage in components
function ThemeToggle() {
const { theme, setTheme } = useAppStore()
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
)
}
// Use selectors to prevent unnecessary re-renders
function NotificationBadge() {
const count = useAppStore((state) => state.notifications.length) // Only re-renders on count change
return <span>{count}</span>
}
Jotai: Atomic State (Fine-Grained Reactivity)
npm install jotai
// atoms/user-atoms.ts
import { atom, atomWithStorage, loadable } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
// Basic atom
export const searchQueryAtom = atom('')
export const selectedTagsAtom = atom<string[]>([])
// Derived atom (computed from other atoms)
export const filteredPostsAtom = atom(async (get) => {
const query = get(searchQueryAtom)
const tags = get(selectedTagsAtom)
return fetchPosts({ query, tags })
})
// Persist to localStorage
export const themeAtom = atomWithStorage('theme', 'light')
// Atom with async data
export const userAtom = atomWithQuery((get) => ({
queryKey: ['user', get(selectedUserIdAtom)],
queryFn: async ({ queryKey: [, id] }) => fetchUser(id),
}))
// Usage
function SearchBar() {
const [query, setQuery] = useAtom(searchQueryAtom)
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search posts..."
/>
)
}
function PostList() {
const postsLoadable = useAtomValue(loadable(filteredPostsAtom))
if (postsLoadable.state === 'loading') return <Spinner />
if (postsLoadable.state === 'hasError') return <Error />
return postsLoadable.data.map(post => <PostCard key={post.id} post={post} />)
}
When to Use Context
// Context is great for: stable values that don't change often
// BAD for: frequently updated values (causes re-renders across the tree)
// GOOD: Auth context (changes rarely)
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = use(AuthContext) // React 19 use() hook
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
// BAD: Counter context (changes on every click)
// This re-renders EVERY consumer on every increment
// Use Zustand instead
Summary: State Management Decision Tree
Is it data from an API/server?
→ Yes: TanStack Query
Is it shared across many components?
→ Yes: Zustand (or Jotai for fine-grained)
→ No: useState / useReducer
Does it need to persist across page refresh?
→ Zustand with persist middleware
Is it URL state (filters, pagination)?
→ nuqs or useSearchParams
Is it form data?
→ React Hook Form
The key insight: most "state management" problems are actually "server state synchronization" problems. TanStack Query solves those. Zustand handles the rest.
Advertisement