Reactive JSON for Angular. JSON branches, reactive leaves. No actions. No reducers. No selectors.
npm install @signaltree/coreJSON branches, reactive leaves.
> No actions. No reducers. No selectors.
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.
| 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. |
- 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
Modern bundlers (webpack 5+, esbuild, Rollup, Vite) automatically tree-shake barrel imports from @signaltree/core. Both import styles produce identical bundle sizes:
``ts
// ✅ Recommended: Simple and clean
import { signalTree, batching } from '@signaltree/core';
// ✅ Also fine: Explicit subpath (same bundle size)
import { signalTree } from '@signaltree/core';
import { batching } from '@signaltree/core/enhancers/batching';
`
Measured impact (with modern bundlers):
- Core only: ~8.5 KB gzipped
- Core + batching: ~9.3 KB gzipped (barrel vs subpath: identical)
- Unused enhancers: automatically excluded by tree-shaking
Built-in markers (entityMap(), status(), stored()) are self-registering - they only add their processor code when you actually use them:
`ts
// ✅ 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.
SignalTree provides TypeScript support for callable syntax on leaf signals as developer experience sugar:
`typescript
// TypeScript accepts this syntax (with proper tooling):
tree.$.name('Jane'); // Set value
tree.$.count((n) => n + 1); // Update with function
// At build time, transforms convert to:
tree.$.name.set('Jane'); // Direct Angular signal API
tree.$.count.update((n) => n + 1); // Direct Angular signal API
// Reading always works directly:
const name = tree.$.name(); // No transform needed
`
Key Points:
- Zero runtime overhead: No Proxy wrappers or runtime hooks
- Build-time only: AST transform converts callable syntax to direct .set/.update calls@signaltree/callable-syntax
- Optional: Use transform or stick with direct .set/.update
- Type-safe: Full TypeScript support via module augmentation
Function-valued leaves:
When a leaf stores a function as its value, use direct .set(fn) to assign. Callable sig(fn) is treated as an updater.
Setup:
Install @signaltree/callable-syntax and configure your build tool to apply the transform. Without the transform, use .set/.update directly.
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 applies research-based multipliers derived from 40,000+ developer surveys and GitHub analysis, 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.
> 📖 Full guide: Implementation Patterns
Follow these principles for idiomatic SignalTree code:
`typescript
const tree = signalTree(initialState); // No .with(entities()) needed in v7+ (deprecated in v6, removed in v7)
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
};
`
`typescript
// Let SignalTree infer the type - no manual interface needed!
import type { createUserTree } from './user.tree';
export type UserTree = ReturnType
// 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
// ...
};
}
`
`typescript
// ✅ Correct: Derived from multiple signals
const selectedUser = computed(() => {
const id = $.selected.userId();
return id ? $.users.byId(id)() : null;
});
// ❌ Wrong: Wrapping an existing signal
const selectedUserId = computed(() => $.selected.userId()); // Unnecessary!
`
`typescript
// ✅ SignalTree-native
const user = $.users.byId(123)(); // O(1) lookup
const allUsers = $.users.all; // Get all
$.users.setAll(usersFromApi); // Replace all
// ❌ NgRx-style (avoid)
const user = entityMap()[123]; // Requires intermediate object
`
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
`typescript`
// Multiple updates in the same microtask are coalesced into a single notification
tree.$.form.name.set('Alice');
tree.$.form.email.set('alice@example.com');
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():
`typescript
import { getPathNotifier } from '@signaltree/core';
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:
`typescript`
const tree = signalTree(initialState, { batching: false });
Use this only for rare cases that truly require synchronous notifications (most apps should keep batching enabled).
`bash`
npm install @signaltree/core
`typescript
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!
`
`typescript
import { signalTree } from '@signaltree/core';
// Create a simple tree
const tree = signalTree({
count: 0,
message: 'Hello World',
});
// Read values (these are Angular signals)
console.log(tree.$.count()); // 0
console.log(tree.$.message()); // 'Hello World'
// Update values
tree.$.count(5);
tree.$.message('Updated!');
// Use in an Angular component
@Component({
template:
,
})
class SimpleComponent {
tree = tree; increment() {
this.tree.$.count((n) => n + 1);
}
}
`$3
`typescript
// Create hierarchical state
const tree = signalTree({
user: {
name: 'John Doe',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
},
ui: {
loading: false,
errors: [] as string[],
},
});// Access nested signals with full type safety
tree.$.user.name('Jane Doe');
tree.$.user.preferences.theme('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...');
}
});
`$3
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.`typescript
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. Leverage automatic memoization for expensive operations
// 4. Chain computed values for complex transformations
// 5. Use factory functions for parameterized computations
`$3
Computed values become even more powerful with the built-in memoization enhancer:
`typescript
import { signalTree, memoization } from '@signaltree/core';const tree = signalTree({
items: Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.random(),
category:
cat-${i % 10},
})),
}).with(memoization());// Expensive computation - automatically cached by memoization enhancer
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 cached result
`$3
`typescript
interface AppState {
auth: {
user: User | null;
token: string | null;
isAuthenticated: boolean;
};
data: {
users: User[];
posts: Post[];
cache: Record;
};
ui: {
theme: 'light' | 'dark';
sidebar: {
open: boolean;
width: number;
};
notifications: Notification[];
};
}const tree = signalTree({
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
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
$3
Create deeply nested reactive state with automatic type inference:
`typescript
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
`$3
SignalTree provides complete type inference without manual typing:
`typescript
// Automatic inference from initial state
const tree = signalTree({
count: 0, // Inferred as WritableSignal
name: 'John', // Inferred as WritableSignal
active: true, // Inferred as WritableSignal
items: [] as Item[], // Inferred as WritableSignal
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
Core provides basic state updates. For advanced entity management, use the built-in
entities enhancer:`typescript
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) {
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));
`$3
Core provides basic state updates. For advanced async helpers, use the built-in async helpers (
createAsyncOperation, trackAsync):`typescript
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:
,
})
class UsersComponent {
tree = tree;
loadUsers = loadUsers;
}
`$3
$3
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
- memoization() - Intelligent caching for expensive computations
- highPerformanceBatching() - Advanced batching for high-frequency updates
- withHighPerformanceMemoization() - Optimized memoization for large state treesData Management:
-
entities() - Advanced CRUD operations for collections
- createAsyncOperation() - Async operation management with loading/error states
- trackAsync() - Track async operations in your state
- serialization() - State persistence and SSR support
- persistence() - Auto-save to localStorage/IndexedDBDevelopment Tools:
-
devTools() - Redux DevTools integration
- withTimeTravel() - Undo/redo functionalityPresets:
-
createDevTree() - Pre-configured development setup
- TREE_PRESETS - Common configuration patterns#### Additional Packages
These are the only separate packages in the SignalTree ecosystem:
-
@signaltree/ng-forms - Angular Forms integration (separate package)
- @signaltree/enterprise - Enterprise-scale optimizations for 500+ signals (separate package)
- @signaltree/callable-syntax - Build-time transform for callable syntax (dev dependency, separate package)#### Composition Patterns
Basic Enhancement:
`typescript
import { signalTree, batching, devTools } from '@signaltree/core';// Apply enhancers in order
const tree = signalTree({ count: 0 }).with(
batching(), // Performance optimization
devTools() // Development tools
);
`Performance-Focused Stack:
`typescript
import { signalTree, batching, memoization, entities } from '@signaltree/core';const tree = signalTree({
products: entityMap(),
ui: { loading: false },
})
.with(entities()) // Efficient CRUD operations (auto-detects entityMap)
.with(batching()); // Batch updates for optimal rendering
// Entity CRUD operations
tree.$.products.addOne(newProduct);
tree.$.products.setAll(productsFromApi);
// Entity queries
const electronics = tree.$.products.all.filter((p) => p.category === 'electronics');
`Full-Stack Application:
`typescript
import { signalTree, serialization, withTimeTravel } from '@signaltree/core';const tree = signalTree({
user: null as User | null,
preferences: { theme: 'light' },
}).with(
// withAsync removed — API integration patterns are now covered by async helpers
serialization({
// Auto-save to localStorage
autoSave: true,
storage: 'localStorage',
}),
withTimeTravel() // 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
Enhancers can declare metadata for automatic dependency resolution:
`typescript
// Enhancers are automatically ordered based on requirements
const tree = signalTree(state).with(
devTools(), // Requires: core, provides: debugging
batching(), // Requires: core, provides: batching
memoization() // Requires: batching, provides: caching
);
// Automatically ordered: batching -> memoization -> devtools
`#### Quick Start with Presets
For common patterns, use presets that combine multiple enhancers:
`typescript
import { createDevTree, TREE_PRESETS } from '@signaltree/core';// Development preset includes: batching, memoization, devtools, time-travel
const devTree = createDevTree({
products: [] as Product[],
cart: { items: [], total: 0 },
});
// Or use preset configurations
const customTree = signalTree(state, TREE_PRESETS.DASHBOARD);
`#### Core Stubs
SignalTree Core includes all enhancer functionality built-in. No separate packages needed:
`typescript
import { signalTree, entityMap, entities } from '@signaltree/core';// Without entityMap - use manual array updates
const basic = signalTree({ users: [] as User[] });
basic.$.users.update((users) => [...users, newUser]);
// With entityMap + entities - use entity helpers
const enhanced = signalTree({
users: entityMap(),
}).with(entities());
enhanced.$.users.addOne(newUser); // ✅ Advanced CRUD operations
enhanced.$.users.byId(123)(); // ✅ O(1) lookups
enhanced.$.users.all; // ✅ Get all as array
`Core includes several performance optimizations:
`typescript
// 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
tree.update((state) => ({
...state, // Reuses unchanged parts
newField: 'value',
}));
`$3
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
`typescript
import { signal, Signal } from '@angular/core';
import { registerMarkerProcessor, signalTree } from '@signaltree/core';// 1. Define marker symbol and interface
const VALIDATED_MARKER = Symbol('VALIDATED_MARKER');
interface ValidatedMarker {
[VALIDATED_MARKER]: true;
defaultValue: T;
validator: (value: T) => string | null;
}
// 2. Create marker factory
function validated(defaultValue: T, validator: (value: T) => string | null): ValidatedMarker {
return { [VALIDATED_MARKER]: true, defaultValue, validator };
}
// 3. Type guard
function isValidatedMarker(value: unknown): value is ValidatedMarker {
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(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
`typescript
import { signal, Signal } from '@angular/core';
import type { ISignalTree } from '@signaltree/core';interface WithLogger {
log(message: string): void;
history: Signal;
}
function withLogger(config?: { maxHistory?: number }) {
const maxHistory = config?.maxHistory ?? 100;
return (tree: ISignalTree): ISignalTree & WithLogger => {
const historySignal = signal([]);
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
$3
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:
`typescript
import { signalTree, entityMap } from '@signaltree/core';
import { computed } from '@angular/core';const tree = signalTree({
users: entityMap(),
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 - Curried helper function that provides type context for your derived function
- WithDerived - Type utility to build intermediate tree types`typescript
// app-tree.ts
import { signalTree, entityMap, 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>>;
// Build intermediate types using WithDerived
export type AppTreeWithTier1 = WithDerived;
export type AppTreeWithTier2 = WithDerived;
function createBaseState() {
return {
users: entityMap(),
selectedUserId: null as number | null,
};
}
export function createAppTree() {
return signalTree(createBaseState()).derived(entityResolutionDerived).derived(complexLogicDerived);
}
``typescript
// 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()(($) => ({
selectedUser: computed(() => {
const id = $.selectedUserId();
return id != null ? $.users.byId(id)?.() ?? null : null;
}),
}));
``typescript
// 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()(($) => ({
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
// ❌ 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()(($) => ({
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 - this allows TypeScript to infer the return type while you specify the tree type.Built-in Markers
SignalTree provides four built-in markers that handle common state patterns Angular doesn't provide out of the box. All markers are self-registering and tree-shakeable - only the markers you use are included in your bundle.
$3
Creates a normalized entity collection with O(1) lookups by ID. Includes chainable
.computed() for derived slices.`typescript
import { signalTree, entityMap } from '@signaltree/core';interface Product {
id: number;
name: string;
category: string;
price: number;
inStock: boolean;
}
const tree = signalTree({
products: entityMap()
.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.setMany([
{ id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
{ id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
]);
tree.$.products.all(); // Signal - all entities
tree.$.products.byId(1); // Signal | undefined
tree.$.products.ids(); // Signal
tree.$.products.count(); // Signal
// Computed slices (reactive, type-safe)
tree.$.products.electronics(); // Signal - auto-updates
tree.$.products.inStock(); // Signal
tree.$.products.totalValue(); // Signal
// 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
`typescript
interface User {
odataId: string; // Not named 'id'
email: string;
}const tree = signalTree({
users: entityMap(),
});
// Specify selectId when upserting
tree.$.users.upsertOne(user, { selectId: (u) => u.odataId });
`$3
Creates a status signal for manual async state management with type-safe error handling.
`typescript
import { signalTree, status, LoadingState } from '@signaltree/core';interface ApiError {
code: number;
message: string;
}
const tree = signalTree({
users: {
data: [] as User[],
loadStatus: status(), // Generic error type
},
});
// Status API
tree.$.users.loadStatus.state(); // Signal
tree.$.users.loadStatus.error(); // Signal
// Convenience signals
tree.$.users.loadStatus.isNotLoaded(); // Signal
tree.$.users.loadStatus.isLoading(); // Signal
tree.$.users.loadStatus.isLoaded(); // Signal
tree.$.users.loadStatus.isError(); // Signal
// Update methods
tree.$.users.loadStatus.setLoading();
tree.$.users.loadStatus.setLoaded();
tree.$.users.loadStatus.setError({ code: 404, message: 'Not found' });
tree.$.users.loadStatus.reset();
// LoadingState enum
LoadingState.NotLoaded; // 'not-loaded'
LoadingState.Loading; // 'loading'
LoadingState.Loaded; // 'loaded'
LoadingState.Error; // 'error'
`$3
Auto-syncs state to localStorage with versioning and migration support.
`typescript
import { signalTree, stored, createStorageKeys, clearStoragePrefix } from '@signaltree/core';// Basic usage
const tree = signalTree({
theme: stored('app-theme', 'light' as 'light' | 'dark'),
lastViewedId: stored('last-viewed', null as number | null),
});
// Auto-loads from localStorage on init
// Auto-saves on every .set() or .update()
tree.$.theme.set('dark'); // Saved to localStorage immediately
// StoredSignal API
tree.$.theme(); // Get current value
tree.$.theme.set('light'); // Set and persist
tree.$.theme.clear(); // Remove from storage, reset to default
tree.$.theme.reload(); // Force reload from storage
`#### Versioning and Migrations
`typescript
interface SettingsV1 {
darkMode: boolean;
}interface SettingsV2 {
theme: 'light' | 'dark' | 'system';
fontSize: number;
}
const tree = signalTree({
settings: stored(
'user-settings',
{ theme: 'light', fontSize: 14 },
{
version: 2,
migrate: (oldData, oldVersion) => {
if (oldVersion === 1) {
// Migrate from V1 to V2
const v1 = oldData as SettingsV1;
return {
theme: v1.darkMode ? 'dark' : 'light',
fontSize: 14, // New field with default
};
}
return oldData as SettingsV2;
},
clearOnMigrationFailure: true, // Clear storage if migration fails
}
),
});
`#### Type-Safe Storage Keys
`typescript
// Create namespaced storage keys
const STORAGE = createStorageKeys('myApp', {
theme: 'theme',
user: {
settings: 'settings',
preferences: 'prefs',
},
} as const);// STORAGE.theme = "myApp:theme"
// STORAGE.user.settings = "myApp:user:settings"
const tree = signalTree({
theme: stored(STORAGE.theme, 'light'),
settings: stored(STORAGE.user.settings, {}),
});
// Clear all app storage (e.g., on logout)
clearStoragePrefix('myApp');
`#### Advanced Options
`typescript
stored('key', defaultValue, {
version: 1, // Schema version
migrate: (old, ver) => migrated, // Migration function
debounceMs: 100, // Write debounce (default: 100)
storage: sessionStorage, // Custom storage backend
serialize: (v) => JSON.stringify(v), // Custom serializer
deserialize: (s) => JSON.parse(s), // Custom deserializer
clearOnMigrationFailure: false, // Clear on failed migration
});
`$3
Creates forms with validation, wizard navigation, and persistence that live inside SignalTree.
`typescript
import { signalTree, form, validators } from '@signaltree/core';interface ContactForm {
name: string;
email: string;
phone: string;
message: string;
}
const tree = signalTree({
contact: form({
initial: { name: '', email: '', phone: '', message: '' },
validators: {
name: validators.required('Name is required'),
email: [validators.required('Email is required'), validators.email('Invalid email format')],
phone: validators.pattern(/^\+?[\d\s-]+$/, 'Invalid phone number'),
message: validators.minLength(10, 'Message must be at least 10 characters'),
},
}),
});
// FormSignal API - Field access via $
tree.$.contact.$.name(); // Get field value
tree.$.contact.$.name.set('Jane'); // Set field value
tree.$.contact.$.email();
// Form-level operations
tree.$.contact(); // Get all values: ContactForm
tree.$.contact.set({ name: 'Jane', email: 'jane@example.com', phone: '', message: '' });
tree.$.contact.patch({ name: 'Updated' }); // Partial update
tree.$.contact.reset(); // Reset to initial values
tree.$.contact.clear(); // Clear all values
// Validation signals
tree.$.contact.valid(); // Signal
tree.$.contact.dirty(); // Signal
tree.$.contact.submitting(); // Signal
tree.$.contact.touched(); // Signal>
tree.$.contact.errors(); // Signal>>
tree.$.contact.errorList(); // Signal
// Validation methods
await tree.$.contact.validate(); // Validate all fields
await tree.$.contact.validateField('email');
tree.$.contact.touch('name'); // Mark field as touched
tree.$.contact.touchAll(); // Mark all fields as touched
`#### Built-in Validators
`typescript
import { validators } from '@signaltree/core';validators.required('Field is required')
validators.minLength(5, 'Min 5 characters')
validators.maxLength(100, 'Max 100 characters')
validators.min(0, 'Must be positive')
validators.max(100, 'Max 100')
validators.email('Invalid email')
validators.pattern(/regex/, 'Invalid format')
// Compose multiple validators
validators: {
password: [
validators.required('Password is required'),
validators.minLength(8, 'Min 8 characters'),
validators.pattern(/[A-Z]/, 'Must contain uppercase'),
validators.pattern(/[0-9]/, 'Must contain number'),
],
}
`#### Wizard Navigation
`typescript
const tree = signalTree({
listing: form({
initial: { title: '', description: '', photos: [], price: null, location: '' },
validators: {
title: validators.required('Title is required'),
price: [validators.required('Price required'), validators.min(0, 'Must be positive')],
location: validators.required('Location required'),
},
wizard: {
steps: ['details', 'media', 'pricing', 'review'],
stepFields: {
details: ['title', 'description'],
media: ['photos'],
pricing: ['price'],
review: ['location'],
},
},
}),
});// Wizard API
const wizard = tree.$.listing.wizard!;
wizard.currentStep(); // Signal - 0-based index
wizard.stepName(); // Signal - current step name
wizard.steps(); // Signal - all step names
wizard.canNext(); // Signal
wizard.canPrev(); // Signal
wizard.isFirstStep(); // Signal
wizard.isLastStep(); // Signal
// Navigation (validates current step before proceeding)
await wizard.next(); // Returns false if validation fails
wizard.prev();
await wizard.goTo(2); // Jump to step by index
await wizard.goTo('pricing'); // Jump to step by name
wizard.reset(); // Go back to first step
`#### Form Persistence
`typescript
const tree = signalTree({
draft: form({
initial: { subject: '', body: '', to: '' },
persist: 'email-draft', // localStorage key
persistDebounceMs: 500, // Debounce writes (default: 500ms)
validators: {
subject: validators.required('Subject required'),
to: validators.email('Invalid email'),
},
}),
});// Form auto-saves to localStorage
// On page reload, draft is restored automatically
`#### Async Validators
`typescript
const tree = signalTree({
registration: form({
initial: { username: '', email: '' },
validators: {
username: validators.minLength(3, 'Min 3 characters'),
},
asyncValidators: {
username: async (value) => {
const taken = await api.checkUsername(value);
return taken ? 'Username already taken' : null;
},
email: async (value) => {
const exists = await api.checkEmail(value);
return exists ? 'Email already registered' : null;
},
},
}),
});
`#### Form Submission
`typescript
async function handleSubmit() {
const contactForm = tree.$.contact; // Validate all fields first
contactForm.touchAll();
const isValid = await contactForm.validate();
if (!isValid) return;
// Set submitting state
contactForm.setSubmitting(true);
try {
await api.submit(contactForm());
contactForm.reset();
} catch (error) {
// Handle error
} finally {
contactForm.setSubmitting(false);
}
}
`Error handling examples
$3
`typescript
const tree = signalTree({
data: null as ApiData | null,
loading: false,
error: null as Error | null,
retryCount: 0,
});async function loadDataWithRetry(attempt = 0) {
tree.$.loading.set(true);
tree.$.error.set(null);
try {
const data = await api.getData();
tree.$.data.set(data);
tree.$.loading.set(false);
tree.$.retryCount.set(0);
} catch (error) {
if (attempt < 3) {
// Retry logic
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
return loadDataWithRetry(attempt + 1);
}
tree.$.loading.set(false);
tree.$.error.set(error instanceof Error ? error : new Error('Unknown error'));
tree.$.retryCount.update((count) => count + 1);
}
}
// Error boundary component
@Component({
template:
{{ tree.$.error()?.message }}
Attempts: {{ tree.$.retryCount() }}
,
})
class ErrorHandlingComponent {
tree = tree; retry() {
loadDataWithRetry();
}
clear() {
this.tree.$.error.set(null);
}
}
`$3
`typescript
const tree = signalTree({
items: [] as Item[],
validationErrors: [] as string[],
});// Safe update with validation
function safeUpdateItem(id: string, updates: Partial- ) {
try {
tree.update((state) => {
const itemIndex = state.items.findIndex((item) => item.id === id);
if (itemIndex === -1) {
throw new Error(
Item with id ${id} not found);
} const updatedItem = { ...state.items[itemIndex], ...updates };
// Validation
if (!updatedItem.name?.trim()) {
throw new Error('Item name is required');
}
const newItems = [...state.items];
newItems[itemIndex] = updatedItem;
return {
items: newItems,
validationErrors: [], // Clear errors on success
};
});
} catch (error) {
tree.$.validationErrors.update((errors) => [...errors, error instanceof Error ? error.message : 'Unknown error']);
}
}
`Package composition patterns
SignalTree Core is designed for modular composition. Start minimal and add features as needed.
$3
`typescript
import { signalTree } from '@signaltree/core';// Core provides the foundation
const tree = signalTree({
users: [] as User[],
ui: { loading: false },
});
// Basic operations included in core
tree.$.users.set([...users, newUser]);
tree.$.ui.loading.set(true);
tree.effect(() => console.log('State changed'));
`$3
`typescript
import { signalTree, batching, memoization } from '@signaltree/core';// Add performance optimizations
const tree = signalTree({
products: [] as Product[],
filters: { category: '', search: '' },
}).with(
batching(), // Batch updates for optimal rendering
memoization() // Cache expensive computations
);
// Now supports batched updates
tree.batchUpdate((state) => ({
products: [...state.products, ...newProducts],
filters: { category: 'electronics', search: '' },
}));
// Expensive computations are automatically cached
const filteredProducts = computed(() => {
return tree.$.products()
.filter((p) => p.category.includes(tree.$.filters.category()))
.filter((p) => p.name.includes(tree.$.filters.search()));
});
`$3
`typescript
import { signalTree, entityMap, entities } from '@signaltree/core';// Add data management capabilities (+2.77KB total)
const tree = signalTree({
users: entityMap(),
posts: entityMap(),
ui: { loading: false, error: null as string | null },
}).with(entities());
// Advanced entity operations via tree.$ accessor
tree.$.users.addOne(newUser);
tree.$.users.selectBy((u) => u.active);
tree.$.users.updateMany([{ id: '1', changes: { status: 'active' } }]);
// Entity helpers work with nested structures
// Example: deeply nested entities in a domain-driven design pattern
const appTree = signalTree({
app: {
data: {
users: entityMap(),
products: entityMap(),
},
},
admin: {
data: {
logs: entityMap(),
reports: entityMap(),
},
},
}).with(entities());
// Access nested entities using tree.$ accessor
appTree.$.app.data.users.selectBy((u) => u.isAdmin); // Filtered signal
appTree.$.app.data.products.selectTotal(); // Count signal
appTree.$.admin.data.logs.all; // All items as array
appTree.$.admin.data.reports.selectIds(); // ID array signal
// For async operations, use manual async or async helpers
async function fetchUsers() {
tree.$.ui.loading.set(true);
try {
const users = await api.getUsers();
tree.$.users.setAll(users);
} catch (error) {
tree.$.ui.error.set(error.message);
} finally {
tree.$.ui.loading.set(false);
}
}
`$3
`typescript
import { signalTree, batching, entities, serialization, withTimeTravel, devTools } from '@signaltree/core';// Full development stack (example)
const tree = signalTree({
app: {
user: null as User | null,
preferences: { theme: 'light' },
data: { users: [], posts: [] },
},
}).with(
batching(), // Performance
entities(), // Data management
// withAsync removed — use async helpers for API integration
serialization({
// State persistence
autoSave: true,
storage: 'localStorage',
}),
withTimeTravel({
// Undo/redo
maxHistory: 50,
}),
devTools({
// Debug tools (dev only)
name: 'MyApp',
trace: true,
})
);
// Rich feature set available
async function fetchUser(id: string) {
return await api.getUser(id);
}
tree.$.app.data.users.byId(userId)(); // O(1) lookup
tree.undo(); // Time travel
tree.save(); // Persistence
`$3
`typescript
import { signalTree, batching, entities, serialization } from '@signaltree/core';// Production build (no dev tools)
const tree = signalTree(initialState).with(
batching(), // Performance optimization
entities(), // Data management
// withAsync removed — use async helpers for API integration
serialization({
// User preferences
autoSave: true,
storage: 'localStorage',
key: 'app-v1.2.3',
})
);
// Clean, efficient, production-ready
`$3
`typescript
import { signalTree, batching, entities, devTools, withTimeTravel } from '@signaltree/core';const isDevelopment = process.env['NODE_ENV'] === 'development';
// Conditional enhancement based on environment
const tree = signalTree(state).with(
batching(), // Always include performance
entities(), // Always include data management
...(isDevelopment
? [
// Development-only features
devTools(),
withTimeTravel(),
]
: [])
);
`$3
`typescript
import { createDevTree, TREE_PRESETS } from '@signaltree/core';// Use presets for common patterns
const devTree = createDevTree({
products: [],
cart: { items: [], total: 0 },
user: null,
});
// Includes: batching, memoization, devtools, time-travel
// Or use preset configurations directly
const customTree = signalTree(state, TREE_PRESETS.PERFORMANCE);
// Includes: batching, memoization optimizations
`$3
Bundle sizes depend on your build, tree-shaking, and which enhancers you include. Use the scripts in
scripts/ to analyze min+gz for your configuration.$3
Start with core and grow incrementally:
`typescript
// Phase 1: Start with core
const tree = signalTree(state);// Phase 2: Add performance when needed
const tree2 = tree.with(batching());
// Phase 3: Add data management for collections
const tree3 = tree2.with(entities());
// Phase 4: Add async for API integration
// withAsync removed — no explicit async enhancer; use async helpers instead
// Each phase is fully functional and production-ready
``typescript
// Start minimal, add features as needed
let tree = signalTree(initialState);if (isDevelopment) {
tree = tree.with(devTools());
}
if (needsPerformance) {
tree = tree.with(batching(), memoization());
}
if (needsTimeTravel) {
tree = tree.with(withTimeTravel());
}
`$3
`typescript
@Injectable()
class AppStateService {
private tree = signalTree({
user: null as User | null,
settings: { theme: 'light' as const },
}); // Expose specific parts
readonly user$ = this.tree.$.user;
readonly settings$ = this.tree.$.settings;
// Expose specific actions
setUser(user: User) {
this.tree.$.user.set(user);
}
updateSettings(settings: Partial) {
this.tree.$.settings.update((current) => ({
...current,
...settings,
}));
}
// For advanced features, return the tree
getTree() {
return this.tree;
}
}
`Measuring performance
For fair, reproducible measurements that reflect your app and hardware, use the Benchmark Orchestrator in the demo. It calibrates runs per scenario and library, applies real-world frequency weighting based on research analysis, reports robust statistics, and supports CSV/JSON export. Avoid copying fixed numbers from docs; results vary.
Example
`typescript
// Complete user management component
@Component({
template: {{ user.email }}
,
})
class UserManagerComponent implements OnInit {
userTree = signalTree({
users: [] as User[],
loading: false,
error: null as string | null,
form: { id: '', name: '', email: '' },
}); constructor(private userService: UserService) {}
ngOnInit() {
this.loadUsers();
}
async loadUsers() {
this.userTree.$.loading.set(true);
this.userTree.$.error.set(null);
try {
const users = await this.userService.getUsers();
this.userTree.$.users.set(users);
} catch (error) {
this.userTree.$.error.set(error instanceof Error ? error.message : 'Load failed');
} finally {
this.userTree.$.loading.set(false);
}
}
editUser(user: User) {
this.userTree.$.form.set(user);
}
async saveUser() {
try {
const form = this.userTree.$.form();
if (form.id) {
await this.userService.updateUser(form.id, form);
this.updateUser(form.id, form);
} else {
const newUser = await this.userService.createUser(form);
this.addUser(newUser);
}
this.clearForm();
} catch (error) {
this.userTree.$.error.set(error instanceof Error ? error.message : 'Save failed');
}
}
private addUser(user: User) {
this.userTree.$.users.update((users) => [...users, user]);
}
private updateUser(id: string, updates: Partial) {
this.userTree.$.users.update((users) => users.map((user) => (user.id === id ? { ...user, ...updates } : user)));
}
deleteUser(id: string) {
if (confirm('Delete user?')) {
this.removeUser(id);
this.userService.deleteUser(id).catch((error) => {
this.userTree.$.error.set(error.message);
this.loadUsers(); // Reload on error
});
}
}
private removeUser(id: string) {
this.userTree.$.users.update((users) => users.filter((user) => user.id !== id));
}
clearForm() {
this.userTree.$.form.set({ id: '', name: '', email: '' });
}
}
` ]
}
}));
// Get entire state as plain object
const currentState = tree.unwrap();
console.log('Current app state:', currentState);
`
});
`Core features
$3
`typescript
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
tree.$.settings.theme.set('light');
tree.$.todos.update((todos) => [...todos, newTodo]);
`$3
`typescript
// Manual CRUD operations
const tree = signalTree({
todos: [] as Todo[],
});function addTodo(todo: Todo) {
tree.$.todos.update((todos) => [...todos, todo]);
}
function updateTodo(id: string, updates: Partial) {
tree.$.todos.update((todos) => todos.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo)));
}
function removeTodo(id: string) {
tree.$.todos.update((todos) => todos.filter((todo) => todo.id !== id));
}
// Manual queries with computed signals
const todoById = (id: string) => computed(() => tree.$.todos().find((todo) => todo.id === id));
const allTodos = computed(() => tree.$.todos());
const todoCount = computed(() => tree.$.todos().length);
`$3
`typescript
async function loadUsers() {
tree.$.loading.set(true); 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);
}
}
// Use in components
async function handleLoadUsers() {
await loadUsers();
}
`$3
`typescript
// Create reactive effects
tree.effect((state) => {
console.log(User: ${state.user.name}, Theme: ${state.settings.theme});
});// Manual subscriptions
const unsubscribe = tree.subscribe((state) => {
// Handle state changes
});
`Core API reference
$3
`typescript
const tree = signalTree(initialState, config?);
`$3
`typescript
// State access
tree.$.property(); // Read signal value
tree.$.property.set(value); // Update signal
tree.unwrap(); // Get plain object// Tree operations
tree.update(updater); // Update entire tree
tree.effect(fn); // Create reactive effects
tree.subscribe(fn); // Manual subscriptions
tree.destroy(); // Cleanup resources
// Entity helpers (when using entityMap + entities)
// tree.$.users.addOne(user); // Add single entity
// tree.$.users.byId(id)(); // O(1) lookup by ID
// tree.$.users.all; // Get all as array
// tree.$.users.selectBy(pred); // Filtered signal
`Extending with enhancers
SignalTree Core includes all enhancers built-in:
`typescript
import { signalTree, batching, memoization, withTimeTravel } from '@signaltree/core';// All enhancers available from @signaltree/core
const tree = signalTree(initialState).with(batching(), memoization(), withTimeTravel());
`$3
All enhancers are included in
@signaltree/core:- batching() - Batch multiple updates for better performance
- memoization() - Intelligent caching & performance optimization
- entities() - Advanced entity management & CRUD operations
- devTools() - Redux DevTools integration for debugging
- withTimeTravel() - Undo/redo functionality & state history
- serialization() - State persistence & SSR support
- createDevTree() - Pre-configured development setup
- TREE_PRESETS - Common configuration patterns (PERFORMANCE, DASHBOARD, etc.)
When to use core only
Perfect for:
- ✅ Simple to medium applications
- ✅ Prototype and MVP development
- ✅ When bundle size is critical
- ✅ Learning signal-based state management
- ✅ Applications with basic state needs
Consider enhancers when you need:
- ⚡ Performance optimization (batching, memoization)
- 🐛 Advanced debugging (devTools, withTimeTravel)
- 📦 Entity management (entities)
Consider separate packages when you need:
- 📝 Angular forms integration (@signaltree/ng-forms)
- 🏢 Enterprise-scale optimizations (@signaltree/enterprise)
- 🎯 Callable syntax transform (@signaltree/callable-syntax)
Migration from NgRx
`typescript
// Step 1: Create parallel tree
const tree = signalTree(initialState);// Step 2: Gradually migrate components
// Before (NgRx)
users$ = this.store.select(selectUsers);
// After (SignalTree)
users = this.tree.$.users;
// Step 3: Replace effects with manual async operations
// Before (NgRx)
loadUsers$ = createEffect(() =>
this.actions$.with(
ofType(loadUsers),
switchMap(() => this.api.getUsers())
)
);
// After (SignalTree Core)
async loadUsers() {
try {
const users = await api.getUsers();
tree.$.users.set(users);
} catch (error) {
tree.$.error.set(error.message);
}
}
// Or use manual async patterns
loadUsers = async () => {
tree.$.loading.set(true);
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);
}
};
`Examples
$3
`typescript
const counter = signalTree({ count: 0 });// In component
@Component({
template:
,
})
class CounterComponent {
counter = counter; increment() {
this.counter.$.count.update((n) => n + 1);
}
}
`$3
`typescript
const userTree = signalTree({
users: [] as User[],
loading: false,
error: null as string | null,
});async function loadUsers() {
userTree.$.loading.set(true);
try {
const users = await api.getUsers();
userTree.$.users.set(users);
userTree.$.error.set(null);
} catch (error) {
userTree.$.error.set(error instanceof Error ? error.message : 'Load failed');
} finally {
userTree.$.loading.set(false);
}
}
function addUser(user: User) {
userTree.$.users.update((users) => [...users, user]);
}
// In component
@Component({
template:
,
})
class UsersComponent {
userTree = userTree; ngOnInit() {
loadUsers();
}
addUser(userData: Partial) {
const newUser = { id: crypto.randomUUID(), ...userData } as User;
addUser(newUser);
}
}
`Available extension packages
All enhancers are now consolidated in the core package. The following features are available directly from
@signaltree/core:$3
- batching() (+1.27KB gzipped) - Batch multiple updates for better performance
- memoization() (+2.33KB gzipped) - Intelligent caching & performance optimization
$3
- entities() (+0.97KB gzipped) - Enhanced CRUD operations & entity management
$3
- devTools() (+2.49KB gzipped) - Development tools & Redux DevTools integration
- withTimeTravel() (+1.75KB gzipped) - Undo/redo functionality & state history
$3
- serialization() (+0.84KB gzipped) - State persistence & SSR support
- ecommercePreset() - Pre-configured setups for e-commerce applications
- dashboardPreset() - Pre-configured setups for dashboard applications
$3
All enhancers are now available from the core package:
`bash
Install only the core package - all features included
npm install @signaltree/coreEverything is available from @signaltree/core:
import {
signalTree,
batching,
memoization,
entities,
devTools,
withTimeTravel,
serialization,
ecommercePreset,
dashboardPreset
} from '@signaltree/core';
`Companion Packages
While
@signaltree/core includes comprehensive built-in enhancers for most use cases, the SignalTree ecosystem also provides specialized companion packages for specific needs:$3
Angular Forms integration for SignalTree (Angular 17+)
Seamlessly connect Angular Forms with your SignalTree state for two-way data binding, validation, and form control.
`bash
npm install @signaltree/ng-forms
`Features:
- 🔗 Two-way binding between forms and SignalTree state
- ✅ Built-in validation integration
- 🎯 Type-safe form controls
- 🔄 Automatic sync between form state and tree state
- 📊 Form status tracking (valid, pristine, touched, etc.)
- ⚡ Native Signal Forms support (Angular 20.3+)
- 🔧 Legacy bridge for Angular 17-19 (deprecated, will be removed with Angular 21)
Signal Forms (Angular 20.3+ recommended)
Use Angular's Signal Forms
connect() API directly with SignalTree:`ts
import { toWritableSignal } from '@signaltree/core';const tree = signalTree({
user: { name: '', email: '' },
});
// Leaves are WritableSignal
nameControl.connect(tree.$.user.name);
// Convert a slice to a WritableSignal
const userSignal = toWritableSignal(tree.$.user);
userGroupControl.connect(userSignal);
`The
@signaltree/ng-forms package supports Angular 17+ and will prefer connect() when available (Angular 20.3+). Angular 17-19 uses a legacy bridge that will be deprecated when Angular 21 is released.Quick Example:
`typescript
import { signalTree } from '@signaltree/core';
import { bindFormToTree } from '@signaltree/ng-forms';const tree = signalTree({
user: { name: '', email: '', age: 0 },
});
@Component({
template:
,
})
class UserFormComponent {
form = new FormGroup({
name: new FormControl(''),
email: new FormControl(''),
age: new FormControl(0),
}); constructor() {
// Automatically sync form with tree state
bindFormToTree(this.form, tree.$.user);
}
}
``When to use:
- Building forms with Angular Reactive Forms
- Need validation integration
- Two-way data binding between forms and state
- Complex form scenarios with nested form groups
Learn more: [npm