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.
| Type | Source | Characteristics | Example |
|---|---|---|---|
| Server state | API / database | Async, shared, cacheable, stale | User profile, dashboard metrics, order list |
| Client state | User interaction | Synchronous, local, transient | Form inputs, modal open/close, selected tab |
| URL state | Browser URL | Synchronous, shareable, navigable | Filters, pagination, search query |
| Global state | App-wide config | Persistent, shared across routes | Auth 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.
TanStack Query (Recommended)
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:
| Approach | What Updates | Time |
|---|---|---|
| React state (naive) | Entire table re-renders | 50-200ms |
| React state (memo) | Table re-renders, memoized cells skip | 10-50ms |
| Zustand (selector) | Only subscribed components | 5-20ms |
| Signals | Only 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:
- @preact/signals-react ... Works but uses internal React hooks that may break between versions
- React compiler (experimental) ... React's built-in compiler optimizes re-renders automatically, reducing (but not eliminating) the need for manual memoization
- 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:
| Concern | Solution | Bundle Size |
|---|---|---|
| Server state | TanStack Query | ~13KB |
| Client state | Zustand | ~1.2KB |
| URL state | nuqs | ~2KB |
| Form state | React 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.
- Technical Advisor for Startups ... Frontend architecture decisions
- Next.js Development for SaaS ... Production-grade React applications
- Technical Due Diligence ... Frontend architecture assessment
Continue Reading
This post is part of the Modern Frontend Architecture ... covering component design, performance, testing, and state management patterns.
More in This Series
- Testing Strategies for React Server Components ... Testing the RSC + client state model
- Optimistic UI ... Client-side state patterns for instant feedback
- Component API Design ... Props and state interfaces that scale
- TypeScript: The Business Case ... Type safety as a state management tool
Related Guides
- Core Web Vitals Audit Checklist ... Performance implications of state management choices
- RSC and Edge: The Death of the Waterfall ... Server Components and data fetching
