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
- 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.
- 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.
- 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 are 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
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency UI updates (drag, scroll, live charts) | Proxy-based (Valtio) | Automatic granular tracking eliminates manual memoization overhead | Low bundle, high render efficiency |
| Complex async workflows with side effects | Traditional store + middleware | Explicit action/reducer chains provide predictable side-effect orchestration | Higher bundle, moderate cognitive load |
| Static configuration or theme data | Plain objects / Context | No reactivity required; proxy traps add unnecessary overhead | Minimal bundle, zero trap cost |
| Cross-tab synchronization / WebSocket streams | Proxy with subscribeKey | Direct property observation aligns with real-time data ingestion | Low latency, efficient memory usage |
| Large-scale enterprise apps with strict audit trails | Traditional store with devtools | Explicit action logging and time-travel debugging require event history | Higher 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
- Install the library: Run
npm install valtio or yarn add valtio. No peer dependencies required.
- Initialize your first proxy: Wrap your initial state object with
proxy() and export it as a module-level singleton or factory instance.
- Consume in components: Import
useSnapshot from valtio/react, pass your proxy, and read properties directly. Mutate the raw proxy in event handlers.
- Add TypeScript interfaces: Define strict interfaces for your state domain to enforce type safety across mutations and snapshots.
- 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.