React State Management 2026 — Zustand, Jotai, TanStack Query, and Context
Advertisement
Introduction
Why This Matters
Redux is no longer the default answer for React state management. In 2026, the ecosystem has converged on purpose-built solutions: TanStack Query for server state, Zustand for global client state, Jotai for atomic state, and Context for low-frequency shared values. Picking the right tool eliminates unnecessary complexity.
State Categories
Before choosing a library, identify what kind of state you have:
| State Type | Best Tool | Examples |
|---|---|---|
| Server state | TanStack Query | API data, lists, user profiles |
| Global client state | Zustand | Auth user, UI preferences, cart |
| Atomic/derived state | Jotai | Form fields, filters, computed values |
| Local component state | useState | Form inputs, toggle, modal open |
| Infrequent shared state | React Context | Theme, locale, feature flags |
TanStack Query for Server State
Never use useState + useEffect for data fetching:
// src/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
interface Post { id: string; title: string; published: boolean }
async function fetchPosts(): Promise<Post[]> {
const res = await fetch('/api/posts')
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60_000, // cache for 60 seconds
gcTime: 5 * 60_000, // garbage collect after 5 minutes
})
}
export function useCreatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data: { title: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
})
return res.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
}function PostList() {
const { data: posts, isLoading, error } = usePosts()
const { mutate: createPost, isPending } = useCreatePost()
if (isLoading) return <PostSkeleton />
if (error) return <ErrorMessage error={error} />
return (
<>
<ul>{posts!.map(p => <li key={p.id}>{p.title}</li>)}</ul>
<button onClick={() => createPost({ title: 'New Post' })} disabled={isPending}>
{isPending ? 'Creating…' : 'New Post'}
</button>
</>
)
}Zustand for Global Client State
Simple, boilerplate-free global state with TypeScript:
// src/store/authStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface User { id: string; name: string; email: string; role: string }
interface AuthState {
user: User | null
isAuthenticated: boolean
login: (user: User) => void
logout: () => void
updateUser: (updates: Partial<User>) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false }),
updateUser: (updates) =>
set((state) => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ user: state.user }),
}
)
)
// Cart store with slice pattern
interface CartState {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartState>()((set, get) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}))Jotai for Atomic State
Jotai is ideal for fine-grained, derived, or async atoms:
// src/atoms/filtersAtom.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export const searchAtom = atom('')
export const categoryAtom = atomWithStorage<string>('filter-category', 'all')
export const sortAtom = atom<'asc' | 'desc'>('desc')
// Derived atom — recomputes when dependencies change
export const filteredPostsAtom = atom((get) => {
const search = get(searchAtom).toLowerCase()
const category = get(categoryAtom)
const sort = get(sortAtom)
return allPosts
.filter(p => p.title.toLowerCase().includes(search))
.filter(p => category === 'all' || p.category === category)
.sort((a, b) => sort === 'asc'
? a.date.localeCompare(b.date)
: b.date.localeCompare(a.date)
)
})function FilterBar() {
const [search, setSearch] = useAtom(searchAtom)
const [category, setCategory] = useAtom(categoryAtom)
const filteredPosts = useAtomValue(filteredPostsAtom)
return (
<div className="flex gap-4">
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search…" />
<select value={category} onChange={e => setCategory(e.target.value)}>
<option value="all">All</option>
<option value="javascript">JavaScript</option>
<option value="react">React</option>
</select>
<span>{filteredPosts.length} results</span>
</div>
)
}Common Mistakes
- Using
useEffectto fetch data and store it inuseState— use TanStack Query instead - Putting server state (API responses) into Zustand — it cannot handle loading, error, and caching
- Using React Context for high-frequency updates like form fields — causes full subtree re-renders
- Forgetting
staleTimein TanStack Query — causes unnecessary refetches on every focus event - Creating new atom instances inside components — atoms must be defined at module level for Jotai
Best Practices
- Default to
useStatefor local state and TanStack Query for server state - Only reach for Zustand when state genuinely needs to be global and not server-sourced
- Use Jotai when you have many interdependent or derived pieces of client state
- Reserve Context for values that change rarely: theme, locale, feature flags
- Use
zustand/middlewarepersistfor state that should survive page reloads
Key Takeaways
- TanStack Query is the standard for server state — it handles loading, caching, refetching, and invalidation
- Zustand replaces Redux for global client state with 90% less boilerplate
- Jotai atoms are defined at module level — creating atoms inside components resets them on each render
- React Context causes every consumer to re-render on any value change — unsuitable for frequent updates
staleTimein TanStack Query controls how long cached data is considered fresh before background refetch- Zustand
persistmiddleware saves store slices tolocalStorageorsessionStorageautomatically - Atomic state (Jotai) enables efficient derived values — only components using a changed atom re-render
- The rule of thumb: useState → Context → Zustand → Jotai in order of increasing complexity need
Advertisement
Related reading
Next.js State Management with Zustand — Complete Guide for 20267 min readReact Context vs Zustand vs Redux — Choosing the Right State Manager6 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readTanStack Query (React Query) — Complete Guide to Server State Management5 min read