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

Why React State Management Got Simpler After Getting Complicated

To understand where we are in 2026, it helps to understand how we got here — because the path was genuinely confusing for several years.

In 2019, the React team released the Context API with hooks, and the community briefly convinced itself that useContext + useReducer could replace Redux entirely. This was technically true for simple cases but led to a wave of performance problems in real applications. Every time context value changed, every consumer re-rendered. Teams discovered this the hard way: a global auth context that also held UI state would cause full-page re-renders on every keypress. The "Context can replace Redux" narrative quietly died, but not before thousands of apps were built on that assumption.

Redux fatigue peaked between 2020 and 2022. Redux itself was never the problem — the problem was the ceremony. Creating a feature meant writing action types, action creators, reducers, selectors, and connecting everything with connect() or useSelector. Even with Redux Toolkit significantly reducing this boilerplate, teams found themselves maintaining hundreds of lines of plumbing for features that needed twenty lines of actual logic. The cognitive load was real. Junior developers struggled to trace data flow. Senior developers spent disproportionate time on state architecture rather than product features.

The "too many options" moment arrived around 2022. Recoil, Jotai, Zustand, Valtio, XState, MobX, Redux Toolkit, React Query, SWR, and plain useState were all legitimate choices with active communities. Choosing a state management library felt like a major architectural decision with long-term consequences. Blog posts with titles like "Which React state library should you use?" proliferated, each reaching different conclusions. The analysis paralysis was genuine.

What clarified everything was a mental model, not a library: the distinction between server state and client state. Tanner Linsley, the author of TanStack Query, articulated this most clearly — server state has fundamentally different characteristics than client state. It lives remotely, it can become stale, it needs to be synchronized with a backend, it requires caching and deduplication. Client state is synchronous, you own it entirely, and it does not go stale. Once you internalize this distinction, the choice becomes obvious: use a server state library (TanStack Query or SWR) for data from APIs, and use a client state library (Zustand, Jotai, or even useState) for everything else.

React Server Components changed the calculus again, and we are still working through the implications. With RSC in Next.js 15, components that previously needed to fetch data client-side — triggering loading states, requiring TanStack Query setup — can now fetch that data on the server and stream it directly to the client. This does not eliminate TanStack Query, but it does shrink the surface area where you need it. The pattern that is emerging in 2026: use RSC for initial data loads, use TanStack Query for data that needs client-side caching, invalidation, or real-time updates.

What does "state management" even mean in 2026 with RSC? The question is genuinely more nuanced. Much of what was traditionally "state" — the list of users, the current user's profile, the app's configuration — is now server state that lives in the RSC tree and is re-fetched on navigation. The remaining client state is smaller and more focused: UI state like open modals, user preferences stored in localStorage, form state before submission, and optimistic updates during mutations. The libraries that thrive in this environment are the ones that handle these specific concerns well without adding unnecessary complexity.


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>
      )}
    </>
  );
}

TanStack Query in Depth: Caching Strategy

Understanding TanStack Query's caching model is what separates teams that use it effectively from teams that fight it. The two most commonly confused settings are staleTime and gcTime, and getting these wrong leads to either too many network requests or stale data that never refreshes.

staleTime controls how long a cached result is considered "fresh." During the stale time window, TanStack Query serves data from cache immediately without making a network request. After the stale time expires, the data is considered "stale" — TanStack Query still serves the cached value immediately (no loading state), but it fires a background refetch to update the cache. The default staleTime is 0, meaning data is immediately stale after fetching. This is a conservative default that ensures freshness but results in a refetch on every component mount. For data that does not change frequently — like a package's metadata or a user's settings — setting staleTime: 5 * 60 * 1000 (five minutes) dramatically reduces unnecessary requests.

gcTime (previously cacheTime in v4) controls how long inactive cache entries are kept in memory. An entry becomes inactive when no component is subscribed to it. After the gcTime expires, the entry is garbage collected. The default is five minutes. Setting gcTime longer than staleTime is always the right choice — if gcTime were shorter, cached data could be removed before it ever served a background refresh.

The queryKey is the most underappreciated feature of TanStack Query. Think of it like a dependency array in useEffect — every variable that the query function depends on should be in the queryKey. This is what enables automatic refetching when parameters change:

// When packageName changes, TanStack Query fetches new data automatically
const { data } = useQuery({
  queryKey: ['package', packageName, { version: selectedVersion }],
  queryFn: () => fetchPackage(packageName, selectedVersion),
});

A common mistake is using objects inside queryKey without understanding how TanStack Query compares them. TanStack Query uses deep equality for queryKey comparisons, so ['package', { name: 'react' }] and ['package', { name: 'react' }] are the same key even if they are different object references. This is intentional and correct — but it means the key order matters. ['package', { name: 'react' }] and [{ name: 'react' }, 'package'] are different keys.

