Skip to main content

The State of React State Management in 2026

·PkgPulse Team

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.