Skip to content

@stateloom/angular

Angular adapter for StateLoom. Bridges Subscribable<T> to Angular Signals and RxJS Observables with dependency injection and SSR scope support.

Install

bash
pnpm add @stateloom/core @stateloom/angular
bash
npm install @stateloom/core @stateloom/angular
bash
yarn add @stateloom/core @stateloom/angular

Size: ~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

typescript
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.

typescript
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.

typescript
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>:

typescript
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:

typescript
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:

typescript
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:

ParameterTypeDescriptionDefault
subscribableSubscribable<T>The StateLoom reactive source to wrap.--

Returns: Observable<T> -- an RxJS Observable that mirrors the subscribable.

typescript
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 async pipe, 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:

ParameterTypeDescriptionDefault
subscribableSubscribable<T>The StateLoom reactive source to bridge.--
optionsToAngularSignalOptions<T>Optional configuration.undefined
options.equal(a: T, b: T) => booleanCustom equality for Angular change detection.Object.is

Returns: Signal<T> -- a read-only Angular signal that mirrors the subscribable.

typescript
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 equal option 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:

ParameterTypeDescriptionDefault
observable$Observable<T>The RxJS Observable to bridge.--
initialValueTThe value returned by get() before the Observable emits.--

Returns: Subscribable<T> -- a subscribable that mirrors the Observable's latest emission.

typescript
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 (or initialValue)
  • Errors from the Observable are silently swallowed; use RxJS catchError upstream
  • 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:

ParameterTypeDescriptionDefault
storeStoreApi<T>The StateLoom store to subscribe to.--
optionsInjectStoreOptions<T>Optional configuration.undefined
options.equal(a: T, b: T) => booleanCustom equality function.Object.is

Returns: Signal<T> -- a read-only Angular signal reflecting the full store state.

typescript
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:

ParameterTypeDescriptionDefault
storeStoreApi<T>The StateLoom store to subscribe to.--
selector(state: T) => UExtracts a value from the store state.--
optionsInjectStoreOptions<U>Optional configuration.undefined
options.equal(a: U, b: U) => booleanCustom equality function.Object.is

Returns: Signal<U> -- a read-only Angular signal reflecting the selected value.

typescript
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.is or custom equal)
  • 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:

ParameterTypeDescriptionDefault
scopeScopeThe scope instance to provide.--

Returns: EnvironmentProviders -- Angular providers that register the scope.

typescript
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.

typescript
import { Component } from '@angular/core';
import { injectScope } from '@stateloom/angular';

@Component({ template: '...' })
export class MyComponent {
  private readonly scope = injectScope();
}

Key behaviors:

  • Returns undefined if 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.

typescript
import { STATELOOM_SCOPE } from '@stateloom/angular';
import { createScope } from '@stateloom/core';

{ provide: STATELOOM_SCOPE, useValue: createScope() }

ToAngularSignalOptions<T> (interface)

Options for toAngularSignal.

typescript
interface ToAngularSignalOptions<T> {
  readonly equal?: (a: T, b: T) => boolean;
}

InjectStoreOptions<U> (interface)

Options for injectStore.

typescript
interface InjectStoreOptions<U> {
  readonly equal?: (a: U, b: U) => boolean;
}

Patterns

Counter Component

typescript
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

typescript
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:

typescript
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

typescript
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

typescript
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 RxJS Observable that emits immediately and on each change. Pure function, no injection context needed.
  • fromObservable: Creates a Subscribable with reference counting. The source Observable is subscribed lazily on first listener and unsubscribed when the last listener detaches.

TypeScript

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:

typescript
// 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:

typescript
// 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

ScenarioWhy @stateloom/angular
Angular 17+ with StateLoomNative Angular Signal integration
Existing RxJS pipelinestoObservable + async pipe
Bridging RxJS to StateLoomfromObservable for reverse direction
Store with selectorsinjectStore with Angular DI lifecycle
Atom-based statetoAngularSignal(atom) works directly
Angular Universal SSRprovideStateloomScope 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.