Cache invalidation patterns in TanStack Query v5 are more explicit than v4. invalidateQueries marks matching queries as stale and triggers background refetches. removeQueries deletes cache entries entirely. resetQueries returns queries to their initial state (useful for logout). The exact option controls whether invalidation matches only exact keys or all keys with the given prefix:

// Invalidate all package-related queries (prefix match)
queryClient.invalidateQueries({ queryKey: ['package'] });

// Invalidate only this specific package
queryClient.invalidateQueries({ queryKey: ['package', 'react'], exact: true });

TanStack Query v5 introduced a breaking change worth knowing: the mutation's isLoading state was renamed to isPending. If you are upgrading from v4, this is the most common source of type errors. The v5 API also makes useQuery options more consistent — onSuccess, onError, and onSettled callbacks were removed from useQuery (they remain on useMutation). Use the useEffect pattern or subscribe to query state changes instead.

Prefetching is TanStack Query's most effective performance technique. You can prefetch data on hover or before navigation, so by the time the user arrives at a page, the cache is already warm:

// Prefetch on hover — data is ready before click
function PackageLink({ name }: { name: string }) {
  const queryClient = useQueryClient();
  return (
    <a
      href={`/packages/${name}`}
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: ['package', name],
          queryFn: () => fetchPackage(name),
          staleTime: 5 * 60 * 1000,
        });
      }}
    >
      {name}
    </a>
  );
}

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.


Zustand vs Jotai: When to Use Which

Both Zustand and Jotai are excellent lightweight state libraries, and the choice between them is less about capability and more about mental model. Understanding which model fits your application's structure will save you from switching libraries mid-project.

Zustand's model is a single store — one object that holds all your global state, with functions co-located inside it that describe how the state changes. This feels familiar if you have used Redux or MobX: there is a clear place to find all your state, a clear place to add new state, and a clear place to look when debugging. Zustand's store is a module-level singleton, which means no Provider is needed. Any component anywhere in the tree can subscribe to any part of the store. For apps with relatively flat state — a dashboard with settings, filters, and UI state — Zustand's model is immediately productive and stays maintainable as the app grows.

Jotai's model is a collection of atoms — small, independent pieces of state that compose through derivation rather than living in a shared object. There is no store. Each atom is a standalone unit. The power of Jotai comes from derived atoms: you can define a filteredResultsAtom that derives from a rawResultsAtom and a filterQueryAtom, and every component that reads filteredResultsAtom automatically re-renders when either dependency changes. Jotai handles async derived state natively through Suspense, which is a significant ergonomic advantage for data that requires computation.

Where Zustand's model becomes unwieldy is when you have deeply hierarchical or highly interdependent state. A large store with fifty fields and thirty action functions becomes difficult to navigate, and selectors that depend on multiple parts of the store can get complex. Some teams split into multiple Zustand stores, which helps with organization but means you need to manually handle any cross-store dependencies.

Where Jotai's atom proliferation becomes confusing is in large teams or apps with many features. Atoms are defined anywhere — a file that imports atom from jotai creates a global atom. Without discipline, you end up with dozens of atoms spread across the codebase with unclear ownership and dependency graphs that are hard to trace. The lack of a central store means debugging requires Jotai DevTools or careful logging.

The practical decision signals: choose Zustand if your team values a single place to look for all global state, if you have experience with Redux-style thinking, or if your state is primarily flat with straightforward update logic. Choose Jotai if your app has significant amounts of derived state, if you want first-class Suspense integration for async state, or if your state naturally decomposes into independent atoms that happen to sometimes depend on each other. For a detailed comparison, see Zustand vs Jotai on PkgPulse.


React Server Components and State Management

React Server Components represent the most significant architectural shift in React since hooks, and they directly reduce how much client-side state management you need. Understanding this shift is essential for making good architecture decisions in 2026.

In a traditional React SPA or Next.js pages-router app, the component tree is entirely client-side. Data fetching happens in useEffect or TanStack Query, which means every piece of data the UI needs must be fetched client-side, stored in state, and managed through loading and error states. The entire state management apparatus exists partly because components need somewhere to put data while they wait for network requests.

With RSC in Next.js 15, components that are marked as Server Components (the default) run on the server. They can fetch data directly — no useEffect, no TanStack Query, no loading state. The data arrives with the HTML. A page that shows a user's profile, their recent posts, and their subscription status can fetch all three from the database in a single server render, with no client-side state involved. The "diminishing state" pattern describes this accurately: as more components become server components, the client state surface area shrinks to what genuinely needs to be on the client.

