Skip to main content

The State of React State Management in 2026

·PkgPulse Team
0

TL;DR

Zustand for global state. TanStack Query for server state. Jotai for atomic/derived state. The biggest shift in React state management since 2023: server state and client state are now clearly separated disciplines. TanStack Query (~5M weekly downloads) owns server state — caching, refetching, loading states. Zustand (~4M) owns client state — UI state, settings, auth. Redux Toolkit (~4M) is still dominant in large enterprise apps but rarely chosen for new projects in 2026.

Key Takeaways

  • TanStack Query: ~5M weekly downloads — server state standard, cache, background refetch
  • Zustand: ~4M downloads — minimal global store, ~1KB, no boilerplate
  • Redux Toolkit: ~4M downloads — legacy dominant, still enterprise standard
  • Jotai: ~2M downloads — atomic model, derived state, no store concept
  • Server components — React Server Components shift more state to the server in 2026

The 2026 State Management Philosophy

Server State vs Client State

The mental model that clarified everything:

Server State                    Client State
─────────────────────────────────────────────────
• Data from APIs                • UI state (modals, drawers)
• User's posts, settings        • Form drafts (before submit)
• Package health scores         • Selected items
• Needs: caching, refetch,      • Auth session (once fetched)
  loading states, deduplication • Theme preferences
                                • Multi-step wizard state

→ Use TanStack Query            → Use Zustand / Jotai

This separation is why the "which state library?" question got simpler: you probably need both, for different things.


TanStack Query (Server State)

// TanStack Query — server state management
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
  useMutation,
  useInfiniteQuery,
} from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,      // 1 min: fresh → stale
      gcTime: 5 * 60 * 1000,    // 5 min: remove from cache
      retry: 3,                   // Retry failed requests
      refetchOnWindowFocus: true, // Auto-refetch when tab refocuses
    },
  },
});

// Query: fetch and cache
function PackageDetails({ name }: { name: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['package', name],    // Cache key
    queryFn: () => fetchPackage(name),
    staleTime: 5 * 60 * 1000,      // Override: 5min fresh
  });

  if (isLoading) return <Skeleton />;
  if (error) return <Error />;
  return <div>{data.name} v{data.version}</div>;
}

// Two components using same queryKey: ONE network request
function DownloadCount({ name }: { name: string }) {
  const { data } = useQuery({
    queryKey: ['package', name],   // Same key — uses cache!
    queryFn: () => fetchPackage(name),
  });
  return <span>{data?.downloads?.toLocaleString()}</span>;
}
// TanStack Query — mutations with cache invalidation
function AddToWatchlist({ packageName }: { packageName: string }) {
  const queryClient = useQueryClient();

  const addMutation = useMutation({
    mutationFn: (name: string) => api.addToWatchlist(name),
    onSuccess: () => {
      // Invalidate: next render will refetch
      queryClient.invalidateQueries({ queryKey: ['watchlist'] });
    },
    onMutate: async (name) => {
      // Optimistic update: add immediately before server responds
      await queryClient.cancelQueries({ queryKey: ['watchlist'] });
      const previous = queryClient.getQueryData(['watchlist']);
      queryClient.setQueryData(['watchlist'], (old: string[]) => [...old, name]);
      return { previous };
    },
    onError: (err, name, context) => {
      // Roll back on error
      queryClient.setQueryData(['watchlist'], context?.previous);
    },
  });

  return (
    <button onClick={() => addMutation.mutate(packageName)}>
      {addMutation.isPending ? 'Adding...' : 'Add to Watchlist'}
    </button>
  );
}
// TanStack Query — infinite scroll
function PackageList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['packages'],
    queryFn: ({ pageParam = 0 }) => fetchPackages({ offset: pageParam }),
    getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length * 20 : undefined,
  });

  return (
    <>
      {data?.pages.flatMap(page => page.packages).map(pkg => (
        <PackageCard key={pkg.name} pkg={pkg} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          Load More
        </button>
      )}
    </>
  );
}

Zustand (Global Client State)

// Zustand — global store, minimal API
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';

interface AppState {
  // UI state
  sidebarOpen: boolean;
  activeModal: string | null;
  theme: 'light' | 'dark' | 'system';

  // User state
  watchlist: string[];

