Skip to content
February 26, 202614 min readfrontend

State Management in 2026: Zustand vs Signals vs Server State

The state management landscape has fragmented again. Zustand dominates, signals are the new hotness, and server state (TanStack Query, SWR) handles most of what Redux used to do. Here's what to use when... with the decision framework I give to advisory clients.

state-managementreactzustandsignalsfrontend
State Management in 2026: Zustand vs Signals vs Server State

TL;DR

State management in 2026 is simpler than the discourse suggests... if you decompose the problem correctly. Server state (data from your API) belongs in TanStack Query or SWR. Client state (UI flags, form inputs, modal visibility) belongs in React's built-in hooks for simple cases or Zustand for shared state. Global application state (auth, theme, feature flags) belongs in Zustand with persistence. Signals (Preact Signals, Solid-style reactivity) solve a real performance problem... fine-grained reactivity without re-renders... but React's adoption story is incomplete and the ecosystem support isn't there yet. For 90% of SaaS applications, the answer is: TanStack Query for server state + Zustand for everything else. That's 2 libraries, both under 10KB, covering every state management need.

Part of the Modern Frontend Architecture ... a comprehensive guide to frontend patterns that scale.


The State Taxonomy

The fundamental mistake teams make: treating all state the same. Server state and client state have completely different characteristics and need different management strategies.

TypeSourceCharacteristicsExample
Server stateAPI / databaseAsync, shared, cacheable, staleUser profile, dashboard metrics, order list
Client stateUser interactionSynchronous, local, transientForm inputs, modal open/close, selected tab
URL stateBrowser URLSynchronous, shareable, navigableFilters, pagination, search query
Global stateApp-wide configPersistent, shared across routesAuth session, theme, feature flags

Redux tried to be the answer for all four categories. That's why Redux applications become unwieldy... they're using a complex tool for simple problems and a simple tool for complex problems.


Server State: TanStack Query (or SWR)

Server state is the most common state in SaaS applications... and the most commonly mismanaged. Teams that put API data in Redux or Zustand end up reimplementing caching, invalidation, retry logic, and optimistic updates from scratch.

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // Read: fetch + cache + refetch function useMetrics(tenantId: string, dateRange: DateRange) { return useQuery({ queryKey: ['metrics', tenantId, dateRange], queryFn: () => fetchMetrics(tenantId, dateRange), staleTime: 60_000, // Consider data fresh for 60 seconds gcTime: 300_000, // Keep in cache for 5 minutes refetchOnWindowFocus: true, retry: 2, }); } // Write: mutate + invalidate related queries function useUpdateMetricGoal() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (params: { metricId: string; goal: number }) => updateMetricGoal(params.metricId, params.goal), onSuccess: (data, variables) => { // Invalidate all metric queries ... they'll refetch with new data queryClient.invalidateQueries({ queryKey: ['metrics'] }); }, onMutate: async (variables) => { // Optimistic update ... show the change immediately await queryClient.cancelQueries({ queryKey: ['metrics'] }); const previous = queryClient.getQueryData(['metrics']); queryClient.setQueryData(['metrics'], (old: MetricsData) => ({ ...old, goals: old.goals.map((g) => g.metricId === variables.metricId ? { ...g, goal: variables.goal } : g ), })); return { previous }; }, onError: (err, variables, context) => { // Rollback on error queryClient.setQueryData(['metrics'], context?.previous); }, }); } // Usage in component function Dashboard() { const { data, isLoading, error } = useMetrics('tenant-1', { days: 30 }); const updateGoal = useUpdateMetricGoal(); if (isLoading) return <Skeleton />; if (error) return <ErrorBoundary error={error} />; return <MetricsGrid data={data} onGoalUpdate={updateGoal.mutate} />; }

What TanStack Query gives you for free:

  • Automatic caching with configurable staleness
  • Background refetching when the user returns to the tab
  • Request deduplication (5 components using the same query = 1 network request)
  • Optimistic updates with automatic rollback on error
  • Retry with exponential backoff
  • Loading and error states without boilerplate

The alternative... managing all of this in Redux... is 200-400 lines of code per API endpoint. TanStack Query does it in 20.

Server State with Server Components

In Next.js App Router, Server Components fetch data on the server. TanStack Query's role shifts to client-side cache management and mutations:

// Server Component: initial data fetch async function DashboardPage() { const metrics = await fetchMetrics('tenant-1', { days: 30 }); return ( <HydrationBoundary state={dehydrate(queryClient)}> <DashboardClient initialMetrics={metrics} /> </HydrationBoundary> ); } // Client Component: manages client-side state and mutations 'use client'; function DashboardClient({ initialMetrics }: { initialMetrics: MetricsData }) { const { data } = useMetrics('tenant-1', { days: 30 }); // data starts with initialMetrics (from server), refetches in background }

This pattern gives you instant page loads (server-rendered data) with client-side interactivity (TanStack Query manages subsequent fetches and mutations).


Client State: Zustand

For state that lives entirely on the client... UI state, form state, selection state... Zustand provides the simplest API with the best performance characteristics.

Why Zustand Over Context + useReducer

React Context re-renders every consumer when any part of the context value changes. For a theme context, that's fine... it changes rarely. For a complex UI state with 10+ values, it causes unnecessary re-renders across the entire component tree.

Zustand uses external stores with selector-based subscriptions. Components only re-render when the specific slice of state they subscribe to changes.

