Angular Integration
@routier/angular gives Angular two ways to follow a Routier live query:
injectLiveQueryreturns a signal, and re-queries when signals it reads change.fromLiveQueryreturns an RxJS Observable, for theasyncpipe,toSignal(), or an existing RxJS pipeline.
Installation
npm install @routier/angular
# peer dependencies, already in an Angular app
npm install @angular/core rxjsRequires Angular 19 or later.
injectLiveQuery
function injectLiveQuery<T>(
query: (callback: (result: ResultType<T>) => void) => void | (() => void),
options?: { injector?: Injector },
): Signal<LiveQueryState<T>>;The signal holds a discriminated union. Narrow on status before reading data or error:
status | Fields |
|---|---|
"pending" | loading: true |
"success" | data: T, isSuccess: true |
"error" | error: Error, isError: true |
- Injection context. Call it in a constructor, a field initializer, or a factory, like
inject(). Elsewhere, pass{ injector }. - Tracking. The query runs in an Angular
effect, so any signal it reads becomes a dependency. When one changes, the previous subscription stops, the state returns topending, and the query runs again. - Cleanup. The subscription stops when the component, directive, or service that created it is destroyed.
fromLiveQuery
function fromLiveQuery<T>(
query: (callback: (result: ResultType<T>) => void) => void | (() => void),
): Observable<LiveQueryState<T>>;Each subscriber runs its own query. The Observable emits pending first, then every result. A failed query arrives as an error state, not as an Observable error, so the stream stays open. Unsubscribing stops the query.
Example
import { computed, signal } from "@angular/core";
import { fromLiveQuery, injectLiveQuery } from "@routier/angular";
import { store, type Product } from "../../home/inventory";
export const pageSize = 6;
export function injectProductPage() {
const page = signal(1);
const rows = injectLiveQuery<Product[]>((onResult) =>
store.products
.sort((p) => p.name)
.skip((page() - 1) * pageSize)
.take(pageSize)
.subscribe()
.toArray(onResult),
);
const names = computed(() => {
const state = rows();
return state.status === "success" ? state.data.map((p) => p.name) : [];
});
return { page, rows, names };
}
export const total$ = fromLiveQuery<number>((onResult) => store.products.subscribe().count(onResult));import { Component } from "@angular/core";
import { AsyncPipe } from "@angular/common";
import { injectProductPage, total$ } from "./products";
@Component({
selector: "app-product-grid",
imports: [AsyncPipe],
template: `
@switch (rows().status) {
@case ("error") { <p>Could not load products.</p> }
@case ("success") {
<ul>@for (name of names(); track name) { <li>{{ name }}</li> }</ul>
}
}
@if (total$ | async; as total) {
@if (total.status === "success") { <p>{{ total.data }} products</p> }
}
<button [disabled]="page() === 1" (click)="page.set(page() - 1)">‹</button>
<button (click)="page.set(page() + 1)">›</button>
`,
})
export class ProductGridComponent {
private readonly products = injectProductPage();
readonly page = this.products.page;
readonly rows = this.products.rows;
readonly names = this.products.names;
readonly total$ = total$;
}store and Product come from a shared module, the same one the Vue page uses. In an app, provide the DataStore from a service with providedIn: "root" so every component shares one instance.
Server-side rendering
Live queries belong in the browser. With Angular SSR, create the store and its queries only on the client, for example behind afterNextRender or an isPlatformBrowser check.