What moves to the server with RSC: initial data loads, data that does not change after the page renders, data that is sensitive and should not be exposed to the client, and data that benefits from server-side caching. What stays on the client: interactive UI state (open/closed modals, selected items), user input and form state before submission, data that needs real-time updates or polling, and data that changes based on user interaction without a full navigation.

The emerging pattern for TanStack Query with RSC is prefetching on the server and hydrating the client cache. Next.js 15 supports this through TanStack Query's HydrationBoundary:

// app/packages/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

export default async function PackagesPage() {
  const queryClient = new QueryClient();

  // Prefetch on the server — cache is warm when client hydrates
  await queryClient.prefetchQuery({
    queryKey: ['packages'],
    queryFn: fetchPackages,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PackageList />  {/* Client component, cache already populated */}
    </HydrationBoundary>
  );
}

This pattern gives you the best of both worlds: fast initial renders from server-side data, plus TanStack Query's client-side caching, invalidation, and background refetch for subsequent interactions. Teams building RSC-heavy Next.js 15 apps are converging on this as the standard approach for any data that needs both SSR performance and client-side freshness.

The practical implication: in a full RSC architecture, you likely need less TanStack Query than you think for initial loads, and you may need even less Zustand since component-level useState handles most interactive state without needing a global store. Reserve TanStack Query for data that changes in response to user actions, requires background polling, or needs optimistic updates. Reserve Zustand for state that genuinely needs to be shared across distant parts of the component tree. See the fastest-growing npm packages in 2026 for TanStack Query and Zustand's growth trajectory.


Migrating Away from Redux: A Real Migration Guide

The migration from Redux to Zustand + TanStack Query is one of the most common refactoring projects in 2026. Teams are doing it not because Redux is broken, but because the maintenance burden and onboarding cost of large Redux codebases are significant compared to the leaner alternatives. Here is how teams are actually doing this migration without rewriting their entire application.

The first step is an honest assessment of what is currently in your Redux store. In most legacy Redux apps, the store contains a mix of server state (data fetched from APIs, stored in Redux slices), client state (UI flags, active modals, user preferences), and form state (sometimes). The migration strategy differs for each category.

Start with server state because it has the clearest target: move any Redux slice that is primarily responsible for fetching, storing, and refreshing API data to TanStack Query. This is the highest-value change. Typical patterns to look for: slices with loading, error, and data fields, slices that use createAsyncThunk to fetch data, and any slice that gets invalidated when a mutation succeeds. Replace these with useQuery and useMutation hooks. The Redux slice disappears; the fetch logic moves into query functions; the loading and error states are provided by TanStack Query.

Second, identify pure client state — things like sidebarOpen, activeTheme, selectedFilters, currentStep in a wizard. Extract these into one or more Zustand stores. The migration is usually straightforward: the initial state maps to Zustand's initial values, the Redux reducers map to Zustand actions, and useSelector calls become Zustand selectors.

Do this incrementally. The key insight is that Redux and Zustand can coexist in the same application. You do not need to migrate everything at once. Add TanStack Query for new features first. As you touch existing features for unrelated reasons, migrate their Redux slices. Set a goal to have no Redux slices responsible for server state within six months, then evaluate whether the remaining client state Redux slices are worth migrating or leaving in place.

For the actual Redux-to-Zustand conversion, tools like the Redux DevTools remain useful during the transition — you can see both Redux state and Zustand state (via Zustand's devtools middleware) simultaneously. For teams managing large Redux codebases, the Redux vs Zustand comparison covers the architectural trade-offs in detail.


Comparison: When to Use What

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

The most important trade-off not captured in this table is team familiarity and migration cost. Redux Toolkit's ~4M weekly downloads are largely driven by existing projects that have no compelling reason to migrate — the cost of switching outweighs the benefit. For greenfield projects in 2026, Redux Toolkit is rarely the first choice, but it remains deeply entrenched in enterprise codebases.

TanStack Query's dominance in the server state category is essentially settled. The only real alternative is SWR (~2M weekly downloads), which has a simpler API but lacks TanStack Query's more advanced features like infinite queries, optimistic updates with rollback, and the v5 deduplication improvements. Teams starting new projects in 2026 should default to TanStack Query unless they have a specific reason to prefer SWR's simpler surface area.

The Zustand vs Jotai question is the only genuinely open choice in 2026. Both are actively maintained, both have excellent TypeScript support, and both handle their respective use cases well. Zustand has a 2:1 download lead over Jotai, which reflects its broader applicability rather than inherent superiority. If you are unsure, start with Zustand — its flat mental model is easier to onboard new developers, and you can always add Jotai later for specific atomic state patterns. See the new wave of TypeScript-first libraries in 2026 for context on how both libraries fit the broader TypeScript ecosystem.


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.

See also: Jotai vs Zustand and Redux vs Zustand, Zustand vs Redux Toolkit in 2026: Full Decision Guide.

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.