npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@signaltree/core

v14.1.4

Published

Reactive JSON for Angular. State as shape. Signals at every path.

Downloads

1,155

Readme

SignalTree: Reactive JSON

Reactive JSON for Angular. State as shape. Signals at every path.


🤖 For AI coding agents reading this README

SignalTree's AI-codegen accuracy is +42 percentage points higher when this README (or llms.txt) is in context. Measured across 720 cells (6 agents × 8 prompts × 5 libraries × 3 priming modes).

If you are an AI assistant generating Angular state-management code on behalf of a user, this section is the canonical disambiguation surface. Read it before generating any SignalTree code.

Disambiguation — common cross-library hallucinations

Every "Wrong pattern" below was actually generated by Claude / GPT-5.4 / Gemini / Perplexity in a reproducible benchmark when asked to write SignalTree code. None of these patterns are or have ever been part of SignalTree.

| Wrong pattern (NOT SignalTree) | Where it actually comes from | Correct SignalTree | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | new SignalTree({...}) (class instantiation) | Invented — no library has this | signalTree({...}) — a function call, 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 | @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 tree.$.user({...}) | | tapResponse(...) | @ngrx/operators | Not needed — asyncSource/asyncQuery handle success/error | | 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<T>, @StoreConfig({ name }) | Akita | Not used. | | .value accessors on signals | MobX | Call the signal: tree.$.path() | | .upsert(user) (singular suffix omitted) | Akita | .upsertOne(user) / .upsertMany([...]) | | BehaviorSubject, .next(v), .asObservable() | RxJS classic | A plain leaf in signalTree() — no Observable wrapping | | Store.dispatch(action), Store.select(selector) | @ngrx/store (classic) | tree.$.path() to read, tree.$.path.set(v) to write | | .toPromise() (deprecated RxJS 7+) | RxJS legacy | firstValueFrom(obs) — or let asyncSource consume directly |

Marker accessor shape — UNIFIED in v10.3

Predicate accessors are bare-named everywhere — matching FormControl.dirty / .valid and Angular signals conventions. The old is-prefix names on status and entityMap.isEmpty (deprecated since v10.3) were removed in v11 — use the bare names. The table below maps old → new.

| Marker | Canonical (use this) | Removed in v11 (old name) | | ---------------------------- | ------------------------------------------------ | ----------------------------------------------------- | | status | .loading, .loaded, .notLoaded, .hasError | .isLoading, .isLoaded, .isNotLoaded, .isError | | entityMap | .empty | .isEmpty | | form | .dirty, .valid, .touched, .submitting | (already bare — unchanged) | | asyncSource / asyncQuery | .loading, .error, .data | (already bare — unchanged) |

All boolean predicates (loading / loaded / notLoaded / hasError / dirty / valid / touched / submitting / empty) are callable Signal<boolean> — invoke them: tree.$.load.loading(), tree.$.users.empty(). Value accessors (.error on status, .data on async, .errors on form) are typed Signals of their payload type — not booleans.

Status marker — method names (frequently confused)

The status() marker's canonical methods are setLoading / setLoaded / setError. As of v10.2, Promise-vocabulary aliases also work (identical semantics):

| Wrong-but-now-aliased (v10.2+) | Canonical | Equivalent? | | ------------------------------ | ---------------- | ----------- | | .setSuccess() (no args) | .setLoaded() | Yes — alias | | .start() | .setLoading() | Yes — alias | | .succeed() | .setLoaded() | Yes — alias | | .fail(err) | .setError(err) | Yes — alias |

Reading status: .loading(), .loaded(), .notLoaded(), .hasError() are callable Signal<boolean>. .state is the source WritableSignal<LoadingState>. .error is WritableSignal<E | null> — invoke as .error() to read.

Canonical async pattern — use asyncSource, NOT status + manual try/catch

For load-and-expose (load data, expose loading state and data), reach for asyncSource:

import { Injectable, inject } from '@angular/core';
import { signalTree, asyncSource } from '@signaltree/core';

@Injectable({ providedIn: 'root' })
export class UsersService {
  private readonly api = inject(UserApi);
  private readonly tree = signalTree({
    users: asyncSource<User[]>({
      initial: [],
      load: () => this.api.list$(),
    }),
  });

  readonly users = this.tree.$.users; // .users() → User[] | undefined, .users.loading(), .users.error()
  load = () => this.tree.$.users.refresh();
}

For input-driven queries (debounced search, filtered fetch), reach for asyncQuery — the debounce + dedup + switchMap pipeline is built in.

Canonical state-management pattern

import { Injectable, inject } from '@angular/core';
import { signalTree, entityMap, status, asyncSource, form } from '@signaltree/core';

@Injectable({ providedIn: 'root' })
export class AppService {
  // State is the literal — no with*() wrappers
  private readonly tree = signalTree({
    users: entityMap<User, number>(),
    saveStatus: status(),
    profile: form<{ firstName: string; lastName: string }>({
      initial: { firstName: '', lastName: '' },
    }),
    feed: asyncSource<Post[]>({ initial: [], load: () => api.feed$() }),
  });

  // Direct reads — call the signal
  readonly userCount = this.tree.$.users.count;
  readonly canSave = this.tree.$.profile.dirty;

  // Direct writes — call .set() / marker methods
  addUser = (u: User) => this.tree.$.users.addOne(u);
  startSave = () => this.tree.$.saveStatus.setLoading();
}

Where to read more


What is @signaltree/core?

SignalTree treats application state as reactive JSON — a typed, dot-notation interface to plain JSON-like objects with fine-grained reactivity layered transparently on top.

You don't model state as actions, reducers, selectors, or classes — you model it as data.

Where SignalTree fits

SignalTree is built for structured state that wants batteries (entity CRUD, async status, forms, persistence, undo) attached at any node, at any depth — which describes most Angular apps past the prototype stage:

import { signalTree, asyncSource, form, status } from '@signaltree/core';

const store = signalTree({
  workspace: {
    editor: {
      doc: asyncSource<Doc>({ initial: emptyDoc, load: () => api.doc$(id) }),
      draft: form<Doc>({ initial: emptyDoc }), // form marker — depth 3
      save: status<ApiError>(), //               status marker — depth 3
    },
    sidebar: { filters: form<Filters>({ initial: defaultFilters }) },
  },
});

store.$.workspace.editor.draft.dirty(); // each panel owns its state, addressed by path

Every behavior lives at the path it describes — not flattened to a store root, not hand-wired field by field. That structural locality, plus recursive typing and partial deep-merge writes, is the boilerplate SignalTree removes. It's the load-bearing difference from NgRx SignalStore (whose with* features compose at the store root only).

