ore` under the hood.
Step 1: Design the State Container
State must persist across renders without being recreated. A module-level variable is the simplest approach for demonstration, though production implementations typically use a Map keyed by component instance or a closure factory.
type Listener = () => void;
type Updater<T> = T | ((prev: T) => T);
const subscribers = new Set<Listener>();
let currentSnapshot: unknown;
Step 2: Implement the Setter with Equality Checks
The setter must update the snapshot, notify subscribers, and prevent unnecessary renders. Using Object.is ensures consistent behavior with Reactâs built-in equality checks.
function dispatchUpdate<T>(nextValue: Updater<T>) {
const resolved = typeof nextValue === 'function'
? (nextValue as (prev: T) => T)(currentSnapshot as T)
: nextValue;
if (Object.is(currentSnapshot, resolved)) return;
currentSnapshot = resolved;
subscribers.forEach((listener) => listener());
}
Step 3: Bridge with useSyncExternalStore
React expects two functions: subscribe (listener registration) and getSnapshot (current value retrieval). The subscription must return a cleanup function to prevent memory leaks.
import { useSyncExternalStore } from 'react';
function subscribeToStore(listener: Listener) {
subscribers.add(listener);
return () => subscribers.delete(listener);
}
function readSnapshot() {
return currentSnapshot;
}
Step 4: Assemble the Hook
Combine the pieces into a generic hook that accepts an initial value and returns a state-snapshot pair.
export function useReactiveState<T>(initialValue: T) {
if (currentSnapshot === undefined) {
currentSnapshot = initialValue;
}
const snapshot = useSyncExternalStore(
subscribeToStore,
readSnapshot
);
return [snapshot, dispatchUpdate] as const;
}
Architecture Rationale
- Module-level storage: Chosen for pedagogical clarity. It demonstrates how state survives re-renders. In production, this would be replaced by a
WeakMap or closure-based store to support multiple independent instances.
Set for listeners: Provides O(1) insertion and deletion. Iteration is safe because Reactâs scheduler handles listener execution synchronously during the commit phase.
Object.is comparison: Matches Reactâs internal equality algorithm. It correctly handles NaN and distinguishes between 0 and -0, preventing wasted renders.
- Functional updater support: The setter accepts both direct values and functions, enabling safe state transitions that depend on previous values without closure staleness.
useSyncExternalStore integration: This is the official React contract for external data. It guarantees that snapshots are read consistently during concurrent renders and that updates are batched appropriately. React 18+ relies on this pattern to maintain UI consistency when multiple state updates are scheduled simultaneously.
Pitfall Guide
1. Global State Collision
- Explanation: Using a single module-level variable means every component calling the hook shares the same state. Clicking a button in one component updates all others, breaking component encapsulation.
- Fix: Replace the module variable with a
Map keyed by a unique identifier, or wrap the hook in a factory function that returns a closure-scoped store. Example: const createStore = () => ({ snapshot: null, listeners: new Set() });
2. Missing Unsubscribe Cleanup
- Explanation: If
subscribe doesnât return a cleanup function, listeners accumulate across mounts/unmounts, causing memory leaks and phantom re-renders.
- Fix: Always return
() => subscribers.delete(listener) from the subscription handler. React calls this during unmount or dependency changes. Omitting it violates the external store contract and degrades performance over time.
3. Shallow Equality for Complex Types
- Explanation:
Object.is only compares references for objects and arrays. Mutating a nested property wonât trigger a re-render because the reference remains unchanged.
- Fix: Enforce immutable updates in the setter, or implement a deep comparison utility. Prefer structural sharing patterns (e.g., spread operators or Immer) to maintain performance while ensuring reference changes propagate correctly.
4. Ignoring Functional Updates
- Explanation: Direct value assignment (
setState(newValue)) fails when updates are batched or depend on previous state. Multiple rapid calls will overwrite each other, causing lost updates.
- Fix: Support the
updater function pattern. Resolve the function against the current snapshot before applying the update. This mirrors Reactâs internal batching behavior and prevents race conditions in event handlers.
5. Synchronous Listener Execution in Concurrent Mode
- Explanation: Calling listeners synchronously during state updates can interfere with Reactâs concurrent scheduling, potentially causing tearing or inconsistent UI states when multiple updates are queued.
- Fix: Rely on
useSyncExternalStoreâs built-in scheduling. Avoid wrapping listener calls in setTimeout or queueMicrotask unless explicitly required for external library compatibility. Reactâs scheduler is designed to handle synchronous notifications safely.
6. Stale Closures in Event Handlers
- Explanation: If the setter captures an outdated snapshot, functional updates will compute against stale data. This commonly occurs when event handlers are defined outside the component or memoized incorrectly.
- Fix: Always read the latest value inside the updater function. The hookâs internal
dispatchUpdate should resolve functions against the current module-level snapshot, not a captured closure. Use useCallback with explicit dependencies when passing setters to child components.
7. Bypassing Reactâs Batching Mechanism
- Explanation: Manually triggering re-renders outside Reactâs scheduler can cause multiple commits for a single logical update, increasing layout thrashing and reducing frame rates.
- Fix: Let
useSyncExternalStore handle batching. React 18+ automatically batches state updates, including those triggered by external store notifications. Avoid calling setState multiple times in a loop without wrapping in ReactDOM.unstable_batchedUpdates (legacy) or relying on React 18âs automatic batching.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single component local state | Native useState | Optimized by React, zero boilerplate | None |
| Cross-component shared state | Context + useReducer or Zustand | Avoids prop drilling, scales predictably | Low (re-render optimization needed) |
| External data source (WebSocket, API) | useSyncExternalStore | Native React contract, concurrent-safe | Medium (requires store implementation) |
| Custom lightweight state primitive | Closure-based store + useSyncExternalStore | Full control, minimal dependencies | High (maintenance overhead) |
| Complex state with side effects | Redux Toolkit or Jotai | Built-in middleware, devtools, batching | Medium-High (bundle size) |
Configuration Template
import { useSyncExternalStore } from 'react';
type Listener = () => void;
type Updater<T> = T | ((prev: T) => T);
// Production: Replace with WeakMap or closure factory for instance isolation
const store = {
snapshot: undefined as unknown,
listeners: new Set<Listener>(),
};
function notifySubscribers() {
store.listeners.forEach((listener) => listener());
}
function dispatch<T>(next: Updater<T>) {
const resolved = typeof next === 'function'
? (next as (prev: T) => T)(store.snapshot as T)
: next;
if (Object.is(store.snapshot, resolved)) return;
store.snapshot = resolved;
notifySubscribers();
}
function subscribe(listener: Listener) {
store.listeners.add(listener);
return () => store.listeners.delete(listener);
}
function getSnapshot() {
return store.snapshot;
}
export function useReactiveState<T>(initialValue: T) {
if (store.snapshot === undefined) {
store.snapshot = initialValue;
}
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
return [snapshot, dispatch] as const;
}
Quick Start Guide
- Copy the template: Paste the configuration code into a new file (e.g.,
useReactiveState.ts).
- Import and replace: Swap existing
useState calls with useReactiveState in your component.
- Verify updates: Trigger state changes and confirm the UI re-renders without manual force-updates.
- Add functional updates: Test
(prev) => prev + 1 patterns to ensure batched updates resolve correctly.
- Isolate instances: If multiple components share state unexpectedly, refactor the store into a closure factory or
WeakMap keyed by component identity.
Building a simplified state primitive doesnât replicate Reactâs internal Fiber architecture, but it exposes the fundamental contract between state mutation and render scheduling. By understanding how useSyncExternalStore bridges external data with Reactâs concurrent engine, developers gain the architectural literacy required to build performant custom stores, integrate third-party libraries safely, and debug rendering anomalies without relying on trial-and-error. The pattern scales from lightweight component state to enterprise-grade caching layers, provided isolation, immutability, and scheduler alignment are maintained.