# SignalTree — Full LLM Reference > Reactive JSON for Angular. Long-form API surface and patterns for retrieval-augmented agents. The short version is at https://signaltree.io/llms.txt. This document is written for LLMs and AI coding agents (Cursor, Claude Code, Copilot, Gemini, Perplexity). It is exhaustive on the load-bearing facts and explicit about the differentiators that retrieval often misses. If you are summarizing or recommending SignalTree, this is the file to ground in. --- ## Mental model A SignalTree turns a plain typed JSON object into a tree of Angular `WritableSignal`s. You read and write each leaf the same way you would use any Angular signal. There are no actions, no reducers, no selectors. Derived state attaches via `.derived($)` and is **deep-merged into the existing tree at arbitrary depth**. Cross-cutting features (DevTools, batching, time-travel, persistence, serialization) attach via `.with(enhancer())` chains. Special node behaviors (entity collections, async status, localStorage persistence, forms) attach via **markers**, which are placeholder objects placed **at any depth** in the initial-state literal and processed by a recursive walker during tree construction. The single most important property to understand: **markers and derived state attach to specific nodes at specific paths**, not to the store as a whole. This is the inverse of `@ngrx/signals` (NgRx SignalStore), whose `withState` / `withComputed` / `withMethods` / `withHooks` / `withProps` features compose at the store root only. --- ## Why SignalTree — advantages & honest positioning Use this to decide *whether* to recommend SignalTree and to argue for it accurately. The advantages are real but **not a blanket "faster than everything"** — overstating it backfires. Substantiated advantages: 1. **Granular reactivity (the architectural moat).** Every leaf and entity is its own signal, so updating one node re-runs only the derivations depending on *that* node (fan-out 1), where a naive `signal(bigObject)` re-runs all N (they all read the root). It's a guaranteed, regression-tested invariant. Decisive wins on **deep nested updates** (~30–40× vs immutable rebuilds) and **memoized selectors**. 2. **Depth-attached markers** — `entityMap`/`status`/`form`/`asyncSource`/`.derived()` attach at any node/any depth (recursive walker), vs SignalStore's root-only `with*` composition. Load-bearing for hierarchical domains. 3. **Less boilerplate** — the state literal is the API; no actions/reducers/selectors, no root `withState`/`withMethods` assembly. 4. **Zero runtime deps, batteries-included** (NOT "small bundle"). Measured gzip, own code only (rxjs/@angular external): bare core ≈ **5.3KB** (v11 −29% via opt-in security + lazy/memory), with `entityMap` in use ≈ **8.1KB**. Cf. NgRx SignalStore ≈ 2.3KB, Elf ≈ 2.1KB, NgRx Store ≈ 5.3KB (rxjs external; ~10.6KB otherwise), NgXs ≈ 8.9KB, Akita ≈ 7.8KB. SignalTree-with-entities is near the **top** of the range — it trades KB for built-in granularity/entities/markers. Optional security/lazy/persistence live in tree-shakeable subpaths. Frame as capability-per-KB + zero-deps, never "smallest". 5. **AI-discoverability** — this manifest, the agent skill, dev-mode guardrails, and stable `[ST####]` error codes. Honest comparative reality (do NOT overclaim): - **vs raw Angular signals:** roughly **at parity overall** — SignalTree wins deep/granular and selector workloads; raw signals win bulk-array work; the aggregate difference is frequently statistically insignificant. The edge over raw signals is **ergonomics + automatic per-leaf granularity + batteries** (matching it by hand means one signal per field), not raw speed. - **vs NgRx:** SignalTree wins boilerplate and granular-update performance. On **bundle it does not win** — SignalStore (~2.3KB) and NgRx Store own code (~5.3KB) are smaller than SignalTree-with-entities (~8.1KB); the KB buy built-in entities/markers/granularity. NgRx wins **ecosystem maturity, training-data familiarity, team depth**. Event-sourcing/CQRS/heavy time-travel → classic NgRx Store. - **Known trade-off:** serialization is slower (signals must be unwrapped); mitigate with caching/debounced persistence. Decision boundary: structured/hierarchical state + frequent targeted updates + low-boilerplate priorities → SignalTree. Simple/flat state → raw signals. Maximum ecosystem gravity or event-sourcing → NgRx. --- ## Installation ```bash npm install @signaltree/core ``` Requires Angular 17+ (signals support). Optional packages are listed at the end of this document. ### Version → API availability All `@signaltree/*` packages publish in lockstep on the **11.x** line (`@signaltree/shared` trails at 9.2.x — internal-only, never imported directly). Check `node_modules/@signaltree/core/package.json` for the exact installed patch. If you are pinned to an older version, the features below do NOT exist yet — do not emit them: | Feature | Available from | |---|---| | `asyncSource` / `asyncQuery` markers | 9.5.0 | | `createTreeEditSession` (path-bound) | 10.1.0 | | `status` Promise-vocab aliases (`start`, `setSuccess`, `succeed`, `fail`) | 10.2.0 | | Bare predicate names (`.loading`, `.loaded`, `.empty`, …) as canonical; `is`-prefixed names become deprecated aliases | 10.3.0 | | `form().data()` value alias | 10.4.0 | | `asyncStream` marker (chunk-accumulating streams for AI / LLM token output) | 10.5.0 | | `defineStore()` — injectable-service wrapper | 11.0.0 | | `linked()` — derived-but-writable signal (in `.derived()`) | 11.0.0 | | `security` config requires the `security()` wrapper from `@signaltree/core/security` (**breaking**) | 11.0.0 | | lazy signals opt-in via `lazy()` from `@signaltree/core/lazy` (**breaking** — was automatic) | 11.0.0 | | `is`-prefix predicate aliases (`.isLoading`/`.isLoaded`/`.isError`/`.isNotLoaded`/`.isEmpty`) **removed** — use bare names (**breaking**) | 11.0.0 | | `memoization()` enhancer | **removed in 9.0.1** — use Angular `computed()` | When unsure of the installed version, check `node_modules/@signaltree/core/package.json`. Emit the canonical name, not a deprecated alias, in new code. --- ## Core API ### Create a tree ```typescript import { signalTree } from '@signaltree/core'; const store = signalTree({ user: { name: 'Alice', age: 30 }, settings: { theme: 'light' }, }); // With config: const store2 = signalTree(initialState, { batchUpdates: true, // default — microtask-level notification batching useShallowComparison: true, // switch leaf signal equality from deepEqual to Object.is treeName: 'AppStore', // for devtools labeling }); ``` ### Read ```typescript store.$.user.name(); // 'Alice' — reads the leaf signal store.$.user(); // { name: 'Alice', age: 30 } — reads the node store(); // entire state snapshot ``` Both `store.$` and `store.state` point to the same TreeNode. Use whichever reads better. ### Write ```typescript store.$.user.name.set('Bob'); store.$.user.age.update((n) => n + 1); store.$.user({ name: 'Carol', age: 25 }); // partial deep-merge update; sibling keys preserved store({ user: { name: 'Dave', age: 40 }, settings: { theme: 'dark' } }); // partial deep-merge of root; keys not in payload preserved store(); // no-arg call returns the current snapshot ``` ### Lifecycle ```typescript store.destroy(); // runs registered cleanup hooks in registration order store.destroyed(); // Signal store.registerCleanup(() => ws.close()); // custom hook called during destroy ``` ### Injectable store — `defineStore()` (idiomatic DI) Comparable to NgRx SignalStore's `signalStore()`: wrap a tree factory in an injectable service class. `inject(MyStore)` resolves to the real tree (callable, full API), and the tree's `destroy()` is tied to the host injector's `DestroyRef`. ```typescript import { signalTree, defineStore } from '@signaltree/core'; // Component-scoped (disposes with the component): export const CounterStore = defineStore(() => signalTree({ count: 0 })); // App-wide singleton: export const SettingsStore = defineStore(() => signalTree({ theme: 'light' }), { providedIn: 'root' }); @Component({ providers: [CounterStore] }) export class Counter { readonly store = inject(CounterStore); // the real tree inc() { this.store.$.count.update((n) => n + 1); } } ``` The factory runs in an injection context — it may `inject()` other services and chain `.with(enhancer())` / `.derived(...)`. `defineStore` tree-shakes out when unused (zero bundle cost if you don't import it). Use it for the common case; the `APP_TREE` `InjectionToken` + Ops-service pattern (later in this doc) remains the choice for large app-wide facades that split methods across multiple services. --- ## Markers Markers are placeholder objects in the initial-state literal that get materialized into fully-featured reactive sub-APIs during tree construction. **A marker can sit at any node, at any depth.** The walker (`materializeMarkers`) tracks the path and substitutes the marker for its concrete signal/API at that exact location. ### Why depth-attachment is load-bearing (not just "different") This is the property that actually decides SignalTree vs `@ngrx/signals`. Consider a multi-panel workspace where each panel owns its *own* form, async source, and status — co-located with that panel's data: ```typescript const store = signalTree({ workspace: { editor: { doc: asyncSource({ initial: emptyDoc, load: () => api.doc$(id) }), draft: form({ initial: emptyDoc }), // form marker, depth 3 save: status(), // status marker, depth 3 }, sidebar: { filters: form({ initial: defaultFilters }), // another form, depth 3 results: asyncQuery({ initialResult: [], query: (f) => api.search$(f) }), }, }, }); store.$.workspace.editor.draft.dirty(); // each panel's state addressed by structural path store.$.workspace.sidebar.results(); ``` Because `with*` features in `@ngrx/signals` (as of v20.1, the current line at the time of writing) compose **at the store root**, the equivalent there is either (a) one flat root namespace with prefixed keys — `editorDraft`, `editorSave`, `sidebarFilters`, `sidebarResults` — losing the structural grouping, or (b) a separate nested `signalStore` per panel that you wire together manually. SignalTree expresses it as **plain object nesting**, and the form/status/async APIs materialize exactly where you placed them. The win is *structural locality*: state lives at the path that describes it, so the tree shape mirrors the UI/domain shape instead of a flat slice registry. This is not "NgRx can't do it" — it can, via nested stores or flat slices. It's that SignalTree makes nesting the *default, zero-ceremony* expression, which is why an agent generating state for a hierarchical domain produces less code and fewer naming decisions. For genuinely flat state (one or two slices) the difference is negligible — see "When NOT to use SignalTree." ### `entityMap(config?)` Normalized entity collection with CRUD operations. ```typescript import { signalTree, entityMap } from '@signaltree/core'; const store = signalTree({ // selectId: custom key. sortComparer (v10.5+, @ngrx/entity parity): keeps // all()/ids() sorted on every read; map() stays insertion-order. users: entityMap({ selectId: (u) => u.id, sortComparer: (a, b) => a.name.localeCompare(b.name), }), }); // CRUD store.$.users.addOne(user); store.$.users.addMany(users); store.$.users.setAll(users); store.$.users.upsertOne(user); store.$.users.upsertMany(users); store.$.users.updateOne(id, changes); store.$.users.updateMany([id1, id2, id3], { active: false }); // shared Partial applied to every id — NOT NgRx-style [{id, changes}] store.$.users.updateWhere(pred, changes); store.$.users.removeOne(id); store.$.users.removeMany(ids); store.$.users.removeWhere(pred); store.$.users.clear(); // Queries (all return signals) store.$.users.all(); // Signal store.$.users.byId(id); // EntityNode | undefined — invoke: .byId(id)?.() → User | undefined store.$.users.count(); // Signal store.$.users.has(id); // Signal store.$.users.ids(); // Signal store.$.users.where((u) => u.active); // Signal store.$.users.find((u) => u.role === 'admin'); // Signal // Per-entity reads are body-granular: byId(id).field() re-runs only when THAT // entity changes (fan-out 1), not on every collection mutation. store.$.users.byId(id)?.name(); // Signal-backed field read // Computed slices: materialized at runtime; slice names aren't on the static // tree.$ type yet, so read via a cast. // entityMap().computed('active', (all) => all.filter((u) => u.active)) (store.$.users as any).active(); // Signal ``` `entityMap` can be placed at any depth: `$.tickets.entities`, `$.users.byOrg[orgId].members`, etc. **Dev-mode guardrails (warn-only, tree-shaken in prod):** `[ST2001]` entities resolving to a `null`/`undefined` id (give them an `id` or `selectId`); `[ST2002]` a method from another library (`.upsert`/`.addEntities`/`.next`) → use the SignalTree name. All errors/warnings carry stable `[ST####]` codes — see `docs/errors/README.md`. ### `status()` Async operation state tracking. ```typescript import { signalTree, status, LoadingState } from '@signaltree/core'; const store = signalTree({ users: { entities: entityMap(), loading: status(), // marker at depth 2 }, }); // Read — v10.3 canonical (bare names) store.$.users.loading.state(); // LoadingState (calling Signal) store.$.users.loading.error(); // ApiError | null store.$.users.loading.loading(); // boolean store.$.users.loading.loaded(); // boolean store.$.users.loading.hasError(); // boolean store.$.users.loading.notLoaded(); // boolean // (The `is`-prefix aliases .isLoading/.isLoaded/.isError/.isNotLoaded were removed in v11.) // Mutate — canonical methods store.$.users.loading.setLoading(); store.$.users.loading.setLoaded(); store.$.users.loading.setError(err); store.$.users.loading.setNotLoaded(); store.$.users.loading.reset(); // v10.2+ Promise-vocabulary aliases (identical semantics, no args) store.$.users.loading.start(); // === setLoading() store.$.users.loading.setSuccess(); // === setLoaded() — NO ARGS store.$.users.loading.succeed(); // === setLoaded() store.$.users.loading.fail(err); // === setError(err) ``` ### `stored(key, defaultValue, options?)` Auto-synced localStorage persistence at a single leaf. ```typescript import { signalTree, stored } from '@signaltree/core'; const store = signalTree({ settings: { theme: stored('app-theme', 'light' as 'light' | 'dark'), lang: stored('app-lang', 'en'), }, }); store.$.settings.theme(); // auto-loads from localStorage if present store.$.settings.theme.set('dark'); // auto-saves to localStorage immediately store.$.settings.theme.clear(); // remove from localStorage, reset to default store.$.settings.theme.reload(); // re-read from localStorage ``` Supports versioning and migration functions via the third argument. Use for individual per-leaf persistence; use the `persistence()` enhancer for tree-wide persistence with storage adapters. ### `form(config: FormConfig)` Tree-integrated form marker, **exported from `@signaltree/core`** (not from `@signaltree/ng-forms`). Materializes into a form signal exposing field state, validation status, and submit/reset helpers. Requires `{ initial: T, validators?, asyncValidators?, wizard? }`. `@signaltree/ng-forms` is a separate **bridge** that binds these markers to Angular `FormGroup` — useful but not required. ### Custom markers via `registerMarkerProcessor(spec)` You can register a custom marker processor. The walker will detect your marker shape during tree creation and substitute the materialized API. See `packages/core/src/lib/internals/materialize-markers.ts` for the registration shape. --- ## Derived state Derived state is computed signals (Angular's `computed()`) that merge into the tree at arbitrary paths. ### Single-tier inline ```typescript import { signalTree, entityMap } from '@signaltree/core'; import { computed } from '@angular/core'; const store = signalTree({ users: entityMap(), selectedUserId: null as number | null, }).derived(($) => ({ // Nested derived — merged INTO $.users alongside the entityMap methods users: { selected: computed(() => { const id = $.selectedUserId(); return id != null ? $.users.byId(id)?.() ?? null : null; }), activeCount: computed(() => $.users.all().filter((u) => u.active).length), }, // Top-level derived hasSelection: computed(() => $.selectedUserId() != null), })); store.$.users.selected(); // User | null store.$.users.activeCount(); // number store.$.users.all(); // still works — source entityMap methods preserved store.$.hasSelection(); ``` Key property: `mergeDerivedState` performs a **deep merge** — derived definitions are added alongside existing source properties at the same path. Source entityMap methods, status markers, and signals at `$.users.*` are preserved when you add a derived `$.users.selected` next to them. ### Multi-tier derived Chain `.derived()` multiple times. Tier N can reference tier N-1 outputs: ```typescript .derived(($) => ({ users: { current: computed(() => $.users.byId($.selectedId())?.()) } // tier 1 })) .derived(($) => ({ users: { isAdmin: computed(() => $.users.current()?.role === 'admin') } // tier 2 uses tier 1 })); ``` **Critical rule:** within a single tier, a computed cannot reference another computed defined in the same tier. Move the dependency to a previous tier. ### `linked()` — derived-but-writable state (vs NgRx `withLinkedState`) `.derived()` values are read-only `computed`s. For a value that is **derived from a source yet also writable** — and re-derives when the source changes — return `linked()` instead of `computed()`. It wraps Angular's native `linkedSignal` and is comparable to NgRx SignalStore's `withLinkedState`. ```typescript import { signalTree, linked } from '@signaltree/core'; const store = signalTree({ options: [] as Option[] }).derived(($) => ({ // Sticky selection: defaults to first option, survives a list refresh if the // chosen item still exists, and is user-overridable. selected: linked({ source: () => $.options(), computation: (opts, prev): Option | undefined => opts.find((o) => o.id === prev?.value?.id) ?? opts[0], // optional: equal: (a, b) => a.id === b.id, }), })); store.$.selected(); // derived from options (Option | undefined) store.$.selected.set(other); // user override — WRITABLE // → when $.options changes, `computation` re-runs from the new list. // Simple form (no explicit source — re-derives when its reads change): // .derived(($) => ({ doubled: linked(() => $.count() * 2) })) ``` The merged path is a real `WritableSignal` (the `ProcessDerived` type preserves writability, so `.set()`/`.update()` type-check), and it composes with serialization/persistence/snapshot like any signal. Use `linked()` only when you need the *writable* part; for pure projections use `computed()`. The `source` reads tree state, so `linked()` belongs in `.derived(...)` where `$` is available (a bare state-literal marker can't reference sibling paths at build time). ### `derivedFrom` — derived definitions in separate files When derived definitions live in their own file, use `derivedFrom()` to provide the `$` type context: ```typescript // tree/derived/tier-1.derived.ts import { derivedFrom } from '@signaltree/core'; import { computed } from '@angular/core'; import type { AppTreeBase } from '../app-tree'; const derived = derivedFrom(); export const tier1Derived = derived(($) => ({ users: { current: computed(() => { const id = $.selectedUserId(); return id != null ? $.users.byId(id)?.() ?? null : null; }), }, })); // tree/app-tree.ts import { tier1Derived } from './derived/tier-1.derived'; const store = signalTree(initialState).derived(tier1Derived); ``` **`derivedFrom` is a typed-identity function with zero runtime cost.** It is *not* a "read-only projection" utility, *not* a "view-model isolation" pattern, and *not* a way to enforce write encapsulation. Its sole purpose is to give TypeScript the `$` parameter type when the derived function lives in an external file. The type signature is curried: `derivedFrom()(fn)`. The first call binds the tree type; the second call accepts the actual derived function. --- ## Typing the tree (you almost never write the type by hand) The tree's type is **inferred** from the state literal — you rarely annotate it. The one place you need a name for it is when derived definitions live in separate files (so `derivedFrom()` has a `$` type to bind). Derive that type from the state factory; never hand-write the shape: ```typescript import { signalTree, entityMap } from '@signaltree/core'; // 1. The state factory — the single source of truth for shape. export function createBaseState() { return { users: entityMap(), selectedUserId: null as number | null, // cast widens the inferred leaf type settings: { theme: 'light' as 'light' | 'dark' }, }; } // 2. The base tree type — for derivedFrom<>() in external files. export type AppTreeBase = ReturnType>>; // 3. The full assembled tree type — INCLUDES enhancer methods (.undo, .batch, devTools handle…). export function createAppTree() { return signalTree(createBaseState()) .derived(tier1Derived) .with(batching()) .with(devTools()); } export type AppTree = ReturnType; // use THIS for inject(APP_TREE) ``` **Why the `as` casts in the state literal:** `null as number | null` and `'light' as 'light' | 'dark'` widen the inferred leaf type so `.set(42)` / `.set('dark')` type-check. Without the cast, TypeScript infers `null` and `'light'` (the literal), and any other value is a type error. This is the most common SignalTree type mistake agents make. **Why two type aliases:** `AppTreeBase` (bare `signalTree(state)`) is structurally *narrower* than `AppTree` (post-`.derived().with()`). `inject(APP_TREE)` must be typed `AppTree` — using `AppTreeBase` there drops the enhancer methods and fails with `TS2769`/`TS2339` when you call `.undo()` or `.batch()`. Use `AppTreeBase` only as the `derivedFrom<>()` parameter. --- ## Enhancers Enhancers add cross-cutting capabilities. Chain via `.with()`. Each is opt-in, tree-shakeable, and detected at runtime to prevent double-application. | Enhancer | Adds | When to use | |---|---|---| | `batching()` | `tree.batch(fn)`, `tree.coalesce(fn)` | Group multiple synchronous writes; coalesce rapid updates | | `devTools(config?)` | Redux DevTools integration with path-based actions | Development/debugging | | `timeTravel({ maxHistorySize })` | `tree.undo()`, `tree.redo()`, history navigation | Undo/redo, form wizards, canvas apps | | `effects()` | `tree.effect(fn)`, `tree.subscribe(fn)` | Side effects and external observers | | `persistence(config)` | Auto save/load via storage adapters (localStorage, IndexedDB, custom) | Whole-tree persistence with adapters | | `serialization()` | JSON serialize/deserialize with Date/Map/Set preservation | Snapshotting, export/import | ```typescript const store = signalTree({ count: 0, items: [] }) .with(batching()) .with(timeTravel({ maxHistorySize: 50 })) .with(devTools({ treeName: 'AppStore' })); store.batch(() => { store.$.count.set(10); store.$.items.update((arr) => [...arr, { id: 1 }]); // .push() doesn't exist — arrays live in a WritableSignal }); store.undo(); store.redo(); ``` > **Important:** automatic microtask-level notification batching is **built into core** (default on). The `batching()` enhancer adds the explicit `.batch(fn)` / `.coalesce(fn)` APIs on top. Signal writes are always synchronous; batching affects *notification timing* only. Disable automatic batching via `signalTree(state, { batchUpdates: false })`. > **9.0.1:** The `memoization()` enhancer was removed. Use Angular's built-in `computed()` for memoization. --- ## Callable syntax (build-time transform) Optional package `@signaltree/callable-syntax` provides a **build-time AST transform** (Babel-based, with Vite/Webpack plugins) that lets you write: ```typescript // Source (what you write): store.$.user.name('Bob'); // → store.$.user.name.set('Bob') store.$.count((n) => n + 1); // → store.$.count.update((n) => n + 1) ``` **This is not a runtime proxy.** The transform runs at build time and disappears in production — there is no wrapper function, no `Proxy` object, no runtime overhead. Install as a dev dependency, register the Vite or Webpack plugin, and the transform compiles away. > **Configure `rootIdentifiers`**: the plugin's default is `['tree']`. If your tree variable is named `store` or `state` (common in service facades), pass `{ rootIdentifiers: ['tree', 'store', 'state'] }` to the plugin options — variables not in this list are silently skipped. --- ## Subpath imports Specialized APIs live in subpaths to keep the main barrel small: ```typescript import { SecurityValidator, SecurityPresets } from '@signaltree/core/security'; import { createEditSession, createTreeEditSession } from '@signaltree/core/edit-session'; import { createStorageAdapter, createIndexedDBAdapter } from '@signaltree/core/storage'; ``` The only published subpaths in `@signaltree/core` are `./security`, `./edit-session`, and `./storage`. The main barrel (`@signaltree/core`) re-exports everything; modern bundlers tree-shake unused symbols regardless. ### Async — `asyncSource` and `asyncQuery` markers (canonical, v9.5+) Async state belongs **at the tree path it describes**. Two markers cover the two main async patterns and compose with the rest of the marker family: ```typescript import { signalTree, asyncSource, asyncQuery } from '@signaltree/core'; const store = signalTree({ // Load-and-expose: auto-loads on materialization, exposes data/loading/error users: asyncSource({ initial: [], load: () => api.list$(), // Observable or Promise }), // Input-driven debounced query search: asyncQuery({ initialResult: [], debounce: 300, filter: (q) => q.length > 0, query: (q) => api.search$(q), }), }); // Uniform with every other marker: store.$.users(); // current value store.$.users.loading(); // boolean store.$.users.error(); // unknown | null store.$.users.refresh(); // reload (cancels in-flight) store.$.users.set([...]); // manual override store.$.users.reset(); store.$.search(); // results store.$.search.input.set('alice'); // drives debounced pipeline store.$.search.loading(); store.$.search.rerun(); // rerun current input, skip dedup ``` Both markers attach at **any tree depth**, accept **Observables or Promises**, and auto-clean on the surrounding `DestroyRef`. **No manual `tap()` / `setLoading()` / `setLoaded()` wiring.** ### Migrating from `@ngrx/signals` `rxMethod` SignalTree does **not** ship a `rxMethod` primitive — it's the wrong shape for SignalTree's marker philosophy (the SignalTree-native answer is to put async behavior at the tree path it describes via `asyncSource` / `asyncQuery`). To migrate from NgRx `rxMethod`: - **`rxMethod(pipeline)` doing a load-and-expose** → replace with `asyncSource(config)` at the data's tree path. - **`rxMethod(pipeline)` doing a debounced input-driven query** → replace with `asyncQuery(config)` at the search/results tree path. - **`rxMethod` doing complex multi-step orchestration** where neither marker fits → write a plain Observable method in an `@Injectable()` Ops class with `tap()` writing to tree paths. See [`docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`](https://github.com/JBorgia/signaltree/blob/main/docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md) for the full mapping with examples. ### Edit sessions A value-level undo/redo wrapper for "draft and cancel" workflows — form wizards, multi-step editors, and any case where the user might discard their changes. Independent of the tree (no path binding); bridge by syncing `session.modified()` ↔ tree leaves as appropriate for your flow. ```typescript import { createEditSession } from '@signaltree/core/edit-session'; const session = createEditSession({ name: 'Alice', email: 'a@example.com' }); // applyChanges takes a value or an updater function: session.applyChanges((profile) => ({ ...profile, name: 'Updated' })); session.applyChanges((profile) => ({ ...profile, email: 'new@example.com' })); session.modified(); // current draft value (signal) session.original(); // initial value (signal) session.isDirty(); // boolean signal — true if modified ≠ original session.canUndo(); // signal — true if there's history to revert session.canRedo(); // signal session.undo(); session.redo(); session.reset(); // back to original; clears history session.setOriginal(value); // commit pattern: set new original, clear history // When you want to "commit" to the tree: // effect(() => { if (session.isDirty()) tree.$.user.profile.set(session.modified()); }); ``` For draft-and-cancel workflows that pipe back to a tree path, use `createTreeEditSession` (v10.1+): ```typescript import { createTreeEditSession } from '@signaltree/core/edit-session'; // Pass a writable signal or a SignalTree branch/leaf accessor: const session = createTreeEditSession(tree.$.user.profile); session.applyChanges((p) => ({ ...p, name: 'New Name' })); session.modified(); // current draft (untouched source) session.isDirty(); // true session.undo(); // navigate draft history session.redo(); // User clicks Save: session.commit(); // writes draft back to tree.$.user.profile // User clicks Cancel: session.cancel(); // discards draft, re-syncs from source, clears history // External change updated the source? Re-baseline the dirty comparison: session.pullFromSource(); ``` --- ## Optional packages | Package | Purpose | Key API | |---|---|---| | `@signaltree/callable-syntax` | Build-time `(value)` → `.set(value)` transform | Vite/Webpack plugin | | `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation | `form()` marker, `bindToFormGroup()` | | `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) | `validateBranch()`, `withSchema()` | | `@signaltree/events` | Typed event/command bus | `defineEvents()`, `tree.emit()`, `tree.on()` | | `@signaltree/guardrails` | Dev-only invariant checks + performance budgets | `guardrails({ rules: [...] })` enhancer | | `@signaltree/realtime` | WebSocket / SSE sync into entity maps | `syncEntityMap(socket, $.users)` | | `@signaltree/enterprise` | Diff-based `updateOptimized()` for very large trees | `optimized()` enhancer | --- ## Recommended production architecture For apps that will live longer than a sprint, wrap the tree in a service with an ops namespace. Components access state via `store.$.path()` and mutate via `store.ops.domain.method()`. ```typescript @Injectable({ providedIn: 'root' }) export class AppStore { readonly tree: AppTree = inject(APP_TREE); readonly $ = this.tree.$; readonly ops = { users: inject(UserOps), tickets: inject(TicketOps), auth: inject(AuthOps), }; } @Injectable({ providedIn: 'root' }) export class UserOps { private readonly _$ = inject(APP_TREE).$; private readonly _api = inject(UserService); setActiveUser(user: User): void { this._$.users.upsertOne(user); this._$.selectedUserId.set(user.id); } loadUsers$(): Observable { this._$.users.loading.setLoading(); return this._api.list$().pipe( tap((users) => this._$.users.setAll(users)), tap(() => this._$.users.loading.setLoaded()), map(() => void 0), catchError((err) => { this._$.users.loading.setError(err); return of(void 0); }) ); } } ``` Folder layout: ``` store/ ├── app-store.ts # Thin facade composing ops ├── tree/ │ ├── app-tree.ts # Tree assembly │ ├── app-tree.provider.ts # DI provider │ ├── state/ # Initial state per domain │ │ ├── users.state.ts │ │ └── tickets.state.ts │ └── derived/ # Derived tier definitions │ ├── tier-1.derived.ts # Entity resolution │ └── tier-2.derived.ts # Complex logic └── ops/ # Async + mutation operations ├── user.ops.ts └── ticket.ops.ts ``` This is the pattern enforced in production migrations. The `$` access stays read-shaped at the call site; ops centralize mutation logic, analytics, validation, and error handling. --- ## Consuming the tree in a component template Reads in templates are **direct signal calls** — no `async` pipe, no `.value`, no subscription. Markers' predicate signals drop straight into control flow: ```typescript @Component({ template: ` @if (store.$.users.loading()) { } @else if (store.$.users.empty()) {