  // Actions
  toggleSidebar: () => void;
  openModal: (modal: string) => void;
  closeModal: () => void;
  setTheme: (theme: 'light' | 'dark' | 'system') => void;
  addToWatchlist: (pkg: string) => void;
  removeFromWatchlist: (pkg: string) => void;
}

export const useAppStore = create<AppState>()(
  devtools(
    persist(
      (set) => ({
        sidebarOpen: false,
        activeModal: null,
        theme: 'system',
        watchlist: [],

        toggleSidebar: () => set(state => ({ sidebarOpen: !state.sidebarOpen })),
        openModal: (modal) => set({ activeModal: modal }),
        closeModal: () => set({ activeModal: null }),
        setTheme: (theme) => set({ theme }),
        addToWatchlist: (pkg) => set(state => ({
          watchlist: [...state.watchlist, pkg],
        })),
        removeFromWatchlist: (pkg) => set(state => ({
          watchlist: state.watchlist.filter(p => p !== pkg),
        })),
      }),
      {
        name: 'app-storage',           // localStorage key
        partialize: (state) => ({      // Only persist these
          theme: state.theme,
          watchlist: state.watchlist,
        }),
      }
    )
  )
);

// Usage — no Provider needed!
function Sidebar() {
  const isOpen = useAppStore(state => state.sidebarOpen);
  const toggle = useAppStore(state => state.toggleSidebar);
  return <nav className={isOpen ? 'open' : 'closed'} onClick={toggle} />;
}

// Selector prevents unnecessary re-renders
function WatchlistCount() {
  const count = useAppStore(state => state.watchlist.length);
  return <span>{count}</span>;  // Only re-renders when count changes
}

Jotai (Atomic State)

// Jotai — atoms composable like hooks
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Primitive atoms
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'dark');
const searchQueryAtom = atom('');
const selectedPackagesAtom = atom<string[]>([]);

// Derived atom (computed from others)
const searchResultsAtom = atom(async (get) => {
  const query = get(searchQueryAtom);
  if (!query) return [];
  return await searchPackages(query);  // Async derived!
});

// Write-only atom (action)
const addPackageAtom = atom(null, (get, set, name: string) => {
  const current = get(selectedPackagesAtom);
  set(selectedPackagesAtom, [...current, name]);
});

// Usage
function SearchBar() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function SearchResults() {
  const results = useAtomValue(searchResultsAtom); // Suspense-compatible!
  return <ul>{results.map(r => <li key={r.name}>{r.name}</li>)}</ul>;
}

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  return <button onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}>
    {theme}
  </button>;
}

Best for: Apps with many small, independent pieces of state that derive from each other.


Redux Toolkit (Enterprise)

// Redux Toolkit — still dominant in large apps
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';

// Async thunk for API calls
const fetchPackage = createAsyncThunk(
  'packages/fetch',
  async (name: string) => {
    const response = await api.getPackage(name);
    return response.data;
  }
);

const packagesSlice = createSlice({
  name: 'packages',
  initialState: {
    items: {} as Record<string, Package>,
    status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed',
  },
  reducers: {
    // Sync actions
    addToWatchlist(state, action) {
      state.watchlist.push(action.payload);
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchPackage.pending, (state) => { state.status = 'loading'; })
      .addCase(fetchPackage.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items[action.payload.name] = action.payload;
      });
  },
});

Best for: Enterprise apps where Redux devtools, time-travel debugging, and existing Redux codebase are valuable.


Comparison: When to Use What

LibraryForComplexityBundle
TanStack QueryServer/API stateLow~15KB
ZustandGlobal UI stateMinimal~1KB
JotaiAtomic / derived stateLow~3KB
Redux ToolkitLarge enterprise appsMedium-High~15KB
React ContextSimple / localized stateNone0
useState / useReducerLocal component stateNone0

The 2026 Pattern: Layered State

Layer 1: Server State        → TanStack Query
Layer 2: Global Client State → Zustand
Layer 3: Atomic/Derived      → Jotai (if needed)
Layer 4: Local Component     → useState / useReducer
Layer 5: Form State          → React Hook Form

Most apps in 2026 use TanStack Query + Zustand + React Hook Form and rarely need anything else.


Compare state management package health on PkgPulse.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.