import { create } from 'zustand'; // UI state store interface DashboardUIState { sidebarOpen: boolean; selectedMetricId: string | null; dateRange: DateRange; viewMode: 'grid' | 'list'; toggleSidebar: () => void; selectMetric: (id: string | null) => void; setDateRange: (range: DateRange) => void; setViewMode: (mode: 'grid' | 'list') => void; } const useDashboardUI = create<DashboardUIState>((set) => ({ sidebarOpen: true, selectedMetricId: null, dateRange: { days: 30 }, viewMode: 'grid', toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), selectMetric: (id) => set({ selectedMetricId: id }), setDateRange: (range) => set({ dateRange: range }), setViewMode: (mode) => set({ viewMode: mode }), })); // Component only re-renders when sidebarOpen changes function Sidebar() { const open = useDashboardUI((s) => s.sidebarOpen); const toggle = useDashboardUI((s) => s.toggleSidebar); if (!open) return null; return <aside>...</aside>; } // Component only re-renders when viewMode changes function ViewToggle() { const mode = useDashboardUI((s) => s.viewMode); const setMode = useDashboardUI((s) => s.setViewMode); return ( <div> <button onClick={() => setMode('grid')} aria-pressed={mode === 'grid'}>Grid</button> <button onClick={() => setMode('list')} aria-pressed={mode === 'list'}>List</button> </div> ); }

Zustand with Persistence

For state that should survive page refreshes (theme preference, sidebar state, last viewed dashboard):

import { create } from "zustand"; import { persist } from "zustand/middleware"; const usePreferences = create( persist<PreferencesState>( (set) => ({ theme: "dark", sidebarCollapsed: false, lastDashboard: null, setTheme: (theme) => set({ theme }), toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), setLastDashboard: (id) => set({ lastDashboard: id }), }), { name: "user-preferences", // Only persist specific fields (not functions) partialize: (state) => ({ theme: state.theme, sidebarCollapsed: state.sidebarCollapsed, lastDashboard: state.lastDashboard, }), } ) );

Signals: The Performance Promise

Signals provide fine-grained reactivity... when a signal value changes, only the specific DOM node that reads that signal updates. No component re-renders, no virtual DOM diffing.

The Performance Difference

In a dashboard with 500 data cells, updating one cell's value:

ApproachWhat UpdatesTime
React state (naive)Entire table re-renders50-200ms
React state (memo)Table re-renders, memoized cells skip10-50ms
Zustand (selector)Only subscribed components5-20ms
SignalsOnly the specific DOM text node< 1ms

For applications with hundreds of frequently updating values (trading dashboards, real-time monitoring, collaborative editing), signals provide a measurable performance advantage.

The React Signals Story (2026)

As of 2026, signals in React are available through:

  1. @preact/signals-react ... Works but uses internal React hooks that may break between versions
  2. React compiler (experimental) ... React's built-in compiler optimizes re-renders automatically, reducing (but not eliminating) the need for manual memoization
  3. TC39 Signals proposal ... Stage 1, years from standardization

The honest assessment: signals solve a real problem, but the React ecosystem isn't built around them yet. TanStack Query, form libraries, component libraries... they all assume React's rendering model. Adopting signals means potentially fighting the ecosystem.

When Signals Make Sense in React (Today)

  • Real-time data displays with 100+ updating values
  • Collaborative editing where cursor positions update 30+ times per second
  • Visualization layers (charts, maps) with frequent data updates
  • Performance-critical internal tools where you control the entire stack

When Signals Don't Make Sense

  • Standard CRUD applications (TanStack Query + Zustand is more than fast enough)
  • Applications using many third-party React components (they expect React state)
  • Teams that need the React ecosystem's testing, debugging, and DevTools support

The Decision Framework

What type of state? ├── API/Database data │ └── TanStack Query (or SWR) │ └── With Server Components? Use RSC for initial fetch, RQ for client-side ├── URL state (filters, pagination, search) │ └── useSearchParams + nuqs (type-safe URL state) ├── Form state │ └── Simple form? → useState / useActionState │ └── Complex form? → React Hook Form + Zod ├── Local UI state (one component) │ └── useState / useReducer ├── Shared UI state (multiple components) │ └── Zustand (with selectors) ├── Persistent state (survives refresh) │ └── Zustand + persist middleware └── Real-time / high-frequency updates └── Evaluate signals. Otherwise Zustand with shallow comparison.

The Stack I Recommend

For 90% of SaaS applications:

ConcernSolutionBundle Size
Server stateTanStack Query~13KB
Client stateZustand~1.2KB
URL statenuqs~2KB
Form stateReact Hook Form + Zod~9KB + ~3KB
Total~28KB

Compare this to Redux Toolkit + RTK Query + Redux Persist: ~40KB, with significantly more boilerplate.


When to Apply This

  • You're starting a new React/Next.js project and choosing a state management strategy
  • Your existing Redux application is overly complex for the state it manages
  • You're migrating to Server Components and need to rethink data fetching
  • Your team is spending more time on state management boilerplate than feature code

When NOT to Apply This

  • Your Redux application works well and the team is productive... don't migrate for the sake of it
  • You need time-travel debugging (Redux DevTools is still the best for this)
  • You're building an offline-first application (Redux Persist with Redux Offline is more mature)

Choosing a state management architecture for your SaaS? I help teams pick the right tools and avoid the complexity trap.


Continue Reading

This post is part of the Modern Frontend Architecture ... covering component design, performance, testing, and state management patterns.

More in This Series

Get insights like this weekly

Join The Architect's Brief — one actionable insight every Tuesday.

Need help with frontend architecture?

Let's talk strategy