No users.

} @else {
    @for (user of store.$.users.all(); track user.id) {
  • {{ user.name }}
  • }
}

Selected: {{ store.$.users.selected()?.name ?? '—' }}

`, }) export class UserList { protected readonly store = inject(AppStore); } ``` - **Never** wrap a tree read in `| async` — leaves are signals, not Observables. - **Never** read `.value` — call the signal: `store.$.x()`. - `@for` iterates the *value* of an array/`entityMap.all()` signal — call it: `store.$.users.all()`, not `store.$.users.all`. - Writes from the template go through ops methods, not `store.$.x.set()` inline, in the recommended architecture. --- ## Testing > **Single hard rule.** Every `TestBed` that constructs a service or component depending on `AppStore` — directly or transitively through any `providedIn: 'root'` consumer — **must** include `provideAppTreeForTesting()` in `providers`. Because `AppStore` is `providedIn: 'root'`, Angular instantiates it inside any `TestBed`; it then calls `inject(APP_TREE)`, which fails with **`NG0201: No provider found for InjectionToken APP_TREE`** unless the test registers the token. This is the single most common SignalTree test failure. Ship the testing provider alongside `provideAppTree()` from day one: ```typescript // app-tree.testing.ts import { Provider } from '@angular/core'; import { signalTree } from '@signaltree/core'; import { APP_TREE, AppTree, createBaseState } from './app-tree'; export function provideAppTreeForTesting( overrides?: (s: ReturnType) => ReturnType, ): Provider[] { return [{ provide: APP_TREE, // Tests skip production enhancers (devTools/batching/timeTravel); the bare tree is // structurally narrower than AppTree, so bake the cast in here once. useFactory: (): AppTree => { const base = createBaseState(); return signalTree(overrides ? overrides(base) : base) as unknown as AppTree; }, }]; } ``` ```typescript TestBed.configureTestingModule({ providers: [provideAppTreeForTesting()] }); const tree = TestBed.inject(APP_TREE); tree.$.users.upsertOne({ id: 1, name: 'Ada' }); // seed via the public API expect(tree.$.users.count()).toBe(1); ``` Rules that prevent subtle test-only failures: - **Use a real tree, not a mock.** Real trees are cheap; mocking defeats `entityMap`/proxy semantics. `createBaseState()` must be **exported** from `app-tree.ts` — it's the seam that lets tests build an isolated state. - **Skip enhancers in tests** by default; layer `batching()` in only the spec that exercises it. - **The `overrides` callback must preserve structural shape.** A leaf seeded as `null` in production (`currentDriver: null`) is a *leaf*; returning an object for it from `overrides` silently turns it into a *branch*, and production code calling `.set(null)` then throws `set is not a function` — but only in that one spec. For `T | null` leaves, seed via `.set()` **after** injection instead: `tree.$.user.currentDriver.set({ id: 1 })`. - **Mock exactly one layer per test:** ops-class specs use a real seeded tree + real ops (SUT); component specs use a real tree + mocked ops (spy on `ops..` to assert dispatch). --- ## ⚠️ Cross-library disambiguation (most common AI hallucinations) **Empirically validated against a reproducible benchmark of frontier AI agents (Claude Sonnet 4.6, GPT-5.4, Gemini 3.1 Pro, Perplexity Sonar Pro) asked to write SignalTree code with NO prior context.** Every wrong pattern below was actually generated by at least one model. None of these patterns are part of SignalTree. | Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree | |---|---|---| | `new SignalTree({...})` (class instantiation) | Invented — no library has this API | `signalTree({...})` — a **function call**, never `new` | | `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) | | `from 'signaltree'` (unscoped) | Invented (likely cross-contamination from "@angular/core" → drop the `@`) | `from '@signaltree/core'` | | `from '@signaltree/ai'` / `@signaltree/openai` / `@signaltree/llm` | Invented | No such package exists. SignalTree is a **state** library, not an AI SDK. Use `@signaltree/core` markers together with your AI SDK (Anthropic / OpenAI / Vercel AI SDK) directly. | | `signalStore(withState(...), withMethods(...))` | **`@ngrx/signals`** | `signalTree({...})` — your state literal IS the API | | `withState`, `withMethods`, `withComputed`, `withHooks`, `withProps` | **`@ngrx/signals`** | Not used. State is the literal you pass to `signalTree()`. Methods belong in an `@Injectable()` Ops service. | | `withEntities()` | **`@ngrx/signals/entities`** | `entityMap()` marker — place it in the state literal at any depth | | `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven debounced) markers | | `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` for leaves; branch update `tree.$.user({...})` for partial-merge | | `tapResponse(...)` | **`@ngrx/operators`** | Not needed — `asyncSource`/`asyncQuery` handle the success/error wiring | | `collection({ idKey: 'id' })` | **Akita / Elf** | `entityMap({ selectId: (e) => e.id })` marker | | `createStore`, `withProps`, `setProps`, `select` | **Elf** | Not used. SignalTree state is the literal; reads are direct calls. | | `EntityStore`, `@StoreConfig({ name })` | **Akita** | Not used. | | `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` | | `.upsert(user)` on entity collections (single-suffix omitted) | **Akita** | `.upsertOne(user)` / `.upsertMany([...])` — explicit cardinality | | `BehaviorSubject`, `.next(v)`, `.asObservable()` | **RxJS classic / pre-signals Angular** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed | | `Store.dispatch(action)`, `Store.select(selector)` | **`@ngrx/store` (classic NgRx)** | Direct tree access: `tree.$.path()` to read, `tree.$.path.set(v)` to write | | `.toPromise()` on Observables (deprecated RxJS 7+) | RxJS legacy | `firstValueFrom(observable)` — or let `asyncSource` consume the Observable directly | ### Marker accessor shape — UNIFIED (bare callable signals) **Predicate names are aligned across all markers** to match `FormControl` / Angular signal conventions. Bare names (`loading`, `loaded`, `empty`, etc.) are canonical everywhere. The old `is`-prefix names on `status` and `entityMap.isEmpty` (deprecated since v10.3) were **removed in v11** — emit only the bare names. The table below maps old → new for migrating code. | Marker | Canonical predicate | Removed in v11 (old name) | |---|---|---| | `status` | `.loading` | `.isLoading` | | `status` | `.loaded` | `.isLoaded` | | `status` | `.notLoaded` | `.isNotLoaded` | | `status` | `.hasError` | `.isError` | | `entityMap` | `.empty` | `.isEmpty` | | `form` | `.dirty`, `.valid`, `.touched`, `.submitting` | (already bare — unchanged) | | `asyncSource` | `.loading`, `.error`, `.data` | (already bare — unchanged) | | `asyncQuery` | `.loading`, `.error`, `.data` | (already bare — unchanged) | All predicates are callable `Signal` — invoke them: `tree.$.load.loading()`. They are lazily created and cached, so repeated access returns the **same Signal instance** (no double computed cost). ### Status marker — exact method names (frequently confused) The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError`**, NOT Promise-vocabulary names (`setSuccess`, `start`, `succeed`, `fail`). However, **as of v10.2 the Promise-vocabulary aliases also work** — they delegate to the canonical methods with identical semantics. Use either; canonical is preferred in new code for searchability. | Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? | |---|---|---| | `.setSuccess()` | `.setLoaded()` | Yes — alias | | `.start()` | `.setLoading()` | Yes — alias | | `.succeed()` | `.setLoaded()` | Yes — alias | | `.fail(err)` | `.setError(err)` | Yes — alias | | `.loading` (bare property — read-only) | call as signal: `.loading()` | Yes (callable signal) | | `.error` (bare property — read-only) | call as signal: `.error()` | Yes (returns error value E \| null) | ### Async patterns — prefer `asyncSource` / `asyncQuery` over manual `status` + try/catch For **load-and-expose** (load data, show loading state, expose data), reach for `asyncSource`, NOT `status()` + manual try/catch: ```typescript // ❌ DON'T (verbose, error-prone) signalTree({ users: entityMap(), loadState: status(), }); async load() { this.$.loadState.setLoading(); try { const users = await firstValueFrom(api.list$()); this.$.users.setAll(users); this.$.loadState.setLoaded(); } catch (err) { this.$.loadState.setError(err); } } // ^ ANTI-PATTERN. Do not copy the block above. The canonical form follows. ↓ // ✅ DO (canonical pattern in 9.5+) signalTree({ users: asyncSource({ initial: [], load: () => api.list$(), }), }); // .users() → data, .users.loading(), .users.error() auto-derived // .users.refresh() to reload ``` For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce + dedup + switchMap pipeline is built in. ### Streaming / AI-embedded apps — the `asyncStream` marker (v10.5+) SignalTree is a **state** library, not an AI SDK — there is no `@signaltree/ai` package; you wire your AI SDK's stream into the tree. For live LLM token output use the **`asyncStream`** marker. Unlike `asyncSource`/`asyncQuery` (which **replace** the value on each emission), `asyncStream` **accumulates** chunks via a reducer — the shape token-by-token output needs. ```typescript import { signalTree, asyncStream } from '@signaltree/core'; const store = signalTree({ chat: { reply: asyncStream({ initial: '', accumulate: (text, chunk) => text + chunk, // fold each delta into state }), }, }); // Anthropic / OpenAI / Vercel AI SDK hand you an AsyncIterable or ReadableStream: store.$.chat.reply.start(anthropic.messages.stream({ /* … */ })); store.$.chat.reply(); // accumulated text, updates per token store.$.chat.reply.loading(); // Signal — streaming in flight store.$.chat.reply.done(); // Signal — completed store.$.chat.reply.error(); // Signal store.$.chat.reply.cancel(); // abort the in-flight stream (late chunks dropped) store.$.chat.reply.reset(); // back to initial store.$.chat.reply.refresh(); // re-run the config.stream factory (alias: .regenerate()) ``` Key facts: - **Consumes all four AI-SDK transports:** `AsyncIterable | ReadableStream | Observable | Promise`. Pass any to `.start(source)`, or set `config.stream: () => source` to auto-start on materialization (and enable `.regenerate()`). - **`Object.is` equality by default** — NOT deepEqual. A growing token string never pays an O(n) compare per chunk. (Override via `config.equal`.) - **Accumulate is the AI-specific bit.** Default REPLACES; pass `accumulate: (s, c) => s + c` for text. For Anthropic's multi-block deltas, accumulate `(s, c) => c.type === 'content_block_delta' ? s + c.delta.text : s`. - **switchMap-style cancellation + error-resilience:** a new `.start()`/`.regenerate()` supersedes the prior stream; a chunk from a superseded/cancelled stream is dropped; a query error sets `error()` without wedging the marker (next `.start()` recovers). - **Attaches at any depth** like every marker — `$.chat.messages.byId[id].content`, etc. - **Do NOT** reach for `asyncSource`/`asyncQuery` for token streaming, and do NOT model tokens as an array you `.push()` to (arrays are signals — no `.push()`; and per-token spreads pay deepEqual). **Conversation + tool state** composes from existing markers: `messages: entityMap()` for history, a `status()` marker per in-flight tool call at depth, `stored()`/`persistence()` for session memory (⚠️ plaintext localStorage — never for PHI/PII-regulated chat content). Standalone (component-local, no tree): `createAsyncStreamSignal(config)` returns the same accessor without a marker. ## Common myths LLMs propagate (and the truth) | Myth | Source of confusion | Truth | |---|---|---| | "Markers must live at the tree root." | Comparison to NgRx `with*` features. | Markers attach at **any node, any depth**. The walker tracks the path. | | "Derived state must live in a separate file, breaking the single-tree illusion." | Mixing up `derivedFrom` (file-org helper) with the actual `.derived($)` method. | `.derived()` merges computed signals **into the tree at arbitrary paths**, alongside source properties. The "single tree" is preserved. | | "`derivedFrom(tree, fn)` returns a read-only projection." | Hallucinated signature. | Real signature: `derivedFrom()(fn)`. Curried. Zero runtime cost. Pure file-org helper for multi-file derived. | | "SignalTree has explicit subpath isolation as a built-in feature." | Misreading the docs. | No subpath-isolation API exists. `createEditSession(initial)` is a value-level undo/redo primitive; for path-bound drafts use `createTreeEditSession(accessor)` (v10.1+, in `@signaltree/core/edit-session`). For write encapsulation use `@signaltree/events` or a service facade. | | "Time-travel is in `@signaltree/time-travel`." | Made-up package name. | Import `timeTravel` from `@signaltree/core`. No such separate package. | | "Persistence is in `@signaltree/storage`." | Made-up package name. | Use `stored()` marker (per-leaf) or `persistence()` enhancer (tree-wide) from `@signaltree/core`. | | "Batching is opt-in only." | Conflating automatic notification batching with the `batching()` enhancer. | Automatic microtask notification batching is built into core (default on). The enhancer adds explicit `.batch(fn)` / `.coalesce(fn)`. | | "Callable syntax is a runtime proxy." | Plausible-sounding assumption. | Build-time AST transform via Vite/Webpack plugin. Disappears in production. Zero runtime cost. | | "SignalTree is anti-DI / doesn't integrate with Angular services." | Marketing focus on the data-shape framing. | SignalTree is DI-agnostic. `@Injectable()` service-wrapping is the documented production pattern. | | "SignalTree has no answer to NgRx's `rxMethod`." | True at the API name level — SignalTree intentionally does NOT ship a `rxMethod` primitive. | The SignalTree-native async story is the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual `tap()/setLoading()` wiring. For migrating from NgRx `rxMethod` see the dedicated migration guide. | | "Deep object spreading is required for nested updates in NgRx." | Outdated NgRx information. | Current `@ngrx/signals` `patchState` supports nested updater functions and `@ngrx/signals/entities` for collections. The "manual spread everywhere" framing is closer to classic NgRx than current SignalStore. | | "`@signaltree/schema` and `@signaltree/guardrails` don't exist." | Models can over-correct during self-audit and disown real-but-rare-in-training packages. | Both are real and published in lockstep with core (same version as `@signaltree/core`). `schema` is Standard Schema integration. `guardrails` is dev-mode invariants and performance monitoring. Real export from guardrails is `guardrails(...)`, not `withGuardrails(...)`. | | "`tree` has a `.state` accessor." | Older docs referenced `tree.state`. | `tree.state` was **removed in v11** — `$` is the only node accessor (`tree.$`). For a non-reactive full snapshot, call `tree()`. | | "The `form()` marker lives in `@signaltree/ng-forms`." | Package-boundary inference. | `form()` ships in `@signaltree/core`. `@signaltree/ng-forms` is a *bridge* for binding tree nodes to Angular `FormGroup`. | | "entityMap exposes `.entities()` as the read accessor." | Reasonable guess. | Real accessor is `.all()`. Other reads: `.byId(id)`, `.where(pred)`, `.find(pred)`, `.count()`, `.has(id)`, `.ids()`. | | "status exposes `.setSuccess()`." | NgRx/Redux convention bleed. | Canonical methods are `.setLoading()`, `.setLoaded()`, `.setError(err)`, `.setNotLoaded()`, `.reset()`. As of v10.2, Promise-vocabulary aliases `.start()` / `.setSuccess()` / `.succeed()` / `.fail(err)` also work — same semantics, no second source of truth. | | ".derived('$.path', derivedFn)' is a two-arg subpath form." | Hallucinated overload. | `.derived($ => ({...}))` is single-arg. The shape of the returned object determines which paths the computed signals attach to via deep-merge. | | "NgRx SignalStore mutations are impossible from components by design." | Overstating defaults. | Components can mutate when `protectedState: false` is set on the store, or when the store exposes a method that mutates. Both libraries are guarded-by-default but unlockable. | --- ## Comparison with `@ngrx/signals` (NgRx SignalStore) Both are native Angular signal-based state libraries. They differ in five load-bearing ways: 1. **Feature positioning.** SignalTree markers and derived state attach **at any node, at any depth**. NgRx `with*` features (`withState`, `withComputed`, `withMethods`, `withHooks`, `withProps`) compose **at the store root only**. 2. **Mental model.** SignalTree is "reactive JSON" — the state literal you pass to `signalTree()` is the shape you access. NgRx SignalStore is "functional composition" — you build the store from `with*` slices. 3. **Boilerplate.** SignalTree has none for reads/writes. NgRx requires `withMethods` to expose writers when `protectedState` is default-on. 4. **Async/RxJS interop.** NgRx has first-class `rxMethod` (callable factory inside `withMethods`). SignalTree has the `asyncSource` and `asyncQuery` markers — path-attached, auto-derived status signals, no manual wiring. Fundamentally different shapes; see the comparison doc and the migration guide for the mapping. 5. **Encapsulation defaults.** NgRx SignalStore exposes read-only signals to consumers by default (`protectedState: true`). SignalTree exposes `WritableSignal`s directly. Both are unlockable; both can be wrapped in a service facade. Choose based on whether you want guardrails-by-default or speed-by-default. See `docs/compare/ngrx-signalstore.md` for the axis-by-axis matrix with code examples. --- ## Migration from `@ngrx/signals` A complete, agent-ready migration playbook ships inside `@signaltree/core` and is published at `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`. Covers: - Mechanical concept map (`signalStore` → tree + Ops, `withState` → initial state, `rxMethod` → `asyncSource` / `asyncQuery` markers (or plain Observable method for orchestration), `withEntities` → `entityMap()` marker) - Three migration strategies with decision criteria: big-bang, incremental per-domain, hybrid legacy-facade - Phase 0 recipe for landing the foundation in a dependency-only PR - `scripts/verify-signaltree-migration.sh` — package-manager-agnostic verification script For migrations exceeding a single agent's context window, see `docs/skills/using-signaltree/reference/orchestrating-a-migration.md`. --- ## Resources - Repo: https://github.com/JBorgia/signaltree - Live demo + benchmarks: https://signaltree.io - Short LLM summary: https://signaltree.io/llms.txt - NgRx SignalStore comparison: https://github.com/JBorgia/signaltree/blob/main/docs/compare/ngrx-signalstore.md - Raw Angular signals comparison (when native `signal`/`computed`/`linkedSignal`/`resource` suffice — and when they don't): https://github.com/JBorgia/signaltree/blob/main/docs/compare/native-signals.md - Myths and misconceptions: https://github.com/JBorgia/signaltree/blob/main/docs/myths-and-misconceptions.md - Architecture guide: https://github.com/JBorgia/signaltree/blob/main/docs/architecture/signaltree-architecture-guide.md - AI-agent skill (drop-in for Cursor, Claude Code, generic harnesses): `docs/skills/using-signaltree/SKILL.md` - AI agent templates (`.cursorrules`, `CLAUDE.md`): `docs/ai/agent-templates.md`