For a couple of values inside one component, use raw Angular signals (signal / computed / linkedSignal / resource) — they're zero-dependency and complete for that. The honest decision boundary:

  • Raw signals → simple/flat/component-local state, one async fetch. (when native signals are enough)
  • SignalTree → structured/nested state needing batteries at depth, fully typed.
  • NgRx SignalStore → you want Redux-style ergonomics or lean on its ecosystem. (comparison)

Core Philosophy

| Principle | What It Means | | ------------------------ | ---------------------------------------------------------------------------- | | State is Data | Your state shape looks like JSON. No ceremony, no abstractions. | | Dot-Notation Access | tree.$.user.profile.name() — fully type-safe, IDE-discoverable | | Invisible Reactivity | You think in data paths, not subscriptions. Reactivity emerges naturally. | | Lazy by Design | Signals created only where accessed. Types do heavy lifting at compile time. |

Technical Features

  • Recursive typing with deep nesting and accurate type inference
  • Fast operations with sub‑millisecond measurements at 5–20+ levels
  • Strong TypeScript safety across nested structures
  • Memory efficiency via structural sharing and lazy signals
  • Small API surface with minimal runtime overhead
  • Compact bundle size suited for production

Import guidance (tree-shaking)

Modern bundlers (Vite, esbuild, Rollup, webpack 5+) automatically tree-shake barrel imports from @signaltree/core. Unused enhancers and markers drop out of the bundle.

// Import only what you use — unused symbols are tree-shaken away
import { signalTree, batching } from '@signaltree/core';

Published entry points (from package.json exports): @signaltree/core plus @signaltree/core/authoring, @signaltree/core/edit-session, @signaltree/core/lazy, @signaltree/core/security, @signaltree/core/storage. Enhancers are NOT a subpath — they live in the main barrel and are tree-shaken from there.

  • @signaltree/core/lazy — the lazy() helper for deferring marker/enhancer materialization.
  • @signaltree/core/authoring — enhancer- and marker-author plumbing. The root-barrel re-exports of getPathNotifier and registerMarkerProcessor, deprecated in 11.6.0, are gone as of 14.0.0 — import them from here.
  • @signaltree/core — 33 symbols (the app surface)

  • @signaltree/core/authoring — 39 symbols:

    • reader allowlists (8): ASYNC_QUERY_READERS, ASYNC_SOURCE_READERS, ENTITY_LOADER_READERS, ENTITY_READERS, FORM_READERS, FORM_WIZARD_READERS, STATUS_READERS, STORED_READERS
    • marker brands (3): ASYNC_QUERY_MARKER, ASYNC_SOURCE_MARKER, FORM_MARKER
    • marker type guards (6): isAsyncQueryMarker, isAsyncSourceMarker, isDerivedMarker, isFormMarker, isStatusMarker, isStoredMarker
    • other type guards (5): isAnySignal, isBuiltInObject, isNodeAccessor, isSignalTree, isTraversableNode
    • marker authoring (4): createAsyncQuerySignal, createAsyncSourceSignal, createFormSignal, registerMarkerProcessor
    • enhancer authoring (4): ENHANCER_META, composeEnhancers, createEnhancer, resolveEnhancerOrder
    • write-path plumbing (4): getActiveWriteContext, getPathNotifier, interceptLeafSignals, withWriteContext
    • observation hooks (2): onHydrateDecision, onTreeError
    • constants (2): SIGNAL_TREE_CONSTANTS, SIGNAL_TREE_MESSAGES
    • other (1): parsePath
      **Measured impact.** All figures are a **production build** (`ngDevMode: false`),
      own code only (Angular/rxjs/tslib external), gzipped. Reproduce with
      `node tools/check-bundle-budget.mjs`.
  • Bare signalTree (no markers/enhancers): 5.79 KB

  • A tree using a plain entityMap(): 9.40 KB

⚠️ The condition matters. The same code in a development build is ~1.8-2.4 KB larger per tree, because the dev diagnostics are guarded strings that fold away under ngDevMode: false — so a bare tree measures ~7.80 KB in dev and one using entityMap() measures ~12.07 KB. Neither number is wrong; quoting one without saying which invites someone to measure the other and conclude the docs lie.

  • Core + batching(): bare signalTree plus batching()'s own delta (see per-enhancer deltas under "Available extension packages")
  • Unused enhancers: automatically excluded by tree-shaking

Marker Tree-Shaking (Self-Registering)

Built-in markers (entityMap(), status(), stored()) are self-registering - they only add their processor code when you actually use them:

// ✅ Only status() code is bundled (entityMap and stored tree-shaken out)
import { signalTree, status } from '@signaltree/core';
const tree = signalTree({ loadState: status() });

// ✅ Minimal bundle - no marker code included
import { signalTree } from '@signaltree/core';
const tree = signalTree({ count: 0 });

How it works:

  • Each marker factory (status(), stored(), entityMap()) registers its processor on first call
  • If you never call a marker factory, its code is completely eliminated
  • Zero import-time side effects - registration is lazy and automatic

When to use subpath imports:

  • Older bundlers (webpack <5) with poor tree-shaking
  • Explicit control over what gets included
  • Personal/team preference for clarity

This repo's ESLint rule is disabled by default since testing confirms effective tree-shaking with barrel imports.

Callable shape — branches yes, leaves no

One fact explains the whole rule: only leaves are Angular signals.

A branch is SignalTree's own accessor, so we own its call semantics and a call can mean "merge this". It is callable in both directions, natively, with nothing to install:

tree.$.user(); // Read the user subtree
tree.$.user({ name: 'Jane' }); // Deep-merge partial update
tree.$.user((u) => ({ ...u, age: u.age + 1 })); // Updater form
tree({ ui: { loading: false } }); // The root, same shape

A leaf is a real WritableSignal. Calling an Angular signal is a read — it returns the value and ignores any argument — so a leaf is written with .set() / .update():

const name = tree.$.name(); // read
tree.$.name.set('Jane'); // write
tree.$.count.update((n) => n + 1); // transform

Changed in 14.0.0. Through 13.x the types also permitted tree.$.name('Jane'), and the @signaltree/callable-syntax build transform was meant to rewrite it to .set(). In an Angular app that transform could not run at all, so the call type-checked and then silently did nothing. The overloads and the package are both gone; that line is now a compile error. See the migration note.

