Angular

Use Lunora with Angular — provideLunora in your app config, signal-based liveQuery, imperative mutate, and a connectionStatus signal.

Last updated:

@lunora/angular is the Angular adapter, a thin, idiomatic layer over @lunora/client — which owns the WebSocket transport, subscription registry, offline queue, and delta-merge. Angular signals map directly onto Lunora's per-subscription deltas, so a live query is just a signal the WebSocket writes to.

This page is the task-oriented guide; for the full reference see @lunora/angular.

Deliberately small surface. The Angular adapter ships the core four — DI wiring, liveQuery, mutate, connectionStatus. Parity extras the other adapters have (paginated queries, bound optimistic-mutation handles, presence, reactive loaders) aren't here yet. See Bring your framework.

Install

pnpm add @lunora/angular
npm install @lunora/angular
yarn add @lunora/angular
bun add @lunora/angular

@angular/core is a peer dependency — the host app supplies the Angular runtime.

Provide the client

Add provideLunora to your application config. It defaults to the page origin (the single-worker deploy where /_lunora/ws loops back into the app's own worker); pass options to point at a remote URL, or hand it an already-constructed LunoraClient to share one instance.

src/app/app.config.ts
import type { ApplicationConfig } from "@angular/core";
import { provideLunora } from "@lunora/angular";

export const appConfig: ApplicationConfig = {
    providers: [provideLunora(/* { url: "https://api.example.com" } */)],
};

Every reactive primitive resolves the client from the LUNORA_CLIENT injection token, which has a root-scoped default (a lazily-built same-origin browser client) — so things work even without provideLunora. Call injectLunoraClient() inside an injection context (a component/service field initializer or constructor) when you need the client for imperative calls.

Live queries

liveQuery(fn, args) opens a subscription and mirrors every server delta into a Signal. It reads undefined until the first frame lands, and tears down automatically when the owning component is destroyed (DestroyRef.onDestroy). Call it from an injection context — a field initializer or constructor.

import { Component } from "@angular/core";
import { liveQuery } from "@lunora/angular";

import { api } from "../lunora/_generated/api";

@Component({
    selector: "app-messages",
    standalone: true,
    template: `@for (m of messages()?.messages ?? []; track m.id) {
        <p>{{ m.text }}</p>
    }`,
})
export class MessagesComponent {
    readonly messages = liveQuery(api.messages.list, { channelId: "general" });
}

Pass "skip" as the args to short-circuit — no network call, no socket; the signal stays undefined:

readonly profile = liveQuery(api.users.me, signedIn ? {} : "skip");

Unlike the Vue/Solid adapters, args is a plain value here, not a reactive source — changing it after the fact does not re-subscribe.

Mutations

mutate(fn, args, options) runs a mutation and resolves with the server result. Capture the client in a field first: mutations usually fire from event handlers, which run outside an injection context.

import { Component } from "@angular/core";
import { injectLunoraClient, mutate } from "@lunora/angular";

import { api } from "../lunora/_generated/api";

@Component({/* … */})
export class Composer {
    private readonly client = injectLunoraClient();

    send(text: string) {
        return mutate(api.messages.send, { text }, { client: this.client });
    }
}

Optimistic updates (optimistic / optimisticUpdate) and the offline queue pass straight through to client.mutation.

Connection status

import { connectionStatus } from "@lunora/angular";

readonly status = connectionStatus(); // Signal<"idle" | "connecting" | "connected" | "offline">

The aggregate live-socket status across all shard connections — render it in a header badge or gate a "reconnecting…" banner.

See also