All ArticlesReact•October 31, 2026•9 min read

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.

Close-up of colorful programming code displayed on a computer monitor with a dark background.Photo by Nemuel Sereti on Pexels

React 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.

PlaneCharacteristicsCanonical Owner
Server stateAsync, shared, staleness-aware, needs dedupe/retry/GCQuery cache (TanStack Query / RSC)
Global client stateSynchronous, app-wide, low frequencyExternal store (Zustand, signals) via useSyncExternalStore
URL stateShareable, deep-linkable, survives reloadRouter search params
Local UI stateEphemeral, component-scopeduseState / useReducer

Two invariants follow from this table:

  1. 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.
  2. 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-react with the Babel/SWC plugin) that rewrites .value reads 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

ts
// 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

tsx
// 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

ts
// 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

ts
// 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.

tsx
// 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

tsx
// ❌ 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.

tsx
// 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 .Provider suffix.
  • use(Context) is permitted inside conditionals and loops, unlike useContext.
  • 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 useMemo anyway; it is documentation of intent and a guarantee.

Decision Matrix

You are storing…UseNever use
Fetched entities, lists, paginationQuery cacheContext or signals
Auth session, theme, flagsContext (memoized, split)Query cache
Cart, editor state with 100+ writes/secSignals / external storeContext
Filters, tabs, search queryURL search paramsComponent state
Modal open, hover, draft inputuseStateGlobal store
Derived totalscomputed / useMemoA stored copy

Production Checklist

  • Every datum has exactly one owner, documented in a state inventory.
  • No useEffect fetch loops remain; all reads go through the query cache.
  • All queryFn implementations thread the AbortSignal into fetch.
  • 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

  1. 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.
  2. `useSyncExternalStore` infinite loops. Returning a fresh object from getSnapshot breaks bailout. Cache by reference or use a structural equality check.
  3. Optimistic updates that never roll back. Forgetting onError leaves the UI lying until a hard refresh. Always snapshot in onMutate.
  4. Over-eager `staleTime: 0`. Every focus and remount refetches; on mobile this is a battery and bandwidth tax. Start at 30s for entity lists.
  5. Duplicating server data into Context. You now have two invalidation paths and a guaranteed divergence bug.

Sources & Technical References

0 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles