Back to KB
Difficulty
Intermediate
Read Time
9 min

Beyond Selectors: Engineering Fine-Grained Reactivity with Proxy-Driven State

By Codcompass Team··9 min read

Current Situation Analysis

Modern React applications routinely hit a scaling wall when state management transitions from simple component-local data to cross-cutting business logic. The industry standard approach relies on explicit subscription architectures: stores that require action dispatching, reducer chains, selector functions, and manual render optimization. While these patterns established early best practices, they introduce a fundamental architectural tax as applications grow.

The primary pain point is orchestration overhead. Developers spend a disproportionate amount of engineering time wiring update propagation rather than modeling domain data. Every state change requires explicit dependency tracking, memoization boundaries, and selector composition to prevent unnecessary component re-renders. This shifts the developer mental model from what the data represents to how the data moves through the system. The cognitive load compounds when teams introduce middleware, thunks, and normalization layers to tame complexity, resulting in abstraction inflation that obscures actual data flow.

This problem is frequently misunderstood because the React ecosystem normalized boilerplate as a sign of robustness. Teams assume that explicit subscription management is unavoidable discipline, when in reality it is a workaround for a missing engine-level reactivity contract. Traditional stores treat reactivity as an explicit event system rather than an implicit data observation mechanism. When state mutates frequently, derives dynamically, or requires real-time synchronization, the explicit model becomes brittle. Developers must manually wire update propagation, manage stale closures, and fight React's concurrent rendering lifecycle to maintain consistency.

Empirical data from production migrations highlights the cost of this approach. Traditional state libraries typically add 15–20 KB to the client bundle. They require coarse-grained re-render strategies that force manual memoization to achieve acceptable performance. The mental model complexity scales linearly with feature count, as every new state domain requires its own subscription topology. Meanwhile, proxy-based alternatives demonstrate that intercepting property access at the JavaScript engine level can eliminate selector boilerplate entirely, reduce bundle footprint to approximately 3 KB, and align reactivity with natural data mutation patterns.

WOW Moment: Key Findings

Proxy-based state management fundamentally rewrites the reactivity contract by intercepting property reads and writes at the engine level. Instead of dispatching actions and subscribing to change events, the runtime automatically tracks which properties are accessed during render and triggers updates only when those specific values change. This shifts the architecture from event orchestration to data observation.

ApproachBoilerplate OverheadRe-render GranularityMental Model ComplexityAsync Flow HandlingBundle Size
Traditional (Redux/Zustand)High (selectors, actions, reducers)Coarse (requires manual memoization)High (orchestration-focused)Complex (thunks/middleware)~15-20 KB
Valtio (Proxy-Based)Low (direct mutation)Fine (automatic snapshot tracking)Low (state-focused)Native (async proxies)~3 KB

Why this finding matters: The comparison reveals that proxy-driven reactivity eliminates the subscription tax entirely. Automatic dependency tracking captures property access during render, removing the need for manual dependency arrays and selector composition. Snapshot isolation guarantees a frozen, consistent view of state at render time, which prevents tearing in React 18's concurrent rendering environment. The sweet spot for this architecture emerges in dynamic, frequently mutating state graphs—complex forms, real-time dashboards, collaborative editing tools, and interactive data visualizations—where traditional stores demand excessive memoization and update orchestration. By treating state as observable data rather than dispatched events, teams can reduce cognitive load, shrink bundle size, and align state management with natural JavaScript mutation patterns.

Core Solution

Proxy-based state management replaces explicit subscription wiring with engine-level interception. The runtime wraps a plain JavaScript object in a Proxy, which traps property reads and writes. Components consume a snapshot of that proxy, which automatically tracks accessed properties and triggers re-renders only when those specific values mutate. Direct mutations bypass React's state queue entirely, while snapshots provide a stable, frozen reference for rendering.

Architecture Decisions & Rationale

  1. Direct Mutation vs Snapshot Reading: Mutations occur directly on the proxy object. This avoids React's state batching queue and allows synchronous, intuitive updates. Snapshots are read-only views generated at render time, ensuring components never mutate state during render and always receive a consistent data structure.
  2. Implicit Dependency Tracking: The proxy engine records property access during component render. When a trapped property changes, the engine notifies subscribed components. This eliminates manual useEffect dependencies, selector functions, and useMemo chains for state consumption.
  3. Concurrent-Safe Rendering: Snapshots are generated synchronously and align with React 18's rendering lifecycle. Under the hood, useSnapshot leverages useSyncExternalStore, which guarantees that concurrent renders never tear or observe partial state updates. This removes the need for manual subscription/unsubscription loops.

Implementation

Step 1: Define the State Domain Create a TypeScript interface to enforce type safety, then initialize the proxy.

import { proxy } from 'valtio';

interface TaskBoardState {
  activeFilter: 'all' | 'active' | 'completed';
  tasks: Array<{ id: string; title: string; done: boolean }>;
  isLoading: boolean;
}

const boardState = proxy<TaskBoardState>({
  activeFilter: 'all',
  tasks: [],
  isLoading: false,
});

Step 2: Mutate State Directly Updates occur synchronously on the proxy. No actions, reducers, or dispatch functions a

re required.

export function toggleTask(taskId: string) {
  const target = boardState.tasks.find(t => t.id === taskId);
  if (target) {
    target.done = !target.done;
  }
}

export function setFilter(newFilter: TaskBoardState['activeFilter']) {
  boardState.activeFilter = newFilter;
}

Step 3: Consume via Snapshot in Components Components read from useSnapshot, which automatically tracks dependencies and triggers granular re-renders.

import { useSnapshot } from 'valtio';
import { toggleTask, setFilter } from './boardStore';

export function TaskList() {
  const snap = useSnapshot(boardState);

  const visibleTasks = snap.tasks.filter(task => {
    if (snap.activeFilter === 'active') return !task.done;
    if (snap.activeFilter === 'completed') return task.done;
    return true;
  });

  return (
    <div>
      <select
        value={snap.activeFilter}
        onChange={e => setFilter(e.target.value as TaskBoardState['activeFilter'])}
      >
        <option value="all">All</option>
        <option value="active">Active</option>
        <option value="completed">Completed</option>
      </select>

      <ul>
        {visibleTasks.map(task => (
          <li key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.done}
                onChange={() => toggleTask(task.id)}
              />
              {task.title}
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

Why this works: The proxy engine intercepts snap.activeFilter and snap.tasks during render. When toggleTask or setFilter mutates the underlying proxy, the engine compares the new values against the snapshot. Only components that accessed the mutated properties re-render. Selector boilerplate, dependency arrays, and manual memoization become unnecessary. The architecture scales naturally because reactivity is bound to data access patterns, not explicit subscription topology.

Pitfall Guide

Proxy-based reactivity removes orchestration overhead, but it introduces new failure modes if developers treat it like a traditional store. Understanding these pitfalls prevents performance degradation and rendering inconsistencies.

1. Capturing Snapshots in Long-Running Closures

Explanation: useSnapshot returns a frozen object representing state at a specific render cycle. Storing this snapshot in setTimeout, setInterval, or background workers locks the reference to stale data. Fix: Use snapshots exclusively for rendering. Access the raw boardState proxy directly for mutations, async operations, or long-running callbacks. Snapshots are read-only views; proxies are the source of truth.

2. Mutating Proxies Outside Event Boundaries

Explanation: Triggering proxy mutations during render, inside useEffect cleanup functions, or in uncontrolled async flows can cause missed updates or inconsistent snapshot generation. Fix: Confine mutations to explicit triggers: user events, resolved promises, or scheduled callbacks. If you must mutate during an effect, wrap it in useLayoutEffect or batch it with React's startTransition to align with the rendering cycle.

3. Wrapping Immutable Configuration in Proxies

Explanation: Applying proxy() to static configuration, constants, or large read-only datasets adds unnecessary trap overhead. The engine will track accesses that never change, wasting CPU cycles and memory. Fix: Reserve proxies for frequently accessed or updated UI state. Use plain objects, Object.freeze(), or module-level constants for static data. Only wrap domains that require reactive observation.

4. Computing Derived Values Without Memoization

Explanation: Calculating derived state directly inside the render function without caching causes redundant computations on every parent update, leading to render thrashing. Fix: Use useMemo for synchronous derivations that depend on multiple snapshot properties. For expensive cross-property calculations, leverage Valtio's subscribeKey or external computed utilities to cache results and trigger updates only when source properties change.

5. Mixing Legacy Subscription Patterns with Proxy State

Explanation: Combining useEffect subscription loops, manual addEventListener state tracking, or third-party store integrations with proxy state creates conflicting update paths. This often results in tearing or double-renders. Fix: Rely exclusively on useSnapshot for state consumption. If you must integrate external systems, bridge them through a single mutation function that updates the proxy, rather than subscribing to both systems independently.

6. Deeply Nested Mutations Without Structural Awareness

Explanation: Mutating deeply nested properties triggers proxy traps at every level. While the engine handles this correctly, excessive nesting can degrade performance in high-frequency update scenarios. Fix: Flatten state structures where possible. Group related properties into shallow objects. If deep nesting is unavoidable, isolate frequently mutated branches into separate proxy instances to limit trap traversal depth.

7. Ignoring Batched Updates in High-Frequency Scenarios

Explanation: Rapid successive mutations (e.g., drag-and-drop, scroll tracking, WebSocket streams) can trigger multiple snapshot generations before React batches them, causing layout thrashing. Fix: Wrap high-frequency mutations in React.startTransition or requestAnimationFrame. This defers non-urgent updates, allowing React to batch snapshot generations and maintain 60fps rendering.

Production Bundle

Action Checklist

  • Audit state domains: Identify which modules require reactive observation versus static configuration.
  • Replace selector chains: Migrate useSelector or custom memoized selectors to direct useSnapshot property access.
  • Isolate mutation boundaries: Ensure all proxy updates occur in event handlers, async callbacks, or explicit action functions.
  • Flatten nested structures: Refactor deeply nested state objects into shallow, domain-specific proxies.
  • Implement derived state caching: Wrap expensive calculations in useMemo or external computed utilities.
  • Validate concurrent safety: Confirm all components use useSnapshot and avoid manual useEffect subscription loops.
  • Profile trap overhead: Use React DevTools Profiler and proxy monitoring to identify unnecessary property accesses.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
High-frequency UI updates (drag, scroll, live charts)Proxy-based (Valtio)Automatic granular tracking eliminates manual memoization overheadLow bundle, high render efficiency
Complex async workflows with side effectsTraditional store + middlewareExplicit action/reducer chains provide predictable side-effect orchestrationHigher bundle, moderate cognitive load
Static configuration or theme dataPlain objects / ContextNo reactivity required; proxy traps add unnecessary overheadMinimal bundle, zero trap cost
Cross-tab synchronization / WebSocket streamsProxy with subscribeKeyDirect property observation aligns with real-time data ingestionLow latency, efficient memory usage
Large-scale enterprise apps with strict audit trailsTraditional store with devtoolsExplicit action logging and time-travel debugging require event historyHigher maintenance, strong traceability

Configuration Template

Production-ready store factory with TypeScript enforcement, snapshot derivation, and performance monitoring hooks.

import { proxy, subscribe } from 'valtio';
import { useSnapshot } from 'valtio/react';
import { useMemo } from 'react';

// Domain interface
export interface AnalyticsState {
  metrics: Record<string, number>;
  timeframe: '1h' | '24h' | '7d';
  isStreaming: boolean;
}

// Factory function for isolated proxy instances
export function createAnalyticsStore(initial: AnalyticsState) {
  const store = proxy<AnalyticsState>(initial);

  // Subscribe to specific key changes for external integrations
  const unsubscribe = subscribe(store, () => {
    if (process.env.NODE_ENV === 'development') {
      console.log('[Store Update]', store.timeframe, store.isStreaming);
    }
  });

  return { store, unsubscribe };
}

// Custom hook for memoized derived state
export function useAnalyticsDerived(store: ReturnType<typeof createAnalyticsStore>['store']) {
  const snap = useSnapshot(store);

  const aggregated = useMemo(() => {
    return Object.values(snap.metrics).reduce((sum, val) => sum + val, 0);
  }, [snap.metrics]);

  return { ...snap, aggregated };
}

Quick Start Guide

  1. Install the library: Run npm install valtio or yarn add valtio. No peer dependencies required.
  2. Initialize your first proxy: Wrap your initial state object with proxy() and export it as a module-level singleton or factory instance.
  3. Consume in components: Import useSnapshot from valtio/react, pass your proxy, and read properties directly. Mutate the raw proxy in event handlers.
  4. Add TypeScript interfaces: Define strict interfaces for your state domain to enforce type safety across mutations and snapshots.
  5. Profile and iterate: Open React DevTools Profiler, trigger state changes, and verify that only components accessing mutated properties re-render. Adjust nesting or memoization as needed.