Key points:

  • Zero runtime overhead: branch callables are a native part of the accessor
  • Leaves stay real Angular signals — isSignal() is true and Symbol(SIGNAL) is present, which is what toObservable, model()/input() interop and every third-party tool guarding on isSignal depend on. Wrapping leaves to make leaf(value) work would have cost that, which is why it was refused; the ~4% speed difference was inside noise and never the deciding factor.

Function-valued leaves: when a leaf stores a function as its value, use .set(fn) to assign it and .update(prev => next) to transform it.

Snapshots: undo, devtools and rehydrate are not the same thing

Three features all "restore a snapshot", and they want different things. Getting them confused is how a devtools slider ends up rewriting a user's saved settings, so the distinction is built into the API rather than left to convention.

mode is a property of the CALL SITE — the only place that knows whether a process boundary was crossed:

| | merge | restore | rehydrate | | ------------------------------- | -------------------- | -------------------------------- | --------------------------------------------- | | Triggered by | tree(partial) | undo() / redo() / jumpTo() | deserialize(), SSR transfer, localStorage | | Process boundary crossed? | no | no | yes | | In-flight request possible? | yes | yes | no — nothing survived | | Rule | write what was given | exact | opinionated |

The rule in one line: restore is exact, rehydrate is opinionated.

An undo is a step backwards inside a running app, so transient state is restored verbatim — a request really may still be in flight. A rehydrate happens after everything died, so believing the payload's LOADING would strand a spinner that nothing will ever resolve.

What that means per marker:

| | restore (undo/redo) | rehydrate (deserialize/SSR) | | --------------------------- | -------------------------------------------------- | ---------------------------------------------- | | status() in LOADING | stays LOADING | normalised to NotLoaded | | form() touched | restored | dropped — the user hasn't touched anything yet | | form() submitting | never restored | never restored | | loader-backed entityMap | accepts the write | declines — the loader owns this data | | asyncSource with a loader | accepts the write | declines | | stored() write-through | yes — you are undoing the persisted change too | n/a |

Devtools replay is not undo. Both rewind state, but only one should have side effects. Scrubbing a timeline is inspection; it must not rewrite localStorage, because the user is dragging a slider, not making a decision. Undo must, because the user is undoing the persisted change as well. Core distinguishes them by the write's source ('time-travel' vs 'devtools') — no extra mode, no option, no new vocabulary.

Source ownership. On rehydrate, a marker that owns a source declines and lets its own loader decide; a marker with no source accepts the payload. The per-instance policy knob for offline-first already exists and is the right place for it:

entityMap<Plant, string>({
  selectId: (p) => p.url,
  load: loader(() => api.list$(), {
    persist: {
      adapter: createIndexedDBAdapter(),
      key: 'plants',
      hydrateThenRevalidate: true,
    },
  }),
});

That seeds rows from the loader's own store, marks them stale and revalidates in the background. A tree-level rehydrate writing over that would not be a second opinion — it would be a clobber by the mechanism that knows least about freshness.

Measuring performance and size

Performance and bundle size vary by app shape, build tooling, device, and runtime. To get meaningful results for your environment:

  • Use the Benchmark Orchestrator in the demo app to run calibrated, scenario-based benchmarks across supported libraries with real-world frequency weighting. It can apply frequency multipliers that weight each scenario by how often the maintainer judges that operation to occur in real applications — these are estimates, not survey findings, and the equal preset turns them off for a neutral comparison. It reports statistical summaries (median/p95/p99/stddev), alternates runs to reduce bias, and can export CSV/JSON. When available, memory usage is also reported.
  • Use the bundle analysis scripts in scripts/ to measure your min+gz sizes. Sizes are approximate and depend on tree-shaking and configuration.

Best Practices (SignalTree-First)

📖 Production app structure: For anything beyond a single-component prototype, follow the Recommended Default Architecture — it wraps the patterns below in an AppStore facade with a $ + ops split, derived tiers, and an ESLint guard against direct tree mutation from components/services. The snippets in this README are deliberately minimal and use the low-level core API directly so they stay self-contained; in real apps the mutations shown here belong inside *Ops classes.

Follow these principles for idiomatic SignalTree code:

1. Expose signals directly (no computed wrappers)

const tree = signalTree(initialState);
const $ = tree.$; // Shorthand for state access

// ✅ SignalTree-first: Direct signal exposure
return {
  selectedUserId: $.selected.userId, // Direct from $ tree
  loadingState: $.loading.state,
  selectedUser, // Actual derived state (computed)
};

// ❌ Anti-pattern: Unnecessary computed wrappers
return {
  selectedUserId: computed(() => $.selected.userId()), // Adds indirection
};

2. Use ReturnType inference (SignalTree-first)

// Let SignalTree infer the type - no manual interface needed!
import type { createUserTree } from './user.tree';
export type UserTree = ReturnType<typeof createUserTree>;

// Factory function - no explicit return type needed
export function createUserTree() {
  const tree = signalTree(initialState); // entities() not needed in v7+
  return {
    selectedUserId: tree.$.selected.userId, // Type inferred automatically
    // ...
  };
}

3. Use computed() only for derived state

// ✅ Correct: Derived from multiple signals
const selectedUser = computed(() => {
  const id = $.selected.userId();
  return id ? $.users.byId(id)?.() ?? null : null;
});

// ❌ Wrong: Wrapping an existing signal
const selectedUserId = computed(() => $.selected.userId()); // Unnecessary!

4. Use EntitySignal API directly

// ✅ SignalTree-native
const user = $.users.byId(123)?.(); // EntityNode → User | undefined
const allUsers = $.users.all; // Get all
$.users.setAll(usersFromApi); // Replace all

// (NgRx Signal Store equivalent — for context, not SignalTree syntax)
// const user = usersStore.entityMap()[123];

Notification Batching

SignalTree automatically batches notification delivery to subscribers and change detection to the end of the current microtask. This prevents render thrashing when multiple values are updated together and preserves immediate read-after-write semantics (values update synchronously, notifications are deferred).

Example

// Multiple updates in the same microtask are coalesced into a single notification
tree.$.form.name.set('Alice');
tree.$.form.email.set('[email protected]');
tree.$.form.submitted.set(true);
// → Subscribers are notified once at the end of the microtask with final values

Testing

When tests need synchronous notification delivery, use flushSync():

Removed root-barrel re-export: getPathNotifier was deprecated on the root barrel in 11.6.0 and removed in 14.0.0. It is only importable from @signaltree/core/authoring. This note said "will be removed in the next major" for three majors after the removal had already happened.

import { getPathNotifier } from '@signaltree/core/authoring';

