Advanced State Management in 2026: Balancing Signals, Server State, and React Context
Signals, server-state caches, and Context each solve a different problem — and mixing them up is the root cause of most React performance regressions. This is a production playbook for assigning every piece of state to the correct plane, with copy-pasteable TypeScript implementations.
Photo by Nemuel Sereti on PexelsReact state management in 2026 is no longer a "which library" question. A typical production app now runs four state systems concurrently, and the failures engineers hit in code review are almost never about picking the wrong library — they are about assigning the wrong kind of state to the wrong plane.
This is a field guide to that assignment problem: where signals earn their complexity budget, why server state must be treated as a cache you do not own, and how React Context should be used as dependency injection rather than a global event bus.
The Four Planes of React State
Before touching a library, classify the state. Every piece of state in a React app falls into one of four planes, and each plane has a canonical owner.
| Plane | Characteristics | Canonical Owner |
|---|---|---|
| Server state | Async, shared, staleness-aware, needs dedupe/retry/GC | Query cache (TanStack Query / RSC) |
| Global client state | Synchronous, app-wide, low frequency | External store (Zustand, signals) via useSyncExternalStore |
| URL state | Shareable, deep-linkable, survives reload | Router search params |
| Local UI state | Ephemeral, component-scoped | useState / useReducer |
Two invariants follow from this table:
- One owner per datum. If a value lives in both a Query cache and a Context, you have two write paths and no source of truth.
- State lives at the lowest plane that satisfies every consumer. Promoting form drafts to a global store to avoid prop drilling is the single most common source of re-render storms.
Signals: Fine-Grained Reactivity Inside a Coarse-Grained Runtime
Signals are a push-pull reactive graph. A write to a signal marks subscribers dirty (push) and invalidates downstream computeds; the actual recomputation is deferred until a .value read (pull), which gives you glitch-free, topologically ordered derived values with no redundant work.
React's rendering model is the opposite: it re-renders an entire component subtree and reconciles. Bridging the two requires one of two mechanisms:
- A compiler transform (
@preact/signals-reactwith the Babel/SWC plugin) that rewrites.valuereads into per-signal subscription hooks and splits the component into render nodes. - A subscription bridge using
useSyncExternalStore, which is the only concurrency-safe way to consume external mutable sources in React 18+.
The TC39 Signals proposal is standardizing the underlying primitive, which means the semantics you learn today are portable across frameworks.
A Production-Grade Signals Store
// features/cart/cart-store.ts
import { signal, computed, batch } from '@preact/signals-react';
export interface CartLine {
readonly sku: string;
readonly unitPriceCents: number;
quantity: number;
}
export class CartStore {
private readonly _lines = signal<ReadonlyMap<string, CartLine>>(new Map());
private readonly _coupon = signal<string | null>(null);
// Pull-based derivations: recompute lazily, only when read after invalidation.
readonly lines = computed(() => [...this._lines.value.values()]);
readonly itemCount = computed(() =>
this.lines.value.reduce((n, l) => n + l.quantity, 0),
);
readonly subtotalCents = computed(() =>
this.lines.value.reduce((sum, l) => sum + l.unitPriceCents * l.quantity, 0),
);
addLine(line: Omit<CartLine, 'quantity'>, qty = 1): void {
if (!Number.isInteger(qty) || qty <= 0) {
throw new RangeError(`addLine: qty must be a positive integer, got ${qty}`);
}
const next = new Map(this._lines.value);
const existing = next.get(line.sku);
next.set(
line.sku,
existing
? { ...existing, quantity: existing.quantity + qty }
: { ...line, quantity: qty },
);
this._lines.value = next;
}
clear(): void {
// batch() collapses N writes into a single notification flush.
batch(() => {
this._lines.value = new Map();
this._coupon.value = null;
});
}
}Reading Signals Without Waking Siblings
// features/cart/CartBadge.tsx
import { useSyncExternalStore } from 'react';
import type { CartStore } from './cart-store';
export function CartBadge({ store }: { store: CartStore }) {
// Only re-renders when itemCount changes — not when the coupon changes.
const count = useSyncExternalStore(
(notify) => {
// signals-react exposes subscription via effect(); for a bridge you can
// also use the store's own pub/sub. Keep the callback stable.
const dispose = store.itemCount.subscribe?.(notify) ?? (() => {});
return dispose;
},
() => store.itemCount.value,
() => store.itemCount.value, // getServerSnapshot: required for SSR
);
return <span aria-live="polite">{count}</span>;
}With the compiler transform enabled you drop the bridge entirely and write store.itemCount.value inline — the transform routes the update to the smallest render node possible.
Where Signals Pay Off — and Where They Leak Complexity
Adopt signals when: you have high-frequency writes (drag handles, canvas editors, live dashboards, collaborative cursors, virtualized tables with per-cell updates) and the write volume makes React's subtree reconciliation the bottleneck.
Do not adopt signals when: your app is CRUD with forms and lists. A Query cache plus useReducer is smaller, simpler, and easier to test. The hidden costs of a second reactivity runtime are real: no React DevTools time-travel without a custom adapter, effect disposal discipline in tests, and per-request store instantiation under SSR (a module-level signal() is a cross-request data leak).
Server State: A Cache You Do Not Own
Server state is not global state. It is a replica of remote truth with its own lifecycle: staleness windows, background revalidation, deduplication, retry with backoff, and garbage collection. Hand-rolling that with useEffect + useState reproduces a known-bad cache with none of the safeguards.
Declarative Queries With Cancellation
// features/todos/queries.ts
import { queryOptions } from '@tanstack/react-query';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly requestId?: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
headers: { accept: 'application/json', ...init.headers },
});
if (!res.ok) {
throw new ApiError(
await res.text(),
res.status,
res.headers.get('x-request-id') ?? undefined,
);
}
return (await res.json()) as T;
}
export type TodoFilter = 'all' | 'open' | 'done';
export const todoKeys = {
all: ['todos'] as const,
list: (filter: TodoFilter) => [...todoKeys.all, 'list', filter] as const,
detail: (id: string) => [...todoKeys.all, 'detail', id] as const,
};
export const todoListQuery = (filter: TodoFilter) =>
queryOptions({
queryKey: todoKeys.list(filter),
// The context signal is an AbortSignal — wire it into fetch for real cancellation.
queryFn: ({ signal }) =>
apiFetch<Todo[]>(`/api/todos?filter=${filter}`, { signal }),
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (attempt, error) =>
attempt < 2 && !(error instanceof ApiError && error.status < 500),
});The signal in the query context comes from the cache, not from your component. When a key becomes unused, the in-flight request aborts — which is what prevents the classic "setState on unmounted component" and out-of-order response bugs.
Optimistic Mutations With Deterministic Rollback
// features/todos/use-update-todo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
export interface TodoPatch {
id: string;
title?: string;
done?: boolean;
}
export function useUpdateTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: TodoPatch) =>
apiFetch<Todo>(`/api/todos/${patch.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
}),
onMutate: async (patch) => {
// 1. Cancel in-flight reads so they cannot clobber the optimistic write.
await qc.cancelQueries({ queryKey: todoKeys.detail(patch.id) });
// 2. Snapshot for rollback.
const previous = qc.getQueryData<Todo>(todoKeys.detail(patch.id));
// 3. Apply optimistically.
qc.setQueryData<Todo>(todoKeys.detail(patch.id), (old) =>
old ? { ...old, ...patch } : old,
);
return { previous };
},
onError: (_err, patch, ctx) => {
if (ctx?.previous) {
qc.setQueryData(todoKeys.detail(patch.id), ctx.previous);
}
},
onSettled: () => {
// Reconcile with server truth regardless of outcome.
void qc.invalidateQueries({ queryKey: todoKeys.all });
},
});
}RSC, Streaming, and the Hydration Boundary
With React Server Components, the server can own the initial fetch and stream HTML plus a serialized cache snapshot. The client cache then hydrates without a network round-trip, and mutations invalidate normally.
// app/todos/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { todoListQuery } from '@/features/todos/queries';
export default async function TodosPage() {
const qc = new QueryClient();
// Fire-and-forget prefetch: streamed to the client via HydrationBoundary.
void qc.prefetchQuery(todoListQuery('open'));
return (
<HydrationBoundary state={dehydrate(qc)}>
<TodoList filter="open" />
</HydrationBoundary>
);
}The rule: RSC replaces the read path, not the cache. You still need client-side invalidation, optimistic updates, and error boundaries for mutations.
React Context: Dependency Injection, Not a State Bus
Context has one job: propagating a value down a tree without prop drilling. It has no selector mechanism, so every consumer re-renders when the provider value changes identity — even if the slice it reads is unchanged.
Rule 1: Split Contexts by Change Frequency
Keep a stable, low-frequency value (current user, theme, feature flags) separate from a volatile one (notifications, presence, cursor positions). A single "AppContext" containing everything guarantees a full-tree re-render on every notification.
Rule 2: Memoize the Provider Value — Then Keep Going
// ❌ New object identity on every render → every consumer re-renders.
<AuthContext.Provider value={{ user, signOut }}>
// ✅ Stable identity, so consumers only re-render when user or signOut change.
const authValue = useMemo(() => ({ user, signOut }), [user, signOut]);
<AuthContext.Provider value={authValue}>Rule 3: Selectors for High-Frequency Values
The robust pattern is a Context that carries an external store reference, plus a useSyncExternalStore selector hook. The provider value never changes identity, so consumers subscribe surgically.
// shared/create-store-context.tsx
import {
createContext,
useCallback,
useContext,
useRef,
useSyncExternalStore,
type ReactNode,
} from 'react';
export interface ObservableStore<T> {
getState: () => T;
subscribe: (listener: () => void) => () => void;
}
export function createStoreContext<T>(name: string) {
const StoreContext = createContext<ObservableStore<T> | null>(null);
StoreContext.displayName = `${name}Context`;
function StoreProvider({
store,
children,
}: {
store: ObservableStore<T>;
children: ReactNode;
}) {
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}
function useStore<U>(
selector: (state: T) => U,
isEqual: (a: U, b: U) => boolean = Object.is,
): U {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(`use${name}Store must be used within <${name}Provider>`);
}
const cache = useRef<{ value: U } | null>(null);
// Snapshot must be referentially stable when the selected slice is unchanged,
// otherwise useSyncExternalStore loops. Cache the last value.
const getSnapshot = useCallback(() => {
const next = selector(store.getState());
if (cache.current !== null && isEqual(cache.current.value, next)) {
return cache.current.value;
}
cache.current = { value: next };
return next;
}, [store, selector, isEqual]);
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
return { StoreProvider, useStore, StoreContext };
}Pitfall: the selector must be defined outside the render body (or wrapped in useCallback). An inline arrow function re-derives getSnapshot every render, forcing a resubscribe and negating the optimization.
React 19 Details That Matter
<MyContext value={...}>can now be used directly as a provider — no.Providersuffix.use(Context)is permitted inside conditionals and loops, unlikeuseContext.- React Compiler will memoize provider values automatically in most cases — but it cannot fix a provider whose value includes a fresh array literal from a render body. Keep the
useMemoanyway; it is documentation of intent and a guarantee.
Decision Matrix
| You are storing… | Use | Never use |
|---|---|---|
| Fetched entities, lists, pagination | Query cache | Context or signals |
| Auth session, theme, flags | Context (memoized, split) | Query cache |
| Cart, editor state with 100+ writes/sec | Signals / external store | Context |
| Filters, tabs, search query | URL search params | Component state |
| Modal open, hover, draft input | useState | Global store |
| Derived totals | computed / useMemo | A stored copy |
Production Checklist
- Every datum has exactly one owner, documented in a state inventory.
- No
useEffectfetch loops remain; all reads go through the query cache. - All
queryFnimplementations thread theAbortSignalintofetch. - Context providers are split by change frequency and memoized.
- High-frequency values use a selector store, not Context.
- Signals stores are instantiated per-request on the server (never module-level singletons).
- Every mutation has
onMutate/onError/onSettled— optimistic UI never strands. - Error boundaries wrap every suspense/hydration boundary.
- Profiler traces confirm no consumer re-renders on unrelated state changes.
Pitfalls That Bite in Production
- Tearing under concurrent rendering. Reading a mutable external store directly in render (instead of
useSyncExternalStore) produces inconsistent output across a single commit. There is no workaround — use the hook. - `useSyncExternalStore` infinite loops. Returning a fresh object from
getSnapshotbreaks bailout. Cache by reference or use a structural equality check. - Optimistic updates that never roll back. Forgetting
onErrorleaves the UI lying until a hard refresh. Always snapshot inonMutate. - Over-eager `staleTime: 0`. Every focus and remount refetches; on mobile this is a battery and bandwidth tax. Start at 30s for entity lists.
- Duplicating server data into Context. You now have two invalidation paths and a guaranteed divergence bug.
Sources & Technical References
- React — `useSyncExternalStore` and useContext API reference
- React — `useOptimistic` and React 19 release notes
- React — React Compiler documentation and "You Might Not Need an Effect"
- TC39 — Signals proposal (Stage 1)
- Preact — Signals core & React adapter documentation
- TanStack — Query documentation: important defaults, optimistic updates, cancellation
- MDN — `AbortController` / `AbortSignal` and Fetch API
- Ryan Carniato — "A Hands-on Introduction to Fine-Grained Reactivity"
- React RFC — "useSyncExternalStore" (RFC 0006)
- W3C — HTML Living Standard: Server-sent events & streaming
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Japan Didn't Wait for a Legal Basis. The US Still Doesn't. Pass the Clarity: Technical Architecture & Analysis
Japan codified bounded active cyber defense in 2025 while the US still has no statutory safe harbor — so the legal boundary has to live in your architecture, not your runbook. Here is a production reference design: typed policy gates, Rego-encoded jurisdiction rules, and a tamper-evident evidence ledger built for real-time analysis.

Rust Async Runtimes at 500K RPS: Tokio vs. Glommio vs. Monoio — io_uring, P99 Tail Latency, and Cost per Million Requests
We benchmarked Tokio, Glommio, and Monoio on identical 8-vCPU hardware at ~500K RPS and measured what actually moves your cloud bill: syscall overhead, P99 tail latency, bytes per connection, and dollars per million requests. Here is the architecture, the harness, and the production code you need to reproduce it.

Dynamic OG Images at 5M Portfolio Shares: Benchmarking Satori, resvg-js, Puppeteer, and Cloudinary
We rendered 5M portfolio case-study share cards and measured p95 render latency, cold-start memory, and true cost per million across Satori + resvg-js, Puppeteer, Cloudinary, and pre-rendered CDN delivery. This is the benchmark data, the production handler, and the cost engineering playbook you can ship today.