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>:
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;
}| Field | Purpose |
|---|---|
target | The raw (unproxied) object |
propSignals | Per-property core signals for fine-grained tracking |
version | Monotonically increasing counter, bumped on any mutation |
listeners | Callbacks registered via subscribe() |
childUnsubs | Cleanup functions for child-to-parent notification links |
cachedSnapshot | Last frozen snapshot (for structural sharing) |
snapshotVersion | Version 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. Callingsignal.get()inside acomputedoreffectregisters 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.isto skip notifications when the value hasn't changed - Batched signal updates: Property signal updates are wrapped in
batch()to coalesce when array operations (likepush) cause multiple trap invocations - Array length tracking: When setting a numeric index on an array extends its length, the
lengthsignal 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:
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:
const stateMap = new WeakMap<object, ProxyState>(); // target -> state
const proxyMap = new WeakMap<object, object>(); // target -> proxyWhen observable() is called:
- If the argument is already an observable proxy (
OBSERVABLE_MARKERcheck), return it unchanged - If a proxy already exists for this target (
proxyMap.has()), return the cached proxy - 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:
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:
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
useSyncExternalStoreto skip re-renders for unchanged slices React.memoto 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:
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():
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:
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
| Scenario | Proxy | Store | Atom |
|---|---|---|---|
Mutable assignment syntax (state.x = y) | Best fit | No (use setState) | No (use set) |
| Deep nested state mutations | Best fit (auto-tracked) | Manual spreading | Manual set calls |
| Fine-grained per-property tracking | Built-in (per-property signals) | No (full state) | Per atom |
| Middleware (persist, devtools) | Not supported | Built-in | Not supported |
| Immutable snapshots for React | Via snapshot() | Via selector | Via get() |
| Large state trees | Lazy proxying avoids overhead | Single signal | One signal per atom |
| DOM elements, class instances | Via ref() escape hatch | Store as-is | Store as-is |
| Array operations (push, splice) | Native JS syntax | Manual immutable updates | Manual |
Performance Considerations
| Concern | Strategy | Complexity |
|---|---|---|
| Lazy proxying | Nested objects proxied on first access, not at creation | O(1) per first access |
| Signal allocation | Per-property signals created lazily on first read | O(1) per property |
| Proxy recycling | WeakMap cache prevents re-wrapping the same target | O(1) lookup |
| Snapshot sharing | Version-based cache returns same frozen object for unchanged subtrees | O(1) cache hit per subtree |
| Batch writes | Array mutations (push, splice) batched to coalesce signal updates | O(1) per batch |
| Memory | WeakMap for stateMap and proxyMap — eligible for GC when target is unreferenced | Automatic via GC |
| Child propagation | Listener-based (not polling); cleanup on property reassignment | O(1) setup/teardown |
Memory Patterns
| Construct | Per-Instance Allocation | When GC-Eligible |
|---|---|---|
observable(obj) proxy | 1 Proxy + 1 ProxyState | When target object is unreferenced (WeakMap) |
| Per-property signal | 1 core signal per accessed property | When ProxyState is GC'd |
| Cached snapshot | 1 frozen object per changed subtree | Replaced on next version change |
| Child notification listener | 1 closure per nested property | On property reassignment or deletion |
Cross-References
- Architecture Overview — where proxy fits in the layer structure
- Core Design — signal and batch internals that proxy uses internally
- Design Philosophy — why per-property signals vs whole-object tracking
- Adapters Overview — how
useSnapshotbridges to frameworks - API Reference:
@stateloom/proxy— consumer-facing documentation