it('updates state', () => {
  tree.$.count.set(5);
  getPathNotifier().flushSync();
  expect(subscriber).toHaveBeenCalledWith(5, 0);
});

Alternatively, await a microtask (await Promise.resolve()) to allow the automatic flush to occur.

Opting out

To disable automatic microtask batching for a specific tree instance:

const tree = signalTree(initialState, { batchUpdates: false });

Use this only for rare cases that truly require synchronous notifications (most apps should keep batching enabled).

Quick start

Installation

npm install @signaltree/core

Deep nesting example

import { signalTree } from '@signaltree/core';

// Strong type inference at deep nesting levels
const tree = signalTree({
  enterprise: {
    divisions: {
      technology: {
        departments: {
          engineering: {
            teams: {
              frontend: {
                projects: {
                  signaltree: {
                    releases: {
                      v1: {
                        features: {
                          recursiveTyping: {
                            validation: {
                              tests: {
                                extreme: {
                                  depth: 15,
                                  typeInference: true,
                                },
                              },
                            },
                          },
                        },
                      },
                    },
                  },
                },
              },
            },
          },
        },
      },
    },
  },
});

// Type inference at deep nesting levels
const depth = tree.$.enterprise.divisions.technology.departments.engineering.teams.frontend.projects.signaltree.releases.v1.features.recursiveTyping.validation.tests.extreme.depth();
console.log(`Depth: ${depth}`);

// Type-safe updates at unlimited depth
tree.$.enterprise.divisions.technology.departments.engineering.teams.frontend.projects.signaltree.releases.v1.features.recursiveTyping.validation.tests.extreme.depth(25); // Perfect type safety!

Basic usage

import { signalTree } from '@signaltree/core';

// Create a simple tree
const tree = signalTree({
  count: 0,
  message: 'Hello World',
});

// Read values (these are Angular signals — always works)
console.log(tree.$.count()); // 0
console.log(tree.$.message()); // 'Hello World'

// Write leaves with .set() / .update()
tree.$.count.set(5);
tree.$.message.set('Updated!');
tree.$.count.update((n) => n + 1);

// Use in an Angular component
@Component({
  template: ` <div>Count: {{ tree.$.count() }}</div>
    <div>Message: {{ tree.$.message() }}</div>
    <button (click)="increment()">+1</button>`,
})
class SimpleComponent {
  tree = tree;

  increment() {
    this.tree.$.count.update((n) => n + 1);
  }
}

Intermediate usage (nested state)

// Create hierarchical state
const tree = signalTree({
  user: {
    name: 'John Doe',
    email: '[email protected]',
    preferences: {
      theme: 'dark',
      notifications: true,
    },
  },
  ui: {
    loading: false,
    errors: [] as string[],
  },
});

// Access nested signals with full type safety
tree.$.user.name.set('Jane Doe');
tree.$.user.preferences.theme.set('light');
tree.$.ui.loading(true);

// Computed values from nested state
const userDisplayName = computed(() => {
  const user = tree.$.user();
  return `${user.name} (${user.email})`;
});

// Effects that respond to changes
effect(() => {
  if (tree.$.ui.loading()) {
    console.log('Loading started...');
  }
});

Reactive computations with computed()

SignalTree works seamlessly with Angular's computed() for creating efficient reactive computations. These computations automatically update when their dependencies change and are memoized for optimal performance.

import { computed, effect } from '@angular/core';
import { signalTree } from '@signaltree/core';

const tree = signalTree({
  users: [
    { id: '1', name: 'Alice', active: true, role: 'admin' },
    { id: '2', name: 'Bob', active: false, role: 'user' },
    { id: '3', name: 'Charlie', active: true, role: 'user' },
  ],
  filters: {
    showActive: true,
    role: 'all' as 'all' | 'admin' | 'user',
  },
});

// Basic computed - automatically memoized
const userCount = computed(() => tree.$.users().length);

// Complex filtering computation
const filteredUsers = computed(() => {
  const users = tree.$.users();
  const filters = tree.$.filters();

  return users.filter((user) => {
    if (filters.showActive && !user.active) return false;
    if (filters.role !== 'all' && user.role !== filters.role) return false;
    return true;
  });
});

// Derived computation from other computed values
const activeAdminCount = computed(() => filteredUsers().filter((user) => user.role === 'admin' && user.active).length);

// Performance-critical computation with complex logic
const userStatistics = computed(() => {
  const users = tree.$.users();

  return {
    total: users.length,
    active: users.filter((u) => u.active).length,
    admins: users.filter((u) => u.role === 'admin').length,
    averageNameLength: users.reduce((acc, u) => acc + u.name.length, 0) / users.length,
  };
});

// Dynamic computed functions (factory pattern)
const userById = (id: string) => computed(() => tree.$.users().find((user) => user.id === id));

// Usage in effects
effect(() => {
  console.log(`Filtered users: ${filteredUsers().length}`);
  console.log(`Statistics:`, userStatistics());
});

// Best Practices:
// 1. Use computed() for derived state that depends on signals
// 2. Keep computations pure - no side effects
// 3. Angular's computed() automatically caches results
// 4. Chain computed values for complex transformations
// 5. Use factory functions for parameterized computations

Performance optimization with computed()

Angular's built-in computed() provides automatic memoization — a result is cached until one of the signals it reads from changes. No additional enhancer is required:

import { computed } from '@angular/core';
import { signalTree } from '@signaltree/core';

const tree = signalTree({
  items: Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    value: Math.random(),
    category: `cat-${i % 10}`,
  })),
});

// Expensive computation - automatically cached by Angular's computed()
const expensiveComputation = computed(() => {
  return tree.$.items()
    .filter((item) => item.value > 0.5)
    .reduce((acc, item) => acc + Math.sin(item.value * Math.PI), 0);
});

// The computation only runs when tree.$.items() actually changes
// Subsequent calls return the cached result

9.0.1 note: The memoization() enhancer was removed. Angular's computed() already memoizes; the enhancer added no value on top of it.

Advanced usage (full state tree)

interface AppState {
  auth: {
    user: User | null;
    token: string | null;
    isAuthenticated: boolean;
  };
  data: {
    users: User[];
    posts: Post[];
    cache: Record<string, unknown>;
  };
  ui: {
    theme: 'light' | 'dark';
    sidebar: {
      open: boolean;
      width: number;
    };
    notifications: Notification[];
  };
}

const tree = signalTree<AppState>({
  auth: {
    user: null,
    token: null,
    isAuthenticated: false,
  },
  data: {
    users: [],
    posts: [],
    cache: {},
  },
  ui: {
    theme: 'light',
    sidebar: { open: true, width: 250 },
    notifications: [],
  },
});

