React State Management 2026 — Zustand, Jotai, TanStack Query, and Context

Sanjeev SharmaSanjeev Sharma
5 min read

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 TypeBest ToolExamples
Server stateTanStack QueryAPI data, lists, user profiles
Global client stateZustandAuth user, UI preferences, cart
Atomic/derived stateJotaiForm fields, filters, computed values
Local component stateuseStateForm inputs, toggle, modal open
Infrequent shared stateReact ContextTheme, 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 useEffect to fetch data and store it in useState — 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 staleTime in 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 useState for 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/middleware persist for 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
  • staleTime in TanStack Query controls how long cached data is considered fresh before background refetch
  • Zustand persist middleware saves store slices to localStorage or sessionStorage automatically
  • 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading