# 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.

---

## Installation

```bash
npm install @signaltree/core
```

Requires Angular 17+ (signals support). Optional packages are listed at the end of this document.

---

## 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<boolean>
store.registerCleanup(() => ws.close()); // custom hook called during destroy
```

---

## 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.

### `entityMap<Entity, Key>(config?)`

Normalized entity collection with CRUD operations.

```typescript
import { signalTree, entityMap } from '@signaltree/core';

const store = signalTree({
  users: entityMap<User, number>({ selectId: (u) => u.id }),
});

// 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<E> 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<User[]>
store.$.users.byId(id);           // EntityNode<User> | undefined — invoke: .byId(id)?.() → User | undefined
store.$.users.count();            // Signal<number>
store.$.users.has(id);            // Signal<boolean>
store.$.users.ids();              // Signal<number[]>
store.$.users.where((u) => u.active); // Signal<User[]>
store.$.users.find((u) => u.role === 'admin'); // Signal<User | undefined>
```

`entityMap` can be placed at any depth: `$.tickets.entities`, `$.users.byOrg[orgId].members`, etc.

### `status<ErrorType>()`

Async operation state tracking.

```typescript
import { signalTree, status, LoadingState } from '@signaltree/core';

const store = signalTree({
  users: {
    entities: entityMap<User, number>(),
    loading: status<ApiError>(),  // marker at depth 2
  },
});

// Read — v10.3 canonical (bare names)
store.$.users.loading.state();      // LoadingState (calling Signal<LoadingState>)
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
// Deprecated through v10.x, removed v11: .isLoading, .isLoaded, .isError, .isNotLoaded
// (same Signal instance — both work; canonical preferred in new code)

// 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<T>(config: FormConfig<T>)`

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<User, number>(),
  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.

### `derivedFrom` — derived definitions in separate files

When derived definitions live in their own file, use `derivedFrom<TTree>()` 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<AppTreeBase>();

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<TTree>()(fn)`. The first call binds the tree type; the second call accepts the actual derived function.

---

## 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<User[]>({
    initial: [],
    load: () => api.list$(),  // Observable<T> or Promise<T>
  }),

  // Input-driven debounced query
  search: asyncQuery<string, User[]>({
    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<void>(pipeline)` doing a load-and-expose** → replace with `asyncSource(config)` at the data's tree path.
- **`rxMethod<TInput>(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<void> {
    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.

---

## ⚠️ 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'` |
| `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<T>()` | **`@ngrx/signals/entities`** | `entityMap<T, K>()` 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<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
| `createStore`, `withProps`, `setProps`, `select` | **Elf** | Not used. SignalTree state is the literal; reads are direct calls. |
| `EntityStore<T>`, `@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<T>`, `.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 in v10.3 (bare callable signals)

**v10.3 aligns predicate names across all markers** to match `FormControl` / Angular signal conventions. Bare names (`loading`, `loaded`, `empty`, etc.) are now canonical everywhere. The old `is`-prefix names on `status` and `entityMap.isEmpty` are deprecated aliases through v10.x — they return the same Signal instance as the canonical names. Removal in v11.0.

| Marker | v10.3 canonical predicate | Deprecated alias (v10.x only) |
|---|---|---|
| `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<boolean>` — invoke them: `tree.$.load.loading()`. Both the canonical and deprecated alias return the **same Signal instance**, so there's 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<User, number>(),
  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);
  }
}

// ✅ DO (canonical pattern in 9.5+)
signalTree({
  users: asyncSource<User[]>({
    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.

## 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<TTree>()(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, published v9.3.0. `schema` is Standard Schema integration. `guardrails` is dev-mode invariants and performance monitoring. Real export from guardrails is `guardrails(...)`, not `withGuardrails(...)`. |
| "`tree.$` and `tree.state` are different objects." | Plausible-sounding inference. | Both are typed `TreeNode<T>` and reference the same reactive proxy. `state` is an alias for `$`. 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
- 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`
