@stateloom/angular
Angular adapter for StateLoom. Bridges Subscribable<T> to Angular Signals and RxJS Observables with dependency injection and SSR scope support.
Install
pnpm add @stateloom/core @stateloom/angularnpm install @stateloom/core @stateloom/angularyarn add @stateloom/core @stateloom/angularSize: ~0.3 KB gzipped
Requires: Angular 17+, RxJS 7+
Optional: Store Package
Add @stateloom/store for injectStore with selector memoization. Atoms work through toAngularSignal without additional dependencies since they implement Subscribable<T>.
Overview
The adapter provides bidirectional bridges (StateLoom to Angular and back), store injection with selectors, and SSR scope management through Angular's dependency injection system.
Quick Start
import { Component } from '@angular/core';
import { signal } from '@stateloom/core';
import { toAngularSignal } from '@stateloom/angular';
const count = signal(0);
@Component({
selector: 'app-counter',
template: `<span>{{ count() }}</span>`,
})
export class CounterComponent {
readonly count = toAngularSignal(count);
}Guide
Bridging to Angular Signals
Use toAngularSignal to convert any Subscribable<T> to a read-only Angular Signal<T>. The Angular signal stays in sync and cleanup is automatic via DestroyRef.
import { Component } from '@angular/core';
import { signal, computed } from '@stateloom/core';
import { toAngularSignal } from '@stateloom/angular';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
@Component({
selector: 'app-display',
template: `
<span>Count: {{ count() }}</span>
<span>Doubled: {{ doubled() }}</span>
`,
})
export class DisplayComponent {
readonly count = toAngularSignal(count);
readonly doubled = toAngularSignal(doubled);
}WARNING
toAngularSignal must be called in an injection context (component constructor, inject() initializer, or runInInjectionContext).
Bridging to RxJS Observables
Use toObservable to convert any Subscribable<T> to an RxJS Observable<T>. The Observable emits the current value immediately (like BehaviorSubject) and then each subsequent change.
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',
imports: [AsyncPipe],
template: `<span>{{ count$ | async }}</span>`,
})
export class CounterComponent {
readonly count$ = toObservable(count);
}toObservable is a pure function -- no injection context required. Use it anywhere.
Bridging from RxJS Observables
Use fromObservable to convert an RxJS Observable<T> into a StateLoom Subscribable<T>:
import { interval } from 'rxjs';
import { fromObservable } from '@stateloom/angular';
const tick = fromObservable(interval(1000), 0);
tick.get(); // 0
const unsub = tick.subscribe((value) => console.log(value));
// logs: 1, 2, 3, ... (each second)
unsub(); // stops listeningInjecting Stores
Use injectStore to subscribe to a store as an Angular Signal, with optional selector support:
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',
template: `<span>{{ count() }}</span>`,
})
export class CounterComponent {
// With selector -- only updates when count changes
readonly count = injectStore(counterStore, (s) => s.count);
}SSR Scope Management
Use provideStateloomScope and injectScope for per-request scope isolation:
import { bootstrapApplication } from '@angular/platform-browser';
import { createScope } from '@stateloom/core';
import { provideStateloomScope, injectScope } from '@stateloom/angular';
// Application setup
bootstrapApplication(AppComponent, {
providers: [provideStateloomScope(createScope())],
});
// In a component
@Component({ template: '...' })
export class MyComponent {
private readonly scope = injectScope();
}API Reference
toObservable<T>(subscribable: Subscribable<T>): Observable<T>
Convert a StateLoom Subscribable to an RxJS Observable.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
subscribable | Subscribable<T> | The StateLoom reactive source to wrap. | -- |
Returns: Observable<T> -- an RxJS Observable that mirrors the subscribable.
import { signal } from '@stateloom/core';
import { toObservable } from '@stateloom/angular';
const count = signal(0);
const count$ = toObservable(count);
count$.subscribe((value) => console.log(value));
// logs: 0 (immediately)
count.set(1);
// logs: 1Key behaviors:
- Emits the current value immediately on subscription (BehaviorSubject-like)
- Unsubscribing from the Observable unsubscribes from the StateLoom source
- Pure function -- no injection context required
- Works with
asyncpipe,switchMap, and other RxJS operators
See also: fromObservable()
toAngularSignal<T>(subscribable: Subscribable<T>, options?: ToAngularSignalOptions<T>): Signal<T>
Bridge a StateLoom Subscribable to a read-only Angular Signal.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
subscribable | Subscribable<T> | The StateLoom reactive source to bridge. | -- |
options | ToAngularSignalOptions<T> | Optional configuration. | undefined |
options.equal | (a: T, b: T) => boolean | Custom equality for Angular change detection. | Object.is |
Returns: Signal<T> -- a read-only Angular signal that mirrors the subscribable.
import { Component } from '@angular/core';
import { signal } from '@stateloom/core';
import { toAngularSignal } from '@stateloom/angular';
const count = signal(0);
@Component({
template: '{{ count() }}',
})
export class CounterComponent {
readonly count = toAngularSignal(count);
}Key behaviors:
- Initialized with
subscribable.get()-- Angular signal has a value from the start - Updates are pushed synchronously when the subscribable changes
- Cleanup is automatic via
DestroyRef.onDestroy - Must be called in an injection context
- The
equaloption controls Angular's change detection, not StateLoom's equality
See also: toObservable(), injectStore()
fromObservable<T>(observable$: Observable<T>, initialValue: T): Subscribable<T>
Convert an RxJS Observable into a StateLoom Subscribable.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
observable$ | Observable<T> | The RxJS Observable to bridge. | -- |
initialValue | T | The value returned by get() before the Observable emits. | -- |
Returns: Subscribable<T> -- a subscribable that mirrors the Observable's latest emission.
import { interval } from 'rxjs';
import { fromObservable } from '@stateloom/angular';
const tick = fromObservable(interval(1000), 0);
tick.get(); // 0
const unsub = tick.subscribe((value) => console.log(value));
// logs: 1, 2, 3, ...
unsub();Key behaviors:
- Lazy subscription: connects to the source Observable when the first listener attaches
- Reference counting: unsubscribes from the source when the last listener detaches
get()always returns the latest emitted value (orinitialValue)- Errors from the Observable are silently swallowed; use RxJS
catchErrorupstream - Pure function -- no injection context required
See also: toObservable()
injectStore<T>(store: StoreApi<T>, options?: InjectStoreOptions<T>): Signal<T>
Inject the full state of a StateLoom store as an Angular Signal.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | StoreApi<T> | The StateLoom store to subscribe to. | -- |
options | InjectStoreOptions<T> | Optional configuration. | undefined |
options.equal | (a: T, b: T) => boolean | Custom equality function. | Object.is |
Returns: Signal<T> -- a read-only Angular signal reflecting the full store state.
import { Component } from '@angular/core';
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const store = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
@Component({
template: '{{ state().count }}',
})
export class CounterComponent {
readonly state = injectStore(store);
}Key behaviors:
- Without a selector, the signal updates on every state change
- Cleanup is automatic via
DestroyRef.onDestroy - Must be called in an injection context
See also: injectStore() with selector
injectStore<T, U>(store: StoreApi<T>, selector: (state: T) => U, options?: InjectStoreOptions<U>): Signal<U>
Inject a selected slice of a StateLoom store as an Angular Signal.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
store | StoreApi<T> | The StateLoom store to subscribe to. | -- |
selector | (state: T) => U | Extracts a value from the store state. | -- |
options | InjectStoreOptions<U> | Optional configuration. | undefined |
options.equal | (a: U, b: U) => boolean | Custom equality function. | Object.is |
Returns: Signal<U> -- a read-only Angular signal reflecting the selected value.
import { Component } from '@angular/core';
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const store = createStore((set) => ({
count: 0,
name: 'Alice',
increment: () => set((s) => ({ count: s.count + 1 })),
}));
@Component({
template: '{{ count() }}',
})
export class CounterComponent {
readonly count = injectStore(store, (s) => s.count);
}Key behaviors:
- The Angular signal only updates when the selected value changes (per
Object.isor customequal) - The selector is called on every state change; keep it cheap
- Cleanup is automatic via
DestroyRef.onDestroy - Must be called in an injection context
provideStateloomScope(scope: Scope): EnvironmentProviders
Create environment providers for a StateLoom scope.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
scope | Scope | The scope instance to provide. | -- |
Returns: EnvironmentProviders -- Angular providers that register the scope.
import { provideStateloomScope } from '@stateloom/angular';
import { createScope } from '@stateloom/core';
bootstrapApplication(AppComponent, {
providers: [provideStateloomScope(createScope())],
});See also: injectScope()
injectScope(): Scope | undefined
Inject the StateLoom scope from the current injector.
Parameters: None.
Returns: Scope | undefined -- the provided scope, or undefined if none.
import { Component } from '@angular/core';
import { injectScope } from '@stateloom/angular';
@Component({ template: '...' })
export class MyComponent {
private readonly scope = injectScope();
}Key behaviors:
- Returns
undefinedif no scope has been provided (uses{ optional: true }) - Must be called in an injection context
See also: provideStateloomScope()
STATELOOM_SCOPE
InjectionToken<Scope> for direct injection. Prefer provideStateloomScope/injectScope.
import { STATELOOM_SCOPE } from '@stateloom/angular';
import { createScope } from '@stateloom/core';
{ provide: STATELOOM_SCOPE, useValue: createScope() }ToAngularSignalOptions<T> (interface)
Options for toAngularSignal.
interface ToAngularSignalOptions<T> {
readonly equal?: (a: T, b: T) => boolean;
}InjectStoreOptions<U> (interface)
Options for injectStore.
interface InjectStoreOptions<U> {
readonly equal?: (a: U, b: U) => boolean;
}Patterns
Counter Component
import { Component } from '@angular/core';
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
}));
@Component({
selector: 'app-counter',
template: `
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
readonly count = injectStore(counterStore, (s) => s.count);
increment() {
counterStore.getState().increment();
}
decrement() {
counterStore.getState().decrement();
}
}RxJS Pipe Chain
import { Component } from '@angular/core';
import { signal } from '@stateloom/core';
import { toObservable } from '@stateloom/angular';
import { map, filter } from 'rxjs';
import { AsyncPipe } from '@angular/common';
const count = signal(0);
@Component({
selector: 'app-even',
imports: [AsyncPipe],
template: `<span>{{ evenDoubled$ | async }}</span>`,
})
export class EvenComponent {
readonly evenDoubled$ = toObservable(count).pipe(
filter((n) => n % 2 === 0),
map((n) => n * 2),
);
}Atom Integration
Atoms implement Subscribable<T>, so they work directly with toAngularSignal:
import { Component } from '@angular/core';
import { atom, derived } from '@stateloom/atom';
import { toAngularSignal } from '@stateloom/angular';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
@Component({
selector: 'app-display',
template: `
<span>{{ count() }} x 2 = {{ doubled() }}</span>
<button (click)="increment()">+</button>
`,
})
export class DisplayComponent {
readonly count = toAngularSignal(countAtom);
readonly doubled = toAngularSignal(doubledAtom);
increment() {
countAtom.set(countAtom.get() + 1);
}
}Bridging RxJS Services to StateLoom
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { fromObservable } from '@stateloom/angular';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly user$ = new BehaviorSubject<string | null>(null);
// Expose as StateLoom Subscribable for use with other adapters
readonly user = fromObservable(this.user$, null);
login(name: string) {
this.user$.next(name);
}
logout() {
this.user$.next(null);
}
}Angular Universal SSR
import { bootstrapApplication } from '@angular/platform-browser';
import { createScope, signal } from '@stateloom/core';
import { provideStateloomScope } from '@stateloom/angular';
const count = signal(0);
// Per-request setup
const requestScope = createScope();
requestScope.set(count, 42);
bootstrapApplication(AppComponent, {
providers: [provideStateloomScope(requestScope)],
});How It Works
Angular Signal Bridge
toAngularSignal creates an Angular signal() initialized with the subscribable's current value, then subscribes to push updates synchronously. Cleanup is registered via DestroyRef:
Store Injection with Selector
injectStore adds a selector layer on top of the subscription. It calls the selector on every state change and compares the result with Object.is (or a custom equal function) before updating the Angular signal. This prevents unnecessary change detection cycles.
Observable Bidirectional Bridge
toObservable: Creates an RxJSObservablethat emits immediately and on each change. Pure function, no injection context needed.fromObservable: Creates aSubscribablewith reference counting. The source Observable is subscribed lazily on first listener and unsubscribed when the last listener detaches.
TypeScript
import { signal } from '@stateloom/core';
import { createStore } from '@stateloom/store';
import { toObservable, toAngularSignal, fromObservable, injectStore } from '@stateloom/angular';
import type { Observable } from 'rxjs';
import type { Signal as AngularSignal } from '@angular/core';
import type { Subscribable } from '@stateloom/core';
import { expectTypeOf } from 'vitest';
// toObservable returns Observable<T>
const count = signal(42);
const count$ = toObservable(count);
expectTypeOf(count$).toEqualTypeOf<Observable<number>>();
// fromObservable returns Subscribable<T>
const tick = fromObservable(count$, 0);
expectTypeOf(tick).toEqualTypeOf<Subscribable<number>>();Migration
From NgRx Store
NgRx's Store + selectors pattern maps to @stateloom/store + injectStore:
// NgRx
import { Store, createSelector, createReducer, createAction } from '@ngrx/store';
const increment = createAction('[Counter] Increment');
const counterReducer = createReducer(
0,
on(increment, (state) => state + 1),
);
@Component({ template: '{{ count$ | async }}' })
export class CounterComponent {
count$ = this.store.select('counter');
constructor(private store: Store) {}
increment() {
this.store.dispatch(increment());
}
}
// StateLoom
import { createStore } from '@stateloom/store';
import { injectStore } from '@stateloom/angular';
const counterStore = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
@Component({ template: '{{ count() }}' })
export class CounterComponent {
readonly count = injectStore(counterStore, (s) => s.count);
increment() {
counterStore.getState().increment();
}
} Key differences:
- NgRx uses actions/reducers/selectors; StateLoom uses direct mutation via
set() - NgRx is deeply integrated with RxJS; StateLoom bridges to both Angular Signals and RxJS
- NgRx stores are provided via Angular DI; StateLoom stores are standalone module-level objects
- StateLoom stores are framework-agnostic -- the same store works across React, Vue, etc.
From Angular Signals (standalone)
If you already use Angular's built-in signal() and want to share state across frameworks:
// Angular native
import { signal, computed } from '@angular/core';
const count = signal(0);
const doubled = computed(() => count() * 2);
// StateLoom (framework-agnostic)
import { signal, computed } from '@stateloom/core';
import { toAngularSignal } from '@stateloom/angular';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
@Component({ template: '{{ angularCount() }}' })
export class CounterComponent {
readonly angularCount = toAngularSignal(count);
readonly angularDoubled = toAngularSignal(doubled);
}When to Use
| Scenario | Why @stateloom/angular |
|---|---|
| Angular 17+ with StateLoom | Native Angular Signal integration |
| Existing RxJS pipelines | toObservable + async pipe |
| Bridging RxJS to StateLoom | fromObservable for reverse direction |
| Store with selectors | injectStore with Angular DI lifecycle |
| Atom-based state | toAngularSignal(atom) works directly |
| Angular Universal SSR | provideStateloomScope for per-request isolation |
Angular requires more ceremony than other frameworks due to its dependency injection system. This adapter embraces Angular idioms: inject(), DestroyRef, InjectionToken, and EnvironmentProviders. For React, Vue, Solid, or Svelte, use the corresponding adapter.
See the full Angular example app for a complete working application.