Skip to content

Proxy Design

Low-level design for @stateloom/proxy — the Valtio-like mutable proxy paradigm adapter. Covers the two-layer proxy architecture, property-level signal tracking, structural sharing in snapshots, proxy recycling, and the ref() escape hatch.

Overview

The proxy paradigm lets developers write state mutations using familiar JavaScript assignment syntax (state.count++) while the library transparently tracks reads and writes through Proxy traps. Under the hood, each property is backed by a core signal, so the reactive graph handles all change propagation. Immutable snapshots with structural sharing enable safe rendering in frameworks like React.

ProxyState

Each proxied object has a ProxyState record stored in a module-level WeakMap<object, ProxyState>:

typescript
interface ProxyState {
  readonly target: Record<PropertyKey, unknown>;
  readonly propSignals: Map<PropertyKey, Signal<unknown>>;
  version: number;
  readonly listeners: Set<() => void>;
  readonly childUnsubs: Map<PropertyKey, () => void>;
  cachedSnapshot: object | undefined;
  snapshotVersion: number;
}
FieldPurpose
targetThe raw (unproxied) object
propSignalsPer-property core signals for fine-grained tracking
versionMonotonically increasing counter, bumped on any mutation
listenersCallbacks registered via subscribe()
childUnsubsCleanup functions for child-to-parent notification links
cachedSnapshotLast frozen snapshot (for structural sharing)
snapshotVersionVersion when cachedSnapshot was created

Proxy Handler

The proxy system uses a single Proxy wrapper per object that intercepts reads, writes, and deletes.

Get Trap (Read Tracking)

Key behaviors:

  • Per-property signals: Each property gets its own Signal<unknown>, created lazily on first access. Calling signal.get() inside a computed or effect registers a dependency on that specific property
  • Lazy child proxying: Nested objects are proxied on first access, not at creation time
  • Child notification setup: When a nested proxy is accessed, a listener is installed so that mutations to the child propagate version increments to the parent

Set Trap (Write Tracking)

Key behaviors:

  • Equality check: Uses Object.is to skip notifications when the value hasn't changed
  • Batched signal updates: Property signal updates are wrapped in batch() to coalesce when array operations (like push) cause multiple trap invocations
  • Array length tracking: When setting a numeric index on an array extends its length, the length signal is also updated
  • Child cleanup: When a property value changes, any child-to-parent listener from the old value is disconnected

DeleteProperty Trap

Follows the same pattern as set: checks if the property existed, removes it via Reflect.deleteProperty, increments version, updates the property signal to undefined, and notifies listeners.

Deep Proxy Creation

Objects are recursively proxied on access, not at creation time. The canProxy guard determines what can be proxied:

typescript
function canProxy(value: unknown): value is object {
  if (value === null || typeof value !== 'object') return false;
  if (isRef(value)) return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === Array.prototype || proto === null;
}

Only plain objects, arrays, and null-prototype objects are proxied. Built-in types (Date, Map, Set, RegExp, class instances) are stored as-is since their internal slots don't work through Proxy.

Proxy Recycling

Two module-level WeakMaps prevent re-wrapping:

typescript
const stateMap = new WeakMap<object, ProxyState>(); // target -> state
const proxyMap = new WeakMap<object, object>(); // target -> proxy

When observable() is called:

  1. If the argument is already an observable proxy (OBSERVABLE_MARKER check), return it unchanged
  2. If a proxy already exists for this target (proxyMap.has()), return the cached proxy
  3. Otherwise, create a new proxy and cache it

This ensures identity stability: observable(obj) === observable(obj) is always true, and accessing the same nested object from different paths returns the same proxy.

Child-to-Parent Notification

When a nested proxy mutates, the parent must know so that its version increments (invalidating cached snapshots) and its listeners fire.

Setup happens in the get trap when a child proxy is returned:

typescript
function setupChildNotification(parentState, prop, childTarget) {
  if (parentState.childUnsubs.has(prop)) return;
  const childState = stateMap.get(childTarget);
  if (!childState) return;
  const listener = () => {
    parentState.version++;
    notifyListeners(parentState);
  };
  childState.listeners.add(listener);
  parentState.childUnsubs.set(prop, () => {
    childState.listeners.delete(listener);
  });
}

When a property is reassigned, cleanupChildNotification() removes the old listener before setting up the new one.

Snapshot Creation

snapshot() creates a deeply frozen, structurally shared copy of the proxy state:

Structural Sharing

The version-based cache is the key to structural sharing:

typescript
if (state.cachedSnapshot !== undefined && state.snapshotVersion === state.version) {
  return state.cachedSnapshot;
}

If a subtree hasn't been mutated (version unchanged), createSnapshot returns the same frozen object reference. This means === checks on unchanged subtrees pass, enabling:

  • React's useSyncExternalStore to skip re-renders for unchanged slices
  • React.memo to bail out efficiently
  • Selector memoization in framework adapters