// Complex updates with type safety — the root accessor itself is callable
// (branches and the root are callable natively; leaves are not). Pass a
// partial object or an updater function. For leaf writes, use .set() / .update().
tree((state) => ({
  auth: {
    ...state.auth,
    user: { id: '1', name: 'John' },
    isAuthenticated: true,
  },
  ui: {
    ...state.ui,
    notifications: [...state.ui.notifications, { id: '1', message: 'Welcome!', type: 'success' }],
  },
}));

// Get entire state as plain object
const currentState = tree();
console.log('Current app state:', currentState);

Core features

1) Hierarchical signal trees

Create deeply nested reactive state with automatic type inference:

const tree = signalTree({
  user: { name: '', email: '' },
  settings: { theme: 'dark', notifications: true },
  todos: [] as Todo[],
});

// Access nested signals with full type safety
tree.$.user.name(); // string signal
tree.$.settings.theme.set('light'); // type-checked value
tree.$.todos.update((todos) => [...todos, newTodo]); // array operations

2) TypeScript inference

SignalTree provides complete type inference without manual typing:

// Automatic inference from initial state
const tree = signalTree({
  count: 0, // Inferred as WritableSignal<number>
  name: 'John', // Inferred as WritableSignal<string>
  active: true, // Inferred as WritableSignal<boolean>
  items: [] as Item[], // Inferred as WritableSignal<Item[]>
  config: {
    theme: 'dark' as const, // Inferred as WritableSignal<'dark'>
    settings: {
      nested: true, // Deep nesting maintained
    },
  },
});

// Type-safe access and updates
tree.$.count.set(5); // ✅ number
tree.$.count.set('invalid'); // ❌ Type error
tree.$.config.theme.set('light'); // ❌ Type error ('dark' const)
tree.$.config.settings.nested.set(false); // ✅ boolean

3) Manual state management

Core provides basic state updates. For advanced entity management, use the built-in entities enhancer:

interface User {
  id: string;
  name: string;
  email: string;
  active: boolean;
}

const tree = signalTree({
  users: [] as User[],
});

// Entity CRUD operations using core methods
function addUser(user: User) {
  tree.$.users.update((users) => [...users, user]);
}

function updateUser(id: string, updates: Partial<User>) {
  tree.$.users.update((users) => users.map((user) => (user.id === id ? { ...user, ...updates } : user)));
}

function removeUser(id: string) {
  tree.$.users.update((users) => users.filter((user) => user.id !== id));
}

// Manual queries using computed signals
const userById = (id: string) => computed(() => tree.$.users().find((user) => user.id === id));
const activeUsers = computed(() => tree.$.users().filter((user) => user.active));

4) Manual async state management

Core provides basic state updates. For canonical async patterns, use the asyncSource and asyncQuery markers (see the async section). The patterns below show the manual style for cases the markers don't cover (multi-stage orchestration, conditional pipelines):

const tree = signalTree({
  users: [] as User[],
  loading: false,
  error: null as string | null,
});

// Manual async operation management
async function loadUsers() {
  tree.$.loading.set(true);
  tree.$.error.set(null);

  try {
    const users = await api.getUsers();
    tree.$.users.set(users);
  } catch (error) {
    tree.$.error.set(error instanceof Error ? error.message : 'Unknown error');
  } finally {
    tree.$.loading.set(false);
  }
}

// Usage in component
@Component({
  template: `
    @if (tree.$.loading()) {
    <div>Loading...</div>
    } @else if (tree.$.error()) {
    <div class="error">{{ tree.$.error() }}</div>
    } @else { @for (user of tree.$.users(); track user.id) {
    <user-card [user]="user" />
    } }
    <button (click)="loadUsers()">Refresh</button>
  `,
})
class UsersComponent {
  tree = tree;
  loadUsers = loadUsers;
}

5) Performance considerations

6) Enhancers and composition

SignalTree Core provides a complete set of built-in enhancers. Each enhancer is a focused, tree-shakeable extension that adds specific functionality.

Available Enhancers (All in @signaltree/core)

All enhancers are exported directly from @signaltree/core:

Performance Enhancers:

  • batching() - Batch updates to reduce recomputation and rendering

9.0.1 note: The memoization() enhancer and all preset variants were removed. Use Angular's built-in computed() — it provides equivalent memoization with zero additional runtime cost.

Data Management:

  • asyncSource(config) marker - Load-and-expose async state (canonical, v9.5+)
  • asyncQuery(config) marker - Input-driven debounced query state (canonical, v9.5+)
  • serialization() - State persistence and SSR support
  • persistence() - Auto-save to localStorage/IndexedDB

Reactive Side Effects:

effects() was removed in 14.0.0. Use Angular's own effect():

import { effect } from '@angular/core';
import { signalTree } from '@signaltree/core';

const tree = signalTree({ count: 0, user: { name: 'Alice' } });

effect(() => console.log('Count:', tree.$.count()));

Native effect() accepts { injector }, which is the reason for the removal: tree.effect() / tree.subscribe() called Angular's effect() with no injector handling, so using them outside an injection context threw NG0203 with no way to opt out.

Development Tools:

  • devTools() - Redux DevTools auto-connect, path actions, and time-travel dispatch

Global error observation (14.0.0): import { onTreeError } from '@signaltree/core/authoring' gives one place to see every error the library CATCHES — a stored() write that fails, an asyncSource loader that rejects. Markers still handle their own errors exactly as before; this is additive and deliberately cannot swallow, retry or transform. A listener that throws cannot damage the operation that reported to it (that is reported as [ST2025]).

import { onTreeError } from '@signaltree/core/authoring';

onTreeError((e) => Sentry.captureException(e.error, { extra: e }));

Hydrate-decision observation (14.0.0): import { onHydrateDecision } from '@signaltree/core/authoring' reports when a marker DECLINES a rehydrate payload because its own loader owns that data, or NORMALISES one because no in-flight request survives a process boundary. Deliberately not a warning — both decisions are correct, and warning on correct behaviour trains people to ignore the channel. The event carries a stable machine-readable reason ('loader-owns-source' | 'no-request-survives-boundary') that reaches production listeners, plus a detail prose string that folds away under ngDevMode: false.

import { onHydrateDecision } from '@signaltree/core/authoring';

onHydrateDecision((e) => console.debug(e.marker, e.decision, e.reason));

