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

Sanjeev SharmaSanjeev Sharma
5 min read

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

Server State (API data)TanStack Query
Global Client StateZustand
Atomic/Fine-grained StateJotai
Simple Component State     → useState
Shared between siblings    → Context or Zustand
Form StateReact 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro