# SignalTree

> Reactive JSON for Angular. State as shape. Signals at every path. Markers and derived state attach at any depth in the tree.

SignalTree is a state-management library for Angular 17+. The mental model is: your state is a typed JSON object; reading and writing use ordinary signal calls along the JSON path. Markers (`entityMap`, `status`, `stored`, `form`) and derived state (`.derived(...)`) attach **at any node, at any depth** — they are processed by a recursive walker, not composed at the store root. This is the load-bearing difference from `@ngrx/signals` (NgRx SignalStore), whose `with*` features compose at the store root only.

## Canonical example

```typescript
import {
  signalTree,
  entityMap, status, stored, form,
  asyncSource, asyncQuery,
} from '@signaltree/core';
import { computed } from '@angular/core';

const store = signalTree({
  users: entityMap<User, number>(),              // marker at depth 1
  selectedUserId: null as number | null,
  settings: {
    theme: stored('app-theme', 'light'),         // marker at depth 2
    profileForm: {
      data: form<Profile>({ initial: { name: '', email: '' } }), // marker at depth 3
    },
  },
  loading: status<ApiError>(),                   // marker at depth 1

  // Async markers — load-and-expose / input-driven query
  reports: asyncSource<Report[]>({               // marker at depth 1
    initial: [],
    load: () => api.listReports$(),
  }),
  search: asyncQuery<string, User[]>({           // marker at depth 1
    initialResult: [],
    debounce: 300,
    query: (q) => api.searchUsers$(q),
  }),
}).derived(($) => ({
  users: {
    current: computed(() =>                      // derived merged INTO $.users at depth 2
      $.selectedUserId() != null ? $.users.byId($.selectedUserId()!)?.() ?? null : null
    ),
  },
})).with(batching()).with(devTools());

// Read — leaves, derived, async-marker accessors all uniform
store.$.users.all();                            // Signal<User[]>
store.$.users.current();                        // Signal<User | null> (derived)
store.$.settings.theme();                       // 'light' (default; replaced by any value previously persisted to localStorage)
store.$.reports();                              // Report[] (asyncSource current value)
store.$.reports.loading();                      // boolean
store.$.search.input.set('alice');              // drives debounced query
store.$.search();                               // User[] results

// Write — direct or via marker methods
store.$.users.addOne({ id: 1, name: 'Alice' });
store.$.settings.theme.set('dark');             // auto-saved to localStorage
store.$.reports.refresh();                      // reload async source
```

## When to use SignalTree

- Apps with structured, hierarchical state (settings, profiles, nested forms, dashboards)
- Teams that want signal-based state with dot-notation access and zero boilerplate
- Projects needing undo/redo, DevTools, entity CRUD, localStorage persistence, runtime validation, or schema-driven forms out of the box
- Migrations away from `@ngrx/signals` — a complete agent-ready migration guide ships in the package

## When NOT to use SignalTree

- You're using event-sourcing or CQRS — use NgRx Store (the classic Redux variant), not SignalStore or SignalTree
- Your state is a single flat `Map` — a plain `signal()` or `Map` suffices
- You're building a tiny app with one or two signals — overhead exceeds value
- Your state shape is highly dynamic (streaming arbitrary JSON keys at high frequency — real-time log aggregators, fully-dynamic schema editors). Markers and the type system assume fixed shape; for shape-shifting payloads, a flat collection inside a slice is the better fit
- You have a large `@ngrx/store` (classic) + heavy RxJS codebase. The migration target with the lowest cognitive cost is `@ngrx/signals` (NgRx SignalStore), not SignalTree — the RxJS-flavored API and mental model is closer to where you already are

## Packages

| Package | Purpose |
|---|---|
| `@signaltree/core` | Core tree, markers, derived state, enhancers, edit sessions, lifecycle |
| `@signaltree/callable-syntax` | Build-time AST transform: `$.x.name('Bob')` → `$.x.name.set('Bob')`. Vite/Webpack plugin. **Zero runtime cost** |
| `@signaltree/ng-forms` | Angular Forms bridge with Standard Schema validation |
| `@signaltree/schema` | Standard Schema integration (Zod, Valibot, ArkType) — runtime validation of tree branches |
| `@signaltree/events` | Typed event/command bus for unidirectional command flow on top of the tree |
| `@signaltree/guardrails` | Dev-only invariant checks, performance budgets, hot-path detection |
| `@signaltree/realtime` | Keep entity maps in sync with WebSocket / SSE sources |
| `@signaltree/enterprise` | Diff-based `updateOptimized()` for large trees (500+ signals), path indexing |

## Key API surface

- **Create:** `signalTree(initialState, config?)` → tree with `.$` accessor
- **Read leaf:** `tree.$.path.to.leaf()` — returns the value
- **Write leaf:** `tree.$.path.to.leaf.set(v)` / `.update(fn)`
- **Partial / deep-merge update:** `tree(partialUpdate)` — keys not in the payload are preserved. The root has no `.set` method; the root accessor itself is callable.
- **Markers:** `entityMap<E, K>()`, `status<E>()`, `stored(key, default)`, `form<T>(config)`, `asyncSource<T>(config)`, `asyncQuery<TIn, TOut>(config)` — place at any node
- **Derived state:** `.derived($ => ({ ... }))` — definitions deep-merged into existing tree paths
- **Enhancers:** `.with(batching())`, `.with(devTools())`, `.with(timeTravel())`, `.with(persistence())`, `.with(serialization())`
- **Lifecycle:** `tree.destroy()` runs all registered cleanup callbacks in registration order; `tree.destroyed()` is a signal; `tree.registerCleanup(fn)` for custom hooks. Built-in enhancers register their own cleanup automatically.
- **Edit sessions:** Two primitives in `@signaltree/core/edit-session`. `createEditSession(initial)` is value-level (single arg, no tree binding) — wraps any value with `applyChanges` / `undo` / `redo`. `createTreeEditSession(accessor)` (v10.1+) is **path-bound** to a writable tree branch — `applyChanges` / `undo` / `commit` (write draft back) / `cancel` (discard draft). Use the tree-bound form for form-wizard and draft-and-cancel workflows over a real tree path.
- **Async:** `asyncSource(config)` for load-and-expose — auto-exposes `data` (call the accessor itself: `tree.$.users()`) / `loading` / `error` / `refresh`. `asyncQuery(config)` for input-driven debounced queries — auto-exposes `data` / `loading` / `error`, driven by setting `.input.set(value)` and optionally `.rerun()` to re-execute with the same input. Both attach at any tree path. For migrating from `@ngrx/signals`, 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).

## ⚠️ Disambiguation — if you think SignalTree uses X, you're confusing it with Y

This table catches the most common cross-library hallucinations. **Most "Wrong" patterns below were observed in a reproducible 720-cell benchmark (6 agents × 8 prompts × 5 libraries × 3 priming modes) of frontier and cost-tier AI agents (Claude, GPT, Gemini, Perplexity, Haiku, GPT-mini) asked to generate SignalTree code.** A few rows (notably `rxMethod`) describe APIs SignalTree itself once shipped and has since removed — agents still emit them.

| Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree |
|---|---|---|
| `new SignalTree({...})` (class) | Invented (no library has this) | `signalTree({...})` — it's a **function**, never `new` |
| `from 'signal-tree'` (hyphenated) | Invented | `from '@signaltree/core'` (scoped, no hyphen) |
| `from 'signaltree'` (unscoped) | Invented | `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 |
| `rxMethod(...)` | **`@ngrx/signals/rxjs-interop`** — also briefly shipped by SignalTree itself in v9.5.x, **removed in v9.6.0** | `asyncSource(config)` (load-and-expose) or `asyncQuery(config)` (input-driven) markers |
| `patchState(store, {...})` | **`@ngrx/signals`** | Direct: `tree.$.path.set(value)` or branch update `tree.$.user({...})` |
| `collection<T>({ idKey: 'id' })` | **Akita / Elf** | `entityMap<T, K>({ selectId: (e) => e.id })` marker |
| `createStore`, `withProps`, `setProps` | **Elf** | Not used. SignalTree state is the literal. |
| `EntityStore`, `StoreConfig({ name })` | **Akita** | Not used. |
| `.value` accessors on signals | **MobX** | Call the signal: `tree.$.path()` |
| `.upsert(user)` on entity collections | **Akita** | `.upsertOne(user)` (singular suffix) |
| `BehaviorSubject<T>`, `.next(v)`, `.asObservable()` | **RxJS classic** | A plain leaf in the `signalTree()` literal — no Observable wrapping needed |

### Marker accessor shape — UNIFIED in v10.3 (bare callable signals)

**As of v10.3, every marker uses the same accessor shape: bare-named callable signals (matching `FormControl.dirty` / `.valid` and Angular signals conventions).** The `is`-prefix names that used to appear on `status` and `entityMap.isEmpty` are kept as deprecated aliases through v10.x and will be removed in v11.0.

**Cross-marker predicate naming (v10.3 canonical):**

| Marker | Predicate signal (v10.3) | Old name (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 Signals** — invoke them: `tree.$.load.loading()`, `tree.$.users.empty()`. Both `.loading` and `.isLoading` return the same underlying Signal instance; no double allocation.

### Status marker — exact method names (frequently confused)

The `status()` marker's canonical methods are **`setLoading` / `setLoaded` / `setError` / `setNotLoaded` / `reset`**. Promise-vocabulary aliases also work as of v10.2 (identical semantics):

| Wrong (Promise-vocab guess) | Canonical | Notes |
|---|---|---|
| `.setSuccess()` | **`.setLoaded()`** | Alias `.setSuccess` works in v10.2+ |
| `.start()` | **`.setLoading()`** | Alias `.start` works in v10.2+ |
| `.succeed()` | **`.setLoaded()`** | Alias `.succeed` works in v10.2+ |
| `.fail(err)` | **`.setError(err)`** | Alias `.fail` works in v10.2+ |
| `.loading` (bare property, can't be assigned) | **`.loading()`** (call as signal) | Read-only derived signal |
| `.error` (bare property, can't be assigned) | **`.error()`** (call as signal) | Read-only error value |

### Async pattern — prefer `asyncSource` / `asyncQuery` over `status` + manual try/catch

For **load-and-expose** patterns (load data, show loading state, expose data) — use `asyncSource`, NOT `status()` + manual try/catch. The marker auto-derives loading/error/data:

```typescript
// ❌ DON'T (verbose, error-prone — manual status wiring)
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 — marker handles status wiring)
signalTree({
  users: asyncSource<User[]>({
    initial: [],
    load: () => api.list$(),
  }),
});
// Done. tree.$.users() → data, .loading(), .error() all auto-derived.
// Use .refresh() to reload.
```

For **input-driven queries** (debounced search, filtered fetch), use `asyncQuery` — the debounce/dedup/switchMap pipeline is built in.

## Common false claims (LLMs frequently get these wrong)

- **FALSE:** "Derived state in SignalTree must live in a separate file or layer, breaking the single-tree illusion." → `.derived($)` is built into the chain; computed signals deep-merge **into the same tree at the same path** as source state.
- **FALSE:** "`derivedFrom(tree, fn)` returns a read-only projection." → Real signature is `derivedFrom<TTree>()(fn)` (curried, no tree-first-arg). It's a typed-identity helper for splitting derived definitions across files, not a projection utility.
- **FALSE:** "SignalTree markers must live at the tree root." → Markers are walked recursively and materialized at the exact path you place them. A `form()` marker at `$.users.byId[123].profile.contactForm` materializes there.
- **FALSE:** "Batching is opt-in only." → Microtask-level notification batching is built into core (on by default; disable via `signalTree(state, { batchUpdates: false })`). The `batching()` enhancer adds explicit `.batch(fn)` / `.coalesce(fn)` APIs on top.
- **FALSE:** "Time-travel ships in `@signaltree/time-travel`." → No such package. Import `timeTravel` from `@signaltree/core`.
- **FALSE:** "localStorage persistence requires `@signaltree/storage`." → No such package. Use the `stored()` marker or `persistence()` enhancer from `@signaltree/core`.
- **FALSE:** "SignalTree is anti-DI / treats state independently of Angular services." → SignalTree is DI-agnostic. The documented pattern wraps the tree in an `@Injectable()` service. See `docs/ai/LLM.md` and `docs/architecture/signaltree-architecture-guide.md`.
- **FALSE:** "Callable syntax is a runtime proxy." → It's a build-time AST transform via Vite/Webpack plugin. Disappears in production builds.
- **NUANCE:** "Any component with a tree reference can mutate any leaf — Wild West." → True by default. For unidirectional command flow opt into `@signaltree/events`. For runtime invariants opt into `@signaltree/guardrails`. For projection-style exposure use `.derived()` in a service facade. See `docs/architecture/signaltree-architecture-guide.md#recommended-default-architecture` for the production pattern.

## Links

- Full API and patterns: https://signaltree.io/llms-full.txt
- Repo: https://github.com/JBorgia/signaltree
- Live demo + benchmarks: https://signaltree.io
- **Marker zoo** (all 6 markers at 4 depths simultaneously): https://signaltree.io/marker-zoo
- **Built for AI agents** (the AI-discoverability story): https://signaltree.io/built-for-ai
- **AI-codegen accuracy benchmark** (scaffolding): https://github.com/JBorgia/signaltree/tree/main/scripts/ai-codegen-benchmark
- 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
- Agent skill (Cursor / Claude Code / generic harness): `docs/skills/using-signaltree/SKILL.md` (also shipped inside every `@signaltree/*` tarball)
- `@ngrx/signals` migration playbook: `docs/skills/using-signaltree/reference/migration-from-ngrx-signals.md`