Recognising a tree (14.0.0): isSignalTree(value) moved to @signaltree/core/authoring with the rest of the guards. Use it when you accept unknown and need to branch — writing an enhancer, a devtools bridge, or a serializer that may be handed either a tree or a plain object.

import { isSignalTree } from '@signaltree/core/authoring';

if (isSignalTree(candidate)) candidate.destroy();
  • entityMap reads: all(), count(), ids(), asMap() (the collection as a ReadonlyMap, keyed by id — renamed from map() in 14.1.1, which read as a projection beside all()), byId(id), where(pred), find(pred).
  • entityMap({ recordHistory: false }) - Keep a collection in every OTHER snapshot (serialization(), persistence(), devtools, audit) but out of the timeTravel() undo stack. For a large server-owned grid that must survive reload and must never be undone. Renamed from history: false in 14.1.1: that name collided with form({ history: history() }), which asks the opposite question — own a scoped stack, versus be recorded into one someone else owns.
  • timeTravel(config?) - Undo/redo. canUndo(), canRedo() and getHistory() are reactive as of 14.0.0 — before that they read plain values, so computed(() => tree.canUndo()) cached false forever and an undo button in a zoneless app never enabled. Removed in 14.1.1: pauseRecording()/resumeRecording()/isRecordingPaused() — they could only express "record nothing", never "one undo step", so the documented recipe needed a synthetic sealing write, and pause was a global mute that suppressed unrelated writers. timeTravel({ shouldSkip: (prev, next) => … }) drops uninteresting transitions — it runs on every recorded write, so compare only the fields you mean.

Additional Packages

These are the only separate packages in the SignalTree ecosystem:

  • @signaltree/ng-forms - Angular Forms integration (separate package)
  • ~~@signaltree/enterprise~~ - removed in 14.0.0 (deprecated 13.5.0); use tree.updateAndReport() in core

Composition Patterns

Basic Enhancement:

import { signalTree, batching, devTools } from '@signaltree/core';

// Apply enhancers by chaining — each .with() takes a single enhancer
const tree = signalTree({ count: 0 })
  .with(batching()) // Performance optimization
  .with(devTools()); // Development tools

Performance-Focused Stack:

import { signalTree, batching } from '@signaltree/core';

// entityMap() markers self-register — no entities() enhancer needed
const tree = signalTree({
  products: entityMap<Product>(),
  ui: { loading: false },
}).with(batching()); // Batch updates for optimal rendering

// Entity CRUD operations
tree.$.products.addOne(newProduct);
tree.$.products.setAll(productsFromApi);

// Entity queries
// Reactive: returns Signal<Product[]> that tracks the predicate
const electronics = tree.$.products.where((p) => p.category === 'electronics');
// One-shot non-reactive read: call .all() then filter
const electronicsSnapshot = tree.$.products.all().filter((p) => p.category === 'electronics');

Full-Stack Application:

import { signalTree, serialization, timeTravel } from '@signaltree/core';

const tree = signalTree({
  user: null as User | null,
  preferences: { theme: 'light' },
})
  .with(
    serialization({
      // Auto-save to localStorage
      autoSave: true,
      storage: 'localStorage',
    })
  )
  .with(timeTravel()); // Undo/redo support

// For async operations, use manual async or async helpers
async function fetchUser(id: string) {
  tree.$.loading.set(true);
  try {
    const user = await api.getUser(id);
    tree.$.user.set(user);
  } catch (error) {
    tree.$.loading.set(error.message);
  } finally {
    tree.$.loading.set(false);
  }
}

// Automatic state persistence
tree.$.preferences.theme('dark'); // Auto-saved

// Time travel
tree.undo(); // Revert changes

Enhancer Metadata & Ordering

Derived computed signals are preserved across .with() chaining, so enhancer composition does not recreate signal identities.

Enhancers can declare metadata for automatic dependency resolution:

// Chain enhancers — each .with() takes a single enhancer
const tree = signalTree(state)
  .with(batching()) // Requires: core, provides: batching
  .with(devTools()); // Requires: core, provides: debugging

Core Stubs

SignalTree Core includes all enhancer functionality built-in. No separate packages needed:

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

// Without entityMap - use manual array updates
const basic = signalTree({ users: [] as User[] });
basic.$.users.update((users) => [...users, newUser]);

// With entityMap — entity helpers are automatically available (no enhancer needed)
const enhanced = signalTree({
  users: entityMap<User>(),
});

enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
enhanced.$.users.byId(123)?.(); // ✅ O(1) lookups (undefined if missing)
enhanced.$.users.all; // ✅ Get all as array

Core includes several performance optimizations:

// Lazy signal creation (default)
const tree = signalTree(
  {
    largeObject: {
      // Signals only created when accessed
      level1: { level2: { level3: { data: 'value' } } },
    },
  },
  {
    useLazySignals: true, // Default: true
  }
);

// Custom equality function
const tree2 = signalTree(
  {
    items: [] as Item[],
  },
  {
    useShallowComparison: false, // Deep equality (default)
  }
);

// Structural sharing for memory efficiency — update individual leaves directly
tree.$.newField.set('value'); // Only the changed leaf re-emits; siblings are unaffected

7) Extensibility: Custom Markers & Enhancers

SignalTree is designed for extensibility. Create your own markers (state placeholders that materialize into specialized signals) and enhancers (functions that augment trees with additional capabilities).

Custom Marker Example

Deprecated root-barrel re-export: registerMarkerProcessor from @signaltree/core is deprecated (11.6.0) — marker-author plumbing moved to @signaltree/core/authoring. The root re-export will be removed in the next major; import it from @signaltree/core/authoring instead.

import { signal, Signal } from '@angular/core';
import { signalTree } from '@signaltree/core';
import { registerMarkerProcessor } from '@signaltree/core/authoring';

// 1. Define marker symbol and interface
const VALIDATED_MARKER = Symbol('VALIDATED_MARKER');

interface ValidatedMarker<T> {
  [VALIDATED_MARKER]: true;
  defaultValue: T;
  validator: (value: T) => string | null;
}

// 2. Create marker factory
function validated<T>(defaultValue: T, validator: (value: T) => string | null): ValidatedMarker<T> {
  return { [VALIDATED_MARKER]: true, defaultValue, validator };
}

// 3. Type guard
function isValidatedMarker(value: unknown): value is ValidatedMarker<unknown> {
  return Boolean(value && typeof value === 'object' && (value as any)[VALIDATED_MARKER] === true);
}

