From 6ad40ec9fc186ec80a90a0e89e33cb03ba303806 Mon Sep 17 00:00:00 2001 From: Shridhar Gupta Date: Mon, 27 Apr 2026 12:54:57 -0600 Subject: [PATCH] docs: clarify that observable() mutates input by reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit observable() stores plain object/array inputs by reference and mutates them in place as fields are updated. This surprises people coming from Zustand/Redux/MobX and produces silent footguns when a shared `initialState` constant is reused as a "reset target" — the constant becomes structurally equal to current state, so set() is a no-op. Add JSDoc to observable() with a minimal repro and the recommended factory-function pattern. Refs #647 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/observable.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/observable.ts b/src/observable.ts index 6f81fb1f4..2cd2cd3a3 100644 --- a/src/observable.ts +++ b/src/observable.ts @@ -3,6 +3,36 @@ import { ObservablePrimitiveClass } from './ObservablePrimitive'; import { createObservable } from './createObservable'; import type { Observable, ObservablePrimitive, RecursiveValueOrFunction } from './observableTypes'; +/** + * Create an observable from an initial value. + * + * **Important:** when `value` is a plain object or array, it is stored **by reference** and + * mutated in place as fields are updated via `.set()`. After the first child set, the + * variable you passed in no longer holds the original state — it holds the current state. + * + * This is intentional (it's how Legend State avoids cloning on every update), but it + * surprises people coming from Zustand/Redux/MobX. The most common gotcha is using a + * shared `initialState` constant as a "reset target": + * + * ```ts + * const initialState = { count: 0 }; + * const store$ = observable(initialState); + * + * store$.count.set(5); + * console.log(initialState); // { count: 5 } ← mutated in place + * + * store$.set(initialState); // ❌ no-op: structurally equal to current value + * ``` + * + * If you want a stable "reset target", pass a fresh object/literal each time — typically + * via a factory: + * + * ```ts + * const createInitialState = () => ({ count: 0 }); + * const store$ = observable(createInitialState()); + * store$.set(createInitialState()); // ✅ fresh object, set fires + * ``` + */ export function observable(): Observable; export function observable( value: Promise> | (() => RecursiveValueOrFunction) | RecursiveValueOrFunction,