Angular
Integrate StateLoom with an Angular 17+ application using Angular Signals, RxJS Observables, and dependency injection.
Data Flow
Prerequisites
- Angular 17+ (for Angular Signals support)
- RxJS 7+
- Node.js 18+
Project Setup
Scaffold a new Angular project and install StateLoom:
ng new my-app --standalone
cd my-apppnpm add @stateloom/core @stateloom/angularnpm install @stateloom/core @stateloom/angularyarn add @stateloom/core @stateloom/angularAdd a paradigm adapter:
pnpm add @stateloom/storepnpm add @stateloom/atompnpm add @stateloom/proxyBasic Integration
Angular Signals with toAngularSignal
Bridge StateLoom subscribables to Angular's native Signal:
// src/app/state/counter.ts
import { signal, computed } from '@stateloom/core';
export const count = signal(0);
export const doubled = computed(() => count.get() * 2);
export function increment(): void {
count.set(count.get() + 1);
}
export function decrement(): void {
count.set(count.get() - 1);
}
export function reset(): void {
count.set(0);
}// src/app/components/counter/counter.component.ts
import { Component, type Signal as AngularSignal } from '@angular/core';
import { toAngularSignal } from '@stateloom/angular';
import { count, doubled, increment, decrement, reset } from '../../state/counter';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<section>
<h2>Counter</h2>
<p>Count: {{ countSig() }} | Doubled: {{ doubledSig() }}</p>
<button (click)="onDecrement()">-</button>
<button (click)="onReset()">Reset</button>
<button (click)="onIncrement()">+</button>
</section>
`,
})
export class CounterComponent {
readonly countSig: AngularSignal<number> = toAngularSignal(count);
readonly doubledSig: AngularSignal<number> = toAngularSignal(doubled);
onIncrement(): void {
increment();
}
onDecrement(): void {
decrement();
}
onReset(): void {
reset();
}
}Injection Context Required
toAngularSignal must be called in an injection context: component constructor, inject() initializer, or runInInjectionContext. Cleanup is automatic via DestroyRef.
RxJS Observables with toObservable
Bridge StateLoom to RxJS for use with the async pipe and operators:
import { Component } from '@angular/core';
import { signal } from '@stateloom/core';
import { toObservable } from '@stateloom/angular';
import { AsyncPipe } from '@angular/common';
const count = signal(0);
@Component({
selector: 'app-counter',
standalone: true,
imports: [AsyncPipe],
template: `<span>{{ count$ | async }}</span>`,
})
export class CounterComponent {
readonly count$ = toObservable(count);
}toObservable is a pure function -- no injection context required. The Observable emits the current value immediately on subscription.
Store Injection with injectStore
Subscribe to stores as Angular Signals with optional selectors:
import { Component } from '@angular/core';
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const counterStore = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
@Component({
selector: 'app-counter',
standalone: true,
template: `
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
// With selector: only updates when count changes
readonly count = injectStore(counterStore, (s) => s.count);
increment() {
counterStore.getState().increment();
}
}RxJS Interop
From Observable to Subscribable
Bridge RxJS Observables into the StateLoom reactive graph:
import { interval } from 'rxjs';
import { fromObservable, toAngularSignal } from '@stateloom/angular';
// Convert Observable to Subscribable
const tick = fromObservable(interval(1000), 0);
@Component({
standalone: true,
template: `<span>{{ tickSig() }}</span>`,
})
export class TimerComponent {
readonly tickSig = toAngularSignal(tick);
}Combining with RxJS Operators
import { Component } from '@angular/core';
import { signal } from '@stateloom/core';
import { toObservable } from '@stateloom/angular';
import { map, filter, debounceTime } from 'rxjs';
import { AsyncPipe } from '@angular/common';
const searchTerm = signal('');
@Component({
standalone: true,
imports: [AsyncPipe],
template: `<span>{{ results$ | async }}</span>`,
})
export class SearchComponent {
readonly results$ = toObservable(searchTerm).pipe(
debounceTime(300),
filter((term) => term.length > 2),
map((term) => `Searching for: ${term}`),
);
}Patterns
Async Data with Store
import { Component, type OnInit } from '@angular/core';
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
interface User {
id: number;
name: string;
}
const userStore = createStore((set) => ({
user: null as User | null,
loading: false,
fetch: async (id: number) => {
set({ loading: true });
const res = await fetch(`/api/users/${id}`);
const user = (await res.json()) as User;
set({ user, loading: false });
},
}));
@Component({
standalone: true,
template: `
@if (loading()) {
<p>Loading...</p>
} @else if (user()) {
<p>{{ user()!.name }}</p>
}
`,
})
export class UserComponent implements OnInit {
readonly user = injectStore(userStore, (s) => s.user);
readonly loading = injectStore(userStore, (s) => s.loading);
ngOnInit() {
userStore.getState().fetch(1);
}
}Middleware Integration
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';
import { persist } from '@stateloom/persist';
const settingsStore = createStore(
(set) => ({
theme: 'light' as 'light' | 'dark',
locale: 'en',
setTheme: (t: 'light' | 'dark') => set({ theme: t }),
setLocale: (l: string) => set({ locale: l }),
}),
{
middleware: [devtools({ name: 'Settings' }), persist({ key: 'settings' })],
},
);SSR with Angular Universal
Scope Provider
Use provideStateloomScope for per-request scope isolation:
// app.config.server.ts
import type { ApplicationConfig } from '@angular/core';
import { createScope } from '@stateloom/core';
import { provideStateloomScope } from '@stateloom/angular';
export const serverConfig: ApplicationConfig = {
providers: [provideStateloomScope(createScope())],
};Consuming the Scope
import { Component } from '@angular/core';
import { injectScope } from '@stateloom/angular';
@Component({
selector: 'app-root',
standalone: true,
template: '...',
})
export class AppComponent {
private readonly scope = injectScope();
constructor() {
if (this.scope) {
// SSR scope available
}
}
}Migrating from NgRx
If you are coming from NgRx, here is a comparison:
// NgRx (simplified)
// store.ts: createReducer, createAction, createSelector
// component: store.select(selectCount) | async
// StateLoom
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
// In component:
readonly count = injectStore(counterStore, (s) => s.count);
// Template: {{ count() }}Key differences:
- No actions, reducers, or effects boilerplate. State and actions live together.
- Selectors are plain functions passed to
injectStore. - Middleware replaces NgRx effects for cross-cutting concerns.
Tips
Angular Signals vs RxJS
Use toAngularSignal for template bindings (simpler, Angular-native). Use toObservable when you need RxJS operators (debounce, switchMap, etc.).
Selector Performance
Always pass a selector to injectStore to prevent the Angular Signal from updating on every state change:
// Good: updates only when count changes
readonly count = injectStore(store, (s) => s.count);
// Avoid: updates on any state change
readonly state = injectStore(store);Injection Context
toAngularSignal and injectStore must be called in an injection context. toObservable and fromObservable are pure functions that work anywhere.
Example App
See the complete Angular example for a working app that demonstrates Angular Signals, RxJS integration, dependency injection, and tab sync.
Next Steps
- Angular API Reference -- Full adapter documentation
- Store API Reference -- Store patterns and middleware
- Getting Started -- Core concepts and paradigm comparison
Live Demo
Try the Angular example directly in your browser: