Skip to content

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:

bash
ng new my-app --standalone
cd my-app
bash
pnpm add @stateloom/core @stateloom/angular
bash
npm install @stateloom/core @stateloom/angular
bash
yarn add @stateloom/core @stateloom/angular

Add a paradigm adapter:

bash
pnpm add @stateloom/store
bash
pnpm add @stateloom/atom
bash
pnpm add @stateloom/proxy

Basic Integration

Angular Signals with toAngularSignal

Bridge StateLoom subscribables to Angular's native Signal:

typescript
// 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);
}
typescript
// 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:

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',
  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:

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',
  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:

typescript
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

typescript
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

typescript
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

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

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

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

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

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

Live Demo

Try the Angular example directly in your browser:

Open in StackBlitz | Open Static Demo