Skip to content

@stateloom/proxy

Proxy-based mutable state with transparent tracking. If you've used Valtio or MobX, this API will feel immediately familiar.

Install

bash
pnpm add @stateloom/core @stateloom/proxy
bash
npm install @stateloom/core @stateloom/proxy
bash
yarn add @stateloom/core @stateloom/proxy

Size: ~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

typescript
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 sharing

Guide

Creating Observable State

Use observable to create a deeply-proxied mutable state object. Mutations at any depth are tracked automatically:

typescript
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:

typescript
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:

typescript
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.):

typescript
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 proxied

API 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:

ParameterTypeDescriptionDefault
initialObjTA plain object or array to make observable.--

Returns: T -- a proxied version of initialObj with the same type.

typescript
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(), and batch()
  • 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:

ParameterTypeDescriptionDefault
proxyTAn observable proxy created by observable().--

Returns: Snapshot<T> -- a deeply frozen, read-only copy of the current state.

typescript
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.freeze applied 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:

ParameterTypeDescriptionDefault
fn() => voidSide-effect function. Called immediately, then re-executed when tracked properties change.--

Returns: () => void -- a dispose function that stops observation permanently.

typescript
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 observing

Key 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(), observe does 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:

ParameterTypeDescriptionDefault
proxyobjectAn observable proxy created by observable().--
callback() => voidFunction called on each mutation. Receives no arguments.--

Returns: () => void -- an unsubscribe function.

typescript
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 listening

Key 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:

ParameterTypeDescriptionDefault
valueTThe value to exclude from proxying.--

Returns: Ref<T> -- a { current: T } wrapper with an internal ref marker.

typescript
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 tracked

Key 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.current are 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.

typescript
interface Ref<T> {
  current: T;
  readonly [REF_MARKER]: true;
}

Snapshot<T>

Deep readonly type for proxy snapshots. Recursively applies readonly to all properties.

typescript
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:

tsx
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:

typescript
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:

typescript
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:

typescript
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:

  1. The set trap checks equality via Object.is -- if unchanged, no-op
  2. The raw target is updated directly
  3. The property's signal is updated via signal.set() inside a batch()
  4. The object's version counter increments
  5. All subscribe() listeners are notified synchronously
  6. For arrays, if setting an index extends length, the length signal 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:

typescript
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: useSnapshot tracks accessed properties automatically