@stateloom/angular
February 13, 2026 · View on GitHub
Angular adapter for StateLoom. Bridges Subscribable<T> to Angular Signals and RxJS Observables with dependency injection and SSR scope support.
Install
::: code-group
pnpm add @stateloom/core @stateloom/angular
npm install @stateloom/core @stateloom/angular
yarn add @stateloom/core @stateloom/angular
:::
Size: ~0.3 KB gzipped
Requires: Angular 17+, RxJS 7+
::: tip Optional: Store Package
Add @stateloom/store for injectStore with selector memoization. Atoms work through toAngularSignal without additional dependencies since they implement Subscribable<T>.
:::
Overview
graph LR
Sub["Subscribable<T>"] --> TO["toObservable(sub)"]
Sub --> TAS["toAngularSignal(sub)"]
Obs["Observable<T>"] --> FO["fromObservable(obs$, init)"]
Store["StoreApi<T>"] --> IS["injectStore(store, selector?)"]
TO --> RxJS["Observable<T>"]
TAS --> AS["Angular Signal<T>"]
FO --> SubOut["Subscribable<T>"]
IS --> AS2["Angular Signal<T|U>"]
Scope["Scope"] --> PSS["provideStateloomScope(scope)"]
PSS --> ISC["injectScope()"]
style Sub fill:#4a9eff,color:#fff
style Store fill:#7c3aed,color:#fff
style Obs fill:#d97706,color:#fff
style TO fill:#059669,color:#fff
style TAS fill:#059669,color:#fff
style FO fill:#059669,color:#fff
style IS fill:#059669,color:#fff
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 listening
Injecting 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: 1
Key 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:
sequenceDiagram
participant C as Component
participant TAS as toAngularSignal
participant AS as Angular signal()
participant Sub as Subscribable
participant DR as DestroyRef
C->>TAS: toAngularSignal(subscribable)
TAS->>Sub: subscribable.get()
TAS->>AS: signal(initialValue)
TAS->>Sub: subscribable.subscribe(sig.set)
TAS->>DR: onDestroy(unsubscribe)
TAS->>C: sig.asReadonly()
Sub-->>AS: value change → sig.set(value)
AS-->>C: Angular change detection
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'; // [!code --]
// [!code --]
const increment = createAction('[Counter] Increment'); // [!code --]
const counterReducer = createReducer(
0,
on(increment, (state) => state + 1),
); // [!code --]
// [!code --]
@Component({ template: '{{ count$ | async }}' }) // [!code --]
export class CounterComponent {
// [!code --]
count$ = this.store.select('counter'); // [!code --]
constructor(private store: Store) {} // [!code --]
increment() {
this.store.dispatch(increment());
} // [!code --]
} // [!code --]
// StateLoom
import { createStore } from '@stateloom/store'; // [!code ++]
import { injectStore } from '@stateloom/angular'; // [!code ++]
// [!code ++]
const counterStore = createStore((set) => ({
// [!code ++]
count: 0, // [!code ++]
increment: () => set((s) => ({ count: s.count + 1 })), // [!code ++]
})); // [!code ++]
// [!code ++]
@Component({ template: '{{ count() }}' }) // [!code ++]
export class CounterComponent {
// [!code ++]
readonly count = injectStore(counterStore, (s) => s.count); // [!code ++]
increment() {
counterStore.getState().increment();
} // [!code ++]
} // [!code ++]
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'; // [!code --]
const count = signal(0); // [!code --]
const doubled = computed(() => count() * 2); // [!code --]
// StateLoom (framework-agnostic)
import { signal, computed } from '@stateloom/core'; // [!code ++]
import { toAngularSignal } from '@stateloom/angular'; // [!code ++]
const count = signal(0); // [!code ++]
const doubled = computed(() => count.get() * 2); // [!code ++]
@Component({ template: '{{ angularCount() }}' })
export class CounterComponent {
readonly angularCount = toAngularSignal(count); // [!code ++]
readonly angularDoubled = toAngularSignal(doubled); // [!code ++]
}
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.