All snapshot objects are frozen with Object.freeze(). Ref-wrapped values are included as-is (not frozen).

ref() — Shallow Wrapping

ref() wraps a value in a { current: T, [REF_MARKER]: true } object that signals the proxy system to skip deep proxying:

typescript
const REF_MARKER: unique symbol = Symbol('stateloom.ref');

function ref<T>(value: T): Ref<T> {
  return { current: value, [REF_MARKER]: true as const };
}

Use cases:

  • DOM elements: ref(document.getElementById('canvas'))
  • Class instances: ref(new WebSocket(url))
  • Large immutable data: ref(geoJsonData) — avoid proxying thousands of coordinate objects

The canProxy() guard checks isRef() first, so ref-wrapped values are never recursively proxied. Changes to ref.current are not tracked — to trigger notifications, reassign the entire ref: state.canvas = ref(newCanvas).

observe() — Auto-Tracking Side Effects

observe() is a thin wrapper around core effect():

typescript
function observe(fn: () => void): () => void {
  return effect(() => {
    fn();
    return undefined;
  });
}

When fn reads proxy properties, those reads go through the get trap which calls signal.get(), registering dependencies in the core reactive graph. When any tracked property changes, the effect re-executes. The return undefined ensures the effect doesn't attempt cleanup.

subscribe() — Mutation Listening

subscribe() registers a callback on a proxy's listeners set:

typescript
function subscribe(proxy: object, callback: () => void): () => void {
  const state = getProxyState(proxy);
  state.listeners.add(callback);
  return () => {
    state.listeners.delete(callback);
  };
}

Unlike observe(), subscribe() does not track which properties were read — it fires on every mutation to the proxy or any of its descendants. This is the notification mechanism used by framework adapters (e.g., useSnapshot in React triggers re-render via subscribe, then creates a new snapshot).

Design Decisions

Why Per-Property Signals

A single signal per object would force all consumers to re-render on any property change. Per-property signals enable fine-grained tracking: reading state.count only subscribes to the count signal, not state.name. This matches Valtio's proxy-compare approach but uses core signals instead of a separate tracking system.

Why Version Numbers Instead of Dirty Flags

The version counter on ProxyState serves two purposes: snapshot cache invalidation (quick check if anything changed) and parent propagation (child mutations increment parent version). A boolean dirty flag would need reset logic; a version counter is monotonic and comparison is trivial.

Why Structural Sharing via Caching

Recreating frozen snapshots on every access would be expensive for large state trees. The version-based cache ensures unchanged subtrees return the exact same frozen object, making === comparison reliable for framework optimizations.

Why canProxy Excludes Built-ins

Map, Set, Date, and other built-in types use internal slots that Proxy cannot intercept. Attempting to proxy them causes TypeError on method calls. The canProxy guard ensures only plain objects and arrays are proxied.

Observable Subscription Lifecycle

This state diagram shows the lifecycle of a proxy subscription:

When to Use Proxy vs Other Paradigms

ScenarioProxyStoreAtom
Mutable assignment syntax (state.x = y)Best fitNo (use setState)No (use set)
Deep nested state mutationsBest fit (auto-tracked)Manual spreadingManual set calls
Fine-grained per-property trackingBuilt-in (per-property signals)No (full state)Per atom
Middleware (persist, devtools)Not supportedBuilt-inNot supported
Immutable snapshots for ReactVia snapshot()Via selectorVia get()
Large state treesLazy proxying avoids overheadSingle signalOne signal per atom
DOM elements, class instancesVia ref() escape hatchStore as-isStore as-is
Array operations (push, splice)Native JS syntaxManual immutable updatesManual

Performance Considerations

ConcernStrategyComplexity
Lazy proxyingNested objects proxied on first access, not at creationO(1) per first access
Signal allocationPer-property signals created lazily on first readO(1) per property
Proxy recyclingWeakMap cache prevents re-wrapping the same targetO(1) lookup
Snapshot sharingVersion-based cache returns same frozen object for unchanged subtreesO(1) cache hit per subtree
Batch writesArray mutations (push, splice) batched to coalesce signal updatesO(1) per batch
MemoryWeakMap for stateMap and proxyMap — eligible for GC when target is unreferencedAutomatic via GC
Child propagationListener-based (not polling); cleanup on property reassignmentO(1) setup/teardown

Memory Patterns

ConstructPer-Instance AllocationWhen GC-Eligible
observable(obj) proxy1 Proxy + 1 ProxyStateWhen target object is unreferenced (WeakMap)
Per-property signal1 core signal per accessed propertyWhen ProxyState is GC'd
Cached snapshot1 frozen object per changed subtreeReplaced on next version change
Child notification listener1 closure per nested propertyOn property reassignment or deletion

Cross-References