// 4. Register materializer (call once at app startup)
registerMarkerProcessor(isValidatedMarker, (marker) => {
  const valueSignal = signal(marker.defaultValue);
  const errorSignal = signal<string | null>(marker.validator(marker.defaultValue));
  return {
    get: () => valueSignal(),
    set: (v: any) => {
      valueSignal.set(v);
      errorSignal.set(marker.validator(v));
    },
    error: errorSignal.asReadonly(),
    isValid: () => errorSignal() === null,
  };
});

// 5. Usage
const tree = signalTree({
  email: validated('', (v) => (v.includes('@') ? null : 'Invalid email')),
});

Custom Enhancer Example

import { signal, Signal } from '@angular/core';
import type { ISignalTree } from '@signaltree/core';

interface WithLogger {
  log(message: string): void;
  history: Signal<string[]>;
}

function withLogger(config?: { maxHistory?: number }) {
  const maxHistory = config?.maxHistory ?? 100;
  return <T>(tree: ISignalTree<T>): ISignalTree<T> & WithLogger => {
    const historySignal = signal<string[]>([]);
    return Object.assign(tree, {
      log: (msg: string) => historySignal.update((h) => [...h, `[${new Date().toLocaleTimeString()}] ${msg}`].slice(-maxHistory)),
      history: historySignal.asReadonly(),
    });
  };
}

// Usage
const tree = signalTree({ count: 0 }).with(withLogger());
tree.log('Tree created');

📖 Full guide: Custom Markers & Enhancers

📱 Interactive demo: Demo App

8) Derived State Tiers

SignalTree supports derived state via the .derived() method, which allows you to add computed signals that build on base state or previous derived tiers.

Basic Usage (Inline Derived)

When derived functions are defined inline, TypeScript automatically infers all types:

import { signalTree, entityMap } from '@signaltree/core';
import { computed } from '@angular/core';

const tree = signalTree({
  users: entityMap<User, number>(),
  selectedUserId: null as number | null,
})
  .derived(($) => ({
    // Tier 1: Entity resolution
    selectedUser: computed(() => {
      const id = $.selectedUserId();
      return id != null ? $.users.byId(id)?.() ?? null : null;
    }),
  }))
  .derived(($) => ({
    // Tier 2: Complex logic (can access $.selectedUser from Tier 1)
    isAdmin: computed(() => $.selectedUser()?.role === 'admin'),
  }));

// Usage
tree.$.selectedUser(); // User | null (computed signal)
tree.$.isAdmin(); // boolean (computed signal)

External Derived Functions (Modular Architecture)

For larger applications, you may want to organize derived tiers into separate files. This requires explicit typing because TypeScript cannot infer types across file boundaries.

SignalTree provides two utilities for external derived functions:

  • derivedFrom<TTree>() - Curried helper function that provides type context for your derived function
  • WithDerived<TTree, TDerivedFn> - Type utility to build intermediate tree types
// app-tree.ts
import { signalTree, entityMap, type WithDerived } from '@signaltree/core';
import { entityResolutionDerived } from './derived/tier-entity-resolution';
import { complexLogicDerived } from './derived/tier-complex-logic';

// Define base tree type
export type AppTreeBase = ReturnType<typeof signalTree<ReturnType<typeof createBaseState>>>;

// Build intermediate types using WithDerived
export type AppTreeWithTier1 = WithDerived<AppTreeBase, typeof entityResolutionDerived>;
export type AppTreeWithTier2 = WithDerived<AppTreeWithTier1, typeof complexLogicDerived>;

function createBaseState() {
  return {
    users: entityMap<User, number>(),
    selectedUserId: null as number | null,
  };
}

export function createAppTree() {
  return signalTree(createBaseState()).derived(entityResolutionDerived).derived(complexLogicDerived);
}
// derived/tier-entity-resolution.ts
import { computed } from '@angular/core';
import { derivedFrom } from '@signaltree/core';
import type { AppTreeBase } from '../app-tree';

// derivedFrom provides the type context for $ via curried syntax
export const entityResolutionDerived = derivedFrom<AppTreeBase>()(($) => ({
  selectedUser: computed(() => {
    const id = $.selectedUserId();
    return id != null ? $.users.byId(id)?.() ?? null : null;
  }),
}));
// @skip-lint — `AppTreeWithTier1` is the type the READER defines in the block
// above; there is no such module to resolve here.
// derived/tier-complex-logic.ts
import { computed } from '@angular/core';
import { derivedFrom } from '@signaltree/core';
import type { AppTreeWithTier1 } from '../app-tree';

// This tier has access to $.selectedUser from Tier 1
export const complexLogicDerived = derivedFrom<AppTreeWithTier1>()(($) => ({
  isAdmin: computed(() => $.selectedUser()?.role === 'admin'),
  displayName: computed(() => {
    const user = $.selectedUser();
    return user ? `${user.firstName} ${user.lastName}` : 'No user selected';
  }),
}));

Why External Functions Need Typing

When a function is defined in a separate file, TypeScript analyzes it in isolation before knowing how it will be used. The type inference happens at the definition site, not the call site:

// ❌ TypeScript can't infer $ - this file is compiled before app-tree.ts uses it
export function myDerived($) {
  // $ is 'any'
  return { foo: computed(() => $.bar()) }; // Error: $ has no properties
}

// ✅ derivedFrom provides the type context (curried syntax)
export const myDerived = derivedFrom<AppTreeBase>()(($) => ({
  foo: computed(() => $.bar()), // $ is properly typed
}));

Key point: derivedFrom is only needed for functions defined in separate files. Inline functions automatically inherit types from the chain. Note the curried syntax: derivedFrom<TreeType>()(fn) - this allows TypeScript to infer the return type while you specify the tree type.

Built-in Markers

SignalTree provides built-in markers that handle common state patterns Angular doesn't provide out of the box: entityMap (gains cache-aware (single-scope) self-loading via an optional load config, v11.2+/v11.4+), status, stored, form, and compared (v13.5+, per-leaf equality) — plus the async markers asyncSource / asyncQuery. All markers are self-registering and tree-shakeable - only the markers you use are included in your bundle.

9) entityMap<E, K>() - Normalized Collections

Creates a normalized entity collection with O(1) lookups by ID. Includes chainable .computed() for derived slices.

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

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
  inStock: boolean;
}

const tree = signalTree({
  products: entityMap<Product, number>()
    .computed('electronics', (all) => all.filter((p) => p.category === 'electronics'))
    .computed('inStock', (all) => all.filter((p) => p.inStock))
    .computed('totalValue', (all) => all.reduce((sum, p) => sum + p.price, 0)),
});

// EntitySignal API
tree.$.products.setAll([
  { id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
  { id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
]);

tree.$.products.all;                // Signal<Product[]> — getter, call as .all() to read
tree.$.products.all();              // Product[] — current value
tree.$.products.byId(1);           // EntityNode<Product> | undefined — cursor; call () to get value
tree.$.products.byId(1)?.();       // Product | undefined — unwrap the cursor

// Field-level reads and writes via EntityNode
// Field properties are computed signals: isSignal() returns true, toObservable() works
tree.$.products.byId(1)?.name();             // string — read field reactively
tree.$.products.byId(1)?.name.set('New');    // update single field (interceptors fire)
tree.$.products.byId(1)?.name.update(n => n.toUpperCase()); // updater
tree.$.products.byId(1)?.name.asReadonly();  // Signal<string> — read-only view

// MERGE vs REPLACE — two operations, two names (14.1.1)
tree.$.products.updateOne(1, { price: 899 });   // MERGE: cannot REMOVE a key
tree.$.products.replaceOne(1, {                  // REPLACE: the only way to remove one
  id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true,
});
tree.$.products.clear();                         // `removeAll()` alias removed in 14.1.1

// `replaceOne` takes the id explicitly on purpose. A `setOne(entity)` that derived
// the key via selectId would write to the wrong slot whenever `changeId` has left
// `entity.id` disagreeing with the storage key.

// Entity-level write via callable — REPLACES (14.1.1 breaking: it used to merge)
const node = tree.$.products.byId(1);
node?.({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
node?.((current) => ({ id: current.id, name: current.name.toUpperCase() }));
// ^ the updater form is WHY this replaces: it returns a full entity, so under merge
//   semantics dropping a key was impossible to express — the spread put it back.

// Note: writes on a stale node (entity removed) throw "Entity with id X not found"
// This is consistent with updateOne() and the rest of the mutation API.
tree.$.products.ids;                // Signal<number[]> — getter
tree.$.products.ids();             // number[] — current value
tree.$.products.count;             // Signal<number> — getter
tree.$.products.count();           // number — current value

// Computed slices (reactive, type-safe)
tree.$.products.electronics();      // Signal<Product[]> - auto-updates
tree.$.products.inStock();          // Signal<Product[]>
tree.$.products.totalValue();       // Signal<number>

// CRUD operations
tree.$.products.upsertOne({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
tree.$.products.upsertMany([...]);
tree.$.products.removeOne(1);
tree.$.products.removeMany([1, 2]);
tree.$.products.clear();

Custom ID Selection

interface User {
  odataId: string; // Not named 'id'
  email: string;
}

const tree = signalTree({
  users: entityMap<User, string>(),
});

// Specify selectId when upserting
tree.$.users.upsertOne(user, { selectId: (u) => u.odataId });

Cache-aware / self-loading entityMap (v11.4+)

Wrap the fetch function in the loader() helper and pass it as entityMap's load and it gains a self-loading, cache-aware surface — reach for this instead of hand-wiring entityMap + status() + a loader + a load-guard for any server-backed collection. Full entityMap surface plus a loader, load status, a staleTime freshness guard, single-flight dedup, tag-based invalidation, and optional offline-first persistence. It retains only the current scope (switching scope A → B → A refetches A, not a multi-key cache). There is no separate entityCollection marker (it was folded into entityMap in v11.4.0 — a plain entityMap<E, K>() with no load is unaffected). loader() is what keeps this machinery tree-shakeable — a plain entityMap() doesn't pay for it; a raw function passed directly as load was removed in v12 (RFC 0005 §6/§7) and now fails closed with a coded [ST2004] error. See RFC 0002, RFC 0003, and the cookbook.

import { signalTree, entityMap, loader, invalidateTag } from '@signaltree/core';

const tree = signalTree({
  plants: entityMap<Plant, string>({
    selectId: (p) => p.url,
    load: loader(
      () => plantApi.list$(region), // () => Observable<E[]> | Promise<E[]>
      {
        staleTime: '30m', // skip refetch while fresh; ms or '30m'. default 0 = always stale
        swr: true, // serve last value while revalidating
        tags: ['plants'], // for invalidateTag(tree, 'plants')
      }
    ),
  }),
});

// Full entityMap surface (all/byId/where/addOne/setAll/…) plus:
await tree.$.plants.load(); // guarded: no-op if fresh OR in-flight — N callers => one fetch
await tree.$.plants.loadOrThrow(); // same guard, but rejects on failure instead of only setting .error()
await tree.$.plants.refresh(); // force reload, ignores staleTime
tree.$.plants.invalidate(); // mark stale; next load() refetches
tree.$.plants.loading(); // + .loaded() / .error() / .lastLoadedAt()

// Push invalidation (SSE / SignalR — the @signaltree/realtime seam):
invalidateTag(tree, 'plants'); // marks every collection carrying the tag stale

Auto-loads on first tree.$ access unless lazy: true. Keep HTTP-level caching (ETag / conditional GET) in the browser + HttpClient; this owns application-level freshness, not the transport.

Scoped collections (v11.4+)

Add a third type param P and give load a parameter to parameterize the collection by scope (region, customer, tenant, …) — see RFC 0003:

import { entityMap, loader } from '@signaltree/core';

customers: entityMap<Customer, string, { regionUrl: string }>({
  selectId: (c) => c.externalId,
  load: loader(({ regionUrl }) => api.getCustomers$(regionUrl), {
    staleTime: '30m',
    // freshness compared per scope with `equal` (default: structural value comparison)
  }),
});

tree.$.customers.load({ regionUrl }); // same scope+fresh => no-op; scope changed => refetch + entities replaced
tree.$.customers.params(); // Signal<{ regionUrl: string } | undefined> — the typed scope of the loaded data

Freshness (staleTime) is evaluated per-scope, not globally: switching regionUrl marks the collection stale and refetches even if the old scope was still fresh. .refresh(params?) forces a reload (omit params to redo the last scope); clearOnParamsChange (default false) controls whether old rows stay visible during the scope switch. This is a single-scope cache — only the most recent scope's rows are retained; a multi-scope LRU is deferred (RFC 0003 §5). The parameterless (global) form above is unaffected — P defaults to void.

NG0600 fix (v11.4): a non-lazy, cache-aware entityMap's auto-load — and any offline-first persist seed — is deferred to a microtask off the synchronous materialization/render pass, so reading a non-lazy collection first inside a template no longer throws NG0600: Writing to signals is not allowed while Angular renders. Auto-load is now asynchronous (data arrives on the next microtask instead of during construction). The same fix applies to