@stateloom/proxy
Proxy-based mutable state with transparent tracking. If you've used Valtio or MobX, this API will feel immediately familiar.
Install
pnpm add @stateloom/core @stateloom/proxynpm install @stateloom/core @stateloom/proxyyarn add @stateloom/core @stateloom/proxySize: ~1.2 KB gzipped (+ core)
Overview
The proxy paradigm lets you write state with regular JavaScript mutations. Under the hood, a two-layer proxy architecture tracks writes (for notification) and reads (for render optimization).
Quick Start
import { observable, snapshot, observe } from '@stateloom/proxy';
const state = observable({
user: { name: 'Alice', age: 30 },
todos: [{ text: 'Learn StateLoom', done: false }],
});
// Mutate directly — tracked automatically
state.user.name = 'Bob';
state.todos.push({ text: 'Build app', done: false });
// Observe changes (auto-tracks accessed properties)
const dispose = observe(() => {
console.log(`${state.user.name} has ${state.todos.length} todos`);
});
// Immutable snapshot for React
const snap = snapshot(state);
// snap is deeply frozen with structural sharingGuide
Creating Observable State
Use observable to create a deeply-proxied mutable state object. Mutations at any depth are tracked automatically:
import { observable } from '@stateloom/proxy';
const state = observable({
user: { name: 'Alice', age: 30 },
todos: [{ text: 'Learn StateLoom', done: false }],
});
// Direct mutations — all tracked
state.user.name = 'Bob';
state.todos.push({ text: 'Build app', done: false });Taking Snapshots
Use snapshot to create immutable copies for rendering. Snapshots use structural sharing -- unchanged subtrees keep the same reference:
import { observable, snapshot } from '@stateloom/proxy';
const state = observable({ a: 1, b: { c: 2 } });
const snap1 = snapshot(state);
state.a = 10;
const snap2 = snapshot(state);
// snap2.b === snap1.b (structural sharing — b didn't change)Observing Changes
Use observe for auto-tracking side effects. Only re-runs when accessed properties change:
import { observable, observe } from '@stateloom/proxy';
const state = observable({ count: 0, name: 'Alice' });
const dispose = observe(() => {
console.log('Count:', state.count);
// Only re-runs when count changes, NOT when name changes
});
state.count = 1; // triggers observe
state.name = 'Bob'; // does NOT trigger observe
dispose();Opting Out with ref
Use ref to exclude values from proxying (DOM elements, class instances, etc.):
import { observable, ref } from '@stateloom/proxy';
const state = observable({
canvas: ref(document.createElement('canvas')),
data: { count: 0 }, // this IS proxied
});
// state.canvas.current is NOT proxiedAPI Reference
observable<T extends object>(initialObj: T): T
Create a deeply-proxied mutable state object. Returns a Proxy that intercepts property access and mutation.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
initialObj | T | A plain object or array to make observable. | -- |
Returns: T -- a proxied version of initialObj with the same type.
import { observable } from '@stateloom/proxy';
const state = observable({
count: 0,
user: { name: 'Alice', age: 30 },
todos: [] as Todo[],
settings: {
theme: 'dark' as 'light' | 'dark',
notifications: true,
},
});
// Direct mutations -- all tracked
state.count++;
state.user.name = 'Bob';
state.todos.push({ text: 'Learn', done: false });
state.settings.theme = 'light';Key behaviors:
- Nested objects and arrays are lazily proxied on first access
- Only plain objects, arrays, and null-prototype objects are proxied -- built-in types (Date, Map, Set, etc.) are stored as-is
- Use
ref()to opt specific values out of proxying - Calling
observable()on an already-observable object returns it unchanged - Mutations trigger notifications through the core signal system, integrating with
effect(),computed(), andbatch() - Throws if the argument is not a plain object or array
See also: snapshot(), observe(), ref()
snapshot<T extends object>(proxy: T): Snapshot<T>
Create an immutable, structurally-shared snapshot of an observable proxy. Unchanged subtrees retain the same reference between consecutive snapshots.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
proxy | T | An observable proxy created by observable(). | -- |
Returns: Snapshot<T> -- a deeply frozen, read-only copy of the current state.
import { observable, snapshot } from '@stateloom/proxy';
const state = observable({ a: 1, b: { c: 2 } });
const snap1 = snapshot(state);
// Object.isFrozen(snap1) === true
state.a = 10;
const snap2 = snapshot(state);
// snap2.a === 10
// snap2.b === snap1.b (structural sharing -- b didn't change)Key behaviors:
- Snapshots are deeply frozen (
Object.freezeapplied recursively) - Structural sharing: Unchanged subtrees retain the same object reference -- enables
===checks,React.memo, and selector memoization to skip re-renders - Snapshots are safe for React rendering (immutable, no proxy wrapper)
- Each observable caches its latest snapshot; if no mutations occurred since the last
snapshot()call, the cached copy is returned - Ref-wrapped values are included in the snapshot as-is (not frozen)
See also: observable(), observe()
observe(fn: () => void): () => void
Create an auto-tracking side-effect that re-runs when accessed proxy properties change. Built on core effect().
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
fn | () => void | Side-effect function. Called immediately, then re-executed when tracked properties change. | -- |
Returns: () => void -- a dispose function that stops observation permanently.
import { observable, observe } from '@stateloom/proxy';
const state = observable({ count: 0, name: 'Alice' });
const dispose = observe(() => {
// Only re-runs when `count` changes, not when `name` changes
console.log('Count:', state.count);
});
state.count = 1; // triggers observe
state.name = 'Bob'; // does NOT trigger observe (name wasn't accessed)
dispose(); // stop observingKey behaviors:
- Runs synchronously on creation
- Re-executions are scheduled via microtask (consistent with core
effect()behavior) - Inside an explicit
batch(), re-execution is deferred until the batch ends - Dependencies are tracked dynamically -- conditional branches only track properties accessed in the taken path
dispose()is idempotent- Unlike core
effect(),observedoes not support cleanup return values
See also: observable(), snapshot()
subscribe(proxy: object, callback: () => void): () => void
Subscribe to any mutation on an observable proxy, including nested changes. This is the low-level notification mechanism used by framework adapters.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
proxy | object | An observable proxy created by observable(). | -- |
callback | () => void | Function called on each mutation. Receives no arguments. | -- |
Returns: () => void -- an unsubscribe function.
import { observable, subscribe } from '@stateloom/proxy';
const state = observable({ count: 0, user: { name: 'Alice' } });
const unsub = subscribe(state, () => {
console.log('State changed!');
});
state.count = 1; // "State changed!"
state.user.name = 'Bob'; // "State changed!" (nested change propagates)
unsub(); // stop listeningKey behaviors:
- The callback fires synchronously after each mutation
- Nested mutations propagate up to parent listeners
- Unlike
observe(),subscribe()does not track which properties were read -- it fires on every mutation to the proxy or any of its descendants
See also: observable(), observe()
ref<T>(value: T): Ref<T>
Wrap a value to exclude it from proxying. Use for DOM elements, class instances, or any value that should not participate in reactive tracking.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
value | T | The value to exclude from proxying. | -- |
Returns: Ref<T> -- a { current: T } wrapper with an internal ref marker.
import { observable, ref } from '@stateloom/proxy';
const state = observable({
canvas: ref(document.createElement('canvas')),
worker: ref(new Worker('worker.js')),
data: { count: 0 }, // this IS proxied
});
state.canvas.current; // the raw canvas element (not proxied)
state.data.count; // proxied and trackedKey behaviors:
- Ref-wrapped values are stored as-is inside the observable target
- Accessing a ref property returns the
Ref<T>wrapper, not the inner value - Changes to
ref.currentare not tracked -- to trigger notifications, reassign the entire ref:state.canvas = ref(newCanvas) - Ref-wrapped values are included in snapshots as-is (not frozen)
See also: observable()
Ref<T> (interface)
A ref-wrapped value excluded from proxying.
interface Ref<T> {
current: T;
readonly [REF_MARKER]: true;
}Snapshot<T>
Deep readonly type for proxy snapshots. Recursively applies readonly to all properties.
type Snapshot<T> = T extends (...args: readonly unknown[]) => unknown
? T
: T extends object
? { readonly [K in keyof T]: Snapshot<T[K]> }
: T;React Integration
With @stateloom/react, the useSnapshot hook provides automatic render optimization:
import { observable } from '@stateloom/proxy';
import { useSnapshot } from '@stateloom/react';
const state = observable({
user: { name: 'Alice', age: 30 },
count: 0,
});
function UserName() {
const snap = useSnapshot(state);
// Only re-renders when state.user.name changes
return <div>{snap.user.name}</div>;
}
function Counter() {
const snap = useSnapshot(state);
// Only re-renders when state.count changes
return (
<div>
<span>{snap.count}</span>
<button onClick={() => state.count++}>+</button>
</div>
);
}How it works: useSnapshot returns a read-tracking proxy. During render, it records which properties were accessed. On state change, it checks if any accessed property changed — if not, the component skips re-render.
Patterns
Nested State Updates
Mutations at any depth are tracked:
import { observable } from '@stateloom/proxy';
const state = observable({
form: {
fields: {
email: '',
password: '',
},
errors: {} as Record<string, string>,
},
});
// Deep mutation — tracked automatically
state.form.fields.email = 'alice@example.com';
state.form.errors['email'] = 'Invalid format';Array Operations
All array methods work and are tracked:
import { observable } from '@stateloom/proxy';
const state = observable({ items: [1, 2, 3] });
state.items.push(4);
state.items.splice(1, 1);
state.items[0] = 10;
state.items.sort((a, b) => a - b);Derived State
Use observe or computed for derived values:
import { observable, snapshot, observe } from '@stateloom/proxy';
import { computed } from '@stateloom/core';
const state = observable({
items: [] as Item[],
filter: 'all' as 'all' | 'active' | 'done',
});
// Option 1: observe for side effects
observe(() => {
const active = state.items.filter((i) => !i.done);
console.log(`${active.length} active items`);
});
// Option 2: computed for derived values in UI
const filteredItems = computed(() => {
const snap = snapshot(state);
if (snap.filter === 'all') return snap.items;
return snap.items.filter((i) => (snap.filter === 'active' ? !i.done : i.done));
});How It Works
Per-Property Signal Tracking
Each proxied object has an internal ProxyState record with per-property core signals. When you read state.count, the proxy's get trap creates a signal for the count property (lazily, on first access) and calls signal.get(), registering a dependency in the core reactive graph. When you write state.count = 1, the set trap updates the signal via signal.set(), triggering notifications through the core system.
Write Flow
When a property is mutated:
- The
settrap checks equality viaObject.is-- if unchanged, no-op - The raw target is updated directly
- The property's signal is updated via
signal.set()inside abatch() - The object's
versioncounter increments - All
subscribe()listeners are notified synchronously - For arrays, if setting an index extends
length, thelengthsignal is also updated
Nested Proxy and Child Notification
Objects are proxied lazily on first access. When a child proxy mutates, the parent must know:
Structural Sharing in Snapshots
snapshot() creates frozen copies with a version-based cache. If a subtree's version hasn't changed since the last snapshot, the cached frozen object is reused. This means === checks on unchanged subtrees pass, enabling React.memo and selector memoization to skip re-renders efficiently.
TypeScript
Types are preserved through the proxy and snapshot:
import { observable, snapshot, ref, observe } from '@stateloom/proxy';
import type { Snapshot, Ref } from '@stateloom/proxy';
import { expectTypeOf } from 'vitest';
interface AppState {
user: { name: string; age: number };
todos: Array<{ text: string; done: boolean }>;
}
// Observable preserves the input type
const state = observable<AppState>({
user: { name: 'Alice', age: 30 },
todos: [],
});
expectTypeOf(state).toEqualTypeOf<AppState>();
// Full type safety on mutations
state.user.name = 'Bob'; // OK
// state.user.name = 123; // TS Error: number not assignable to string
state.todos.push({ text: '', done: false }); // OK
// Snapshot is deeply readonly
const snap = snapshot(state);
expectTypeOf(snap).toEqualTypeOf<Snapshot<AppState>>();
// snap.user.name = 'x'; // TS Error: Cannot assign to 'name' (readonly)
// Ref preserves the inner type
const canvasRef = ref(document.createElement('canvas'));
expectTypeOf(canvasRef).toEqualTypeOf<Ref<HTMLCanvasElement>>();
expectTypeOf(canvasRef.current).toEqualTypeOf<HTMLCanvasElement>();When to Use Proxy
- Mutable mental model: You prefer writing state updates as mutations
- Deeply nested state: Proxy handles nested updates without spread operators
- Rapid prototyping: Minimal ceremony, just mutate and observe
- Migration from MobX/Valtio: Familiar patterns, minimal learning curve
- Automatic render optimization:
useSnapshottracks accessed properties automatically