Skip to content
DocspackagesDocumentation

@lunora/react

React 19 hooks built on @lunora/client, with React Server Component data loading.

PackagesReact

@lunora/react ships the official React bindings. It's a thin layer over @lunora/client that maps the cache to useSyncExternalStore, so concurrent React works correctly out of the box. The hooks live in the package root (a client boundary); server-side data loading for React Server Components / the Next.js App Router lives in @lunora/react/server.

import { LunoraClient } from "lunorash/client";
import { LunoraProvider, useMutation, useQuery } from "@lunora/react";
import { api } from "@/lunora/_generated/api";

const client = new LunoraClient({ url: import.meta.env.VITE_LUNORA_URL });

const Root = () => (
    <LunoraProvider client={client}>
        <Chat channelId="general" />
    </LunoraProvider>
);

<LunoraProvider client={...}>

Mounts the shared LunoraClient for the subtree. Required at the root.

useQuery(fn, args, options?)

Subscribes to a query. Returns T | undefined (undefined until the first response). Pass "skip" for args to short-circuit (no network call). Multiple useQuery calls with identical args share a single underlying network call via the in-memory cache.

const messages = useQuery(api.messages.list, { channelId });
const profile = useQuery(api.users.me, signedIn ? {} : "skip");

useMutation(fn)

Returns { mutate, pending, data, error, reset, withOptimisticUpdate }. mutate is the awaitable call; pending is ref-counted across overlapping calls from this hook, so it stays true until the last one settles. Destructure what you need:

const { mutate, pending } = useMutation(api.messages.send);

await mutate(
    { channelId, text },
    {
        optimisticUpdate: (store, args) => {
            const current = store.getQuery(api.messages.list, { channelId: args.channelId }) ?? [];

            store.setQuery(api.messages.list, { channelId: args.channelId }, [...current, draft]);
        },
    },
);

optimisticUpdate names the query it patches, which is what the send/list shape needs: the per-call optimistic: (current) => next shortcut only patches a subscription registered under the mutation's own reference and args, so it fits a query and a mutation that share a path (a counter, a document by id) and quietly does nothing for anything else.

To bind the same update to every call of a handle, use withOptimisticUpdate(update), which returns the same handle with the update applied by default.

Offline, mutate calls client.mutation at once when the client can queue the write: the optimistic update paints, and the write goes to the offline queue (or your durable outbox) to replay on reconnect. Before the shard's first connect (unless queueBeforeFirstConnect is set), or on a client with no WebSocket, the client cannot queue, so the call waits until the browser is back online and then sends. TanStack never retries; the queue owns replays.

useAction(fn)

Returns { call, pending, data, error, reset }. call is the awaitable invocation and rejects on failure; pending is ref-counted across overlapping calls from this hook, so a sibling component calling the same action never affects it.

data and error both track the latest invocation, not the last to settle: a double-click whose first call resolves after the second cannot overwrite the second's outcome. A success clears error; a failure leaves the previous data in place, so a transient error does not blank the view. reset() clears both, but does not cancel an in-flight call — its result still lands.

Unlike useMutation, the call never waits for the network, is never queued and is never retried: client.action has no offline queue and sends no idempotency key, so a paused call would fire minutes later on reconnect and a retry could run a third-party side effect twice. It fails fast instead and lets you decide.

const { call: runVerify, pending } = useAction(api.commands.run);
await runVerify({ command: "lunora", args: ["verify"] });

Per-call options are { shardKey } only — there is no optimistic / optimisticUpdate and no withOptimisticUpdate. An optimistic update patches the subscription cache on the assumption a write will land; an action is not a write — it runs in the Worker, may call a third party, and has no declared effect on any query. Offering the option would imply a rollback guarantee nothing can honour.

useSubscription(fn, args)

Lower-level. Surfaces { data, error } so you can render error states without a separate useState.

useAuth()

Returns { status, user, token, setToken(next) }. Call setToken(jwt) after a sign-in flow; subsequent RPC calls carry the token automatically. It does not re-authenticate an open subscription — the WebSocket credential is fixed at upgrade time; client.setWsToken(token) rotates that.

status is the gate: "loading", "authenticated", "unauthenticated" or "unreachable" (a credential is held but the identity endpoint could not be reached — gates as authenticated). Branch on it, never on whether user is null: user is null both when signed out and when a held credential's identity has not resolved, so a user gate renders the sign-in form to a signed-in user. See the @lunora/client reference for the full table.

const { setToken, status, user } = useAuth();

if (status === "loading") return <Spinner />;
if (status === "unauthenticated") return <SignInForm onToken={setToken} />;
return <Dashboard email={user?.email} />;

Resolving the session also keys the client's offline-queue identity on the user id rather than the token bytes, so rotating the JWT for the same user keeps queued writes and the durable read cache instead of reading as a user switch. Between the rotation and the next session resolve, queued writes are held rather than replayed — the client cannot yet tell a refresh from an account switch.

Server Components (Next.js App Router)

Every hook in @lunora/react calls useState/useEffect and owns a live WebSocket: they're client-only, and each module declares "use client". Use them inside your own Client Components (the examples below mark "use client" on the files that call them).

Server-side data loading lives in a separate, server-safe entry, @lunora/react/server. It opens no socket and touches no browser globals, so it's safe to call from a Server Component. There are two ways to render with data already present on the first paint.

1. Prefetch + hydrate the TanStack cache

Run the query on the server, dehydrate the QueryClient, and wrap the client subtree in HydrationBoundary. The client useQuery reads the value out of the hydrated cache (same key, no loading flash) and a live subscription attaches on mount.

app/posts/page.tsx (Server Component)
import { createServerClient, prefetchQuery, dehydrate, HydrationBoundary } from "@lunora/react/server";
import { QueryClient } from "@tanstack/react-query";
import { cookies } from "next/headers";
import { api } from "@/lunora/_generated/api";
import { PostList } from "./post-list";

export default async function PostsPage() {
    const client = createServerClient({
        url: process.env.LUNORA_URL!,
        token: (await cookies()).get("session")?.value,
    });

    const queryClient = new QueryClient();
    await prefetchQuery(queryClient, client, api.posts.list, {});

    return (
        <HydrationBoundary state={dehydrate(queryClient)}>
            <PostList />
        </HydrationBoundary>
    );
}
app/posts/post-list.tsx (Client Component)
"use client";

import { useQuery } from "@lunora/react";
import { api } from "@/lunora/_generated/api";

export function PostList() {
    // Reads the server-prefetched value from cache on the first render, then
    // updates live as the WS subscription pushes changes.
    const posts = useQuery(api.posts.list, {});

    return (
        <ul>
            {posts?.map((p) => (
                <li key={p._id}>{p.title}</li>
            ))}
        </ul>
    );
}

Drop the await before prefetchQuery for fire-and-forget prefetch when you don't need the data on the first paint.

2. Preload an explicit token

When you'd rather thread one resolved value to one component without a HydrationBoundary, preloadQuery returns a serializable token you pass as a prop. The client reads it with usePreloadedQuery.

Server Component
import { createServerClient, preloadQuery } from "@lunora/react/server";
import { api } from "@/lunora/_generated/api";
import { Post } from "./post";

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params;
    const client = createServerClient({ url: process.env.LUNORA_URL! });
    const preloaded = await preloadQuery(client, api.posts.get, { id });

    return <Post preloaded={preloaded} />;
}
Client Component
"use client";

import { usePreloadedQuery } from "@lunora/react";
import type { Preloaded } from "@lunora/react/server";

export function Post({ preloaded }: { preloaded: Preloaded<{ title: string }> }) {
    const post = usePreloadedQuery(preloaded); // server value first, then live

    // `undefined` after a sign-out or user switch, until the new identity's subscription answers.
    if (!post) {
        return <p>Loading…</p>;
    }

    return <h1>{post.title}</h1>;
}

usePreloadedQuery returns T | undefined. The preloaded value was read for whoever was signed in when the page loaded, so after a sign-out or user switch it is dropped and the hook returns undefined until the live value arrives: guard before reading fields.

Providers

Mount LunoraProvider once in a Client Component near the root. It creates (or reuses) the TanStack QueryClient that HydrationBoundary hydrates into.

app/providers.tsx
"use client";

import { LunoraClient } from "lunorash/client";
import { LunoraProvider } from "@lunora/react";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
    const [client] = useState(() => new LunoraClient({ url: process.env.NEXT_PUBLIC_LUNORA_URL! }));
    return <LunoraProvider client={client}>{children}</LunoraProvider>;
}

@lunora/react/server reference

  • createServerClient({ url, token?, fetch? }): a request-scoped, HTTP-only LunoraClient. Build one per request so a user's token (and any cookies forwarded via fetch) never leak across requests.
  • prefetchQuery(queryClient, client, fn, args, { shardKey? }?): runs the query and seeds queryClient under the key the client hooks use.
  • preloadQuery(client, fn, args, { shardKey? }?): runs the query and returns a serializable Preloaded token for usePreloadedQuery.
  • dehydrate / HydrationBoundary: re-exported from @tanstack/react-query for convenience.

File uploads (@lunora/react/upload)

End-user file uploads (live progress, pause/resume, large-file resumable and per-part retry) are re-exported from @visulima/storage-client (Lunora does not hand-roll the uploader). useUpload auto-selects TUS / chunked-REST / multipart by file size; the TUS path survives a pause/resume and a dropped connection. Point the endpoint at a route backed by @lunora/storage/upload; these uploads are gated by your per-user RLS policy, not the studio adminToken.

TanStack Query reconciliation

The upload hooks (useUpload, useMultipartUpload, useTusUpload, useChunkedRestUpload, useFileInput, usePasteUpload) hold their own state and need no QueryClientProvider. The @visulima/storage-client data hooks (useGetFileList …) do use TanStack Query, and LunoraProvider already mounts a QueryClient, so they coexist with Lunora's own useQuery without extra wiring.

Drag-and-drop uploader with a progress bar

import { useCallback, useRef, useState } from "react";
import { useUpload } from "@lunora/react/upload";

export function Uploader() {
    const [dragging, setDragging] = useState(false);
    const controlRef = useRef(null);

    // `endpointTus` points at the RLS-gated `@lunora/storage/upload` handler.
    // The hook auto-selects TUS for large files (resumable), a plain multipart
    // POST for small ones.
    const { upload, progress, isUploading, isPaused, pause, resume, abort, error, result } = useUpload({
        endpointTus: "/upload",
        endpointMultipart: "/upload",
        // Send the user's session so the server's `authorize` gate can identify them.
        headers: async () => ({ authorization: `Bearer ${await getSessionToken()}` }),
        restrictions: { maxFileSize: 5 * 1024 * 1024 * 1024, allowedFileTypes: ["video/*", "image/*"] },
        onSuccess: (file) => console.log("uploaded", file.id),
    });

    const onDrop = useCallback(
        (event: React.DragEvent) => {
            event.preventDefault();
            setDragging(false);
            const file = event.dataTransfer.files[0];
            if (file) void upload(file);
        },
        [upload],
    );

    return (
        <div
            onDragOver={(e) => {
                e.preventDefault();
                setDragging(true);
            }}
            onDragLeave={() => setDragging(false)}
            onDrop={onDrop}
            data-dragging={dragging}
            className="dropzone"
        >
            {isUploading ? (
                <>
                    <progress value={progress} max={100} />
                    <span>{progress}%</span>
                    {isPaused ? <button onClick={() => void resume?.()}>Resume</button> : <button onClick={() => pause?.()}>Pause</button>}
                    <button onClick={() => abort()}>Cancel</button>
                </>
            ) : result ? (
                <p>Uploaded ✓</p>
            ) : (
                <p>{dragging ? "Drop to upload" : "Drag a file here"}</p>
            )}
            {error ? <p role="alert">{error.message}</p> : null}
        </div>
    );
}

useUpload returns pause / resume / isPaused / offset only for the resumable protocols (TUS / chunked-REST). For a plain <input type="file">, useFileInput gives you the change-handler + selected-files plumbing; useTusUpload is the lower-level hook when you always want the resumable protocol.

Admin auth hooks

Reactive reads over the admin-gated auth plane — the /_lunora/admin/auth/* HTTP endpoints LunoraClient exposes as listAuthUsers, listAuthSessions, listAuthOrganizations, and impersonateAuthUser. They are HTTP-only: unlike useQuery there is no live subscription behind them (the auth-admin surface does not ride the WS transport at all), so they are one-shot TanStack reads with an explicit refetch(). The worker must be built with an authAdmin and an adminToken, and the client must carry an admin-capable token.

HookReturns
useAuthUsers(options?)AdminAuthListResult<AuthUser>
useAuthSessions(options?)AdminAuthListResult<AuthSession>
useOrganizations(options?)AdminAuthListResult<Record<string, unknown>>
useImpersonate(){ data, error, pending, impersonate, reset }

AdminAuthListResult<T> is { data, error, loading, hasMore, total, loadMore, refetch }: data is the full current window (undefined before the first response), total is the server-reported count across the whole collection, and loadMore() grows the requested window by one page and re-fetches. It is a growing-window re-fetch, not a cursor accumulator — the underlying AuthPage is offset/limit-based and there is no live delta stream to keep page boundaries stable against.

Shared options: { enabled?, pageSize? } (pageSize defaults to 50). useAuthUsers adds { search?, searchField?, filterField?, filterValue?, sortBy?, sortDirection? }; useAuthSessions adds { userId? } to scope the list to one user.

AuthUser, AuthSession, AuthPage, and AuthImpersonation are re-exported from the package root, so a console UI can annotate these results without reaching into @lunora/client.

import { useAuthUsers, useImpersonate, type AuthUser } from "@lunora/react";

const UserTable = () => {
    const { data, error, hasMore, loadMore, loading, refetch, total } = useAuthUsers({ pageSize: 25, search: "acme" });
    const { impersonate, pending } = useImpersonate();

    if (loading) return <p>Loading…</p>;
    if (error) return <p role="alert">{error.message}</p>;

    return (
        <>
            <p>
                {data?.length ?? 0} of {total ?? 0}
            </p>
            <ul>
                {(data ?? []).map((user: AuthUser) => (
                    <li key={user.id}>
                        {user.email}
                        <button disabled={pending} onClick={() => void impersonate(user.id)}>
                            Impersonate
                        </button>
                    </li>
                ))}
            </ul>
            {hasMore ? <button onClick={loadMore}>Load more</button> : null}
            <button onClick={refetch}>Refresh</button>
        </>
    );
};

Mutations stay plain client.* calls (client.createAuthOrganization, client.deleteAuthOrganization, …); call the relevant hook's refetch() afterwards — nothing is invalidated implicitly.

impersonate(userId) resolves an AuthImpersonation (bearer token + target user + expiry) and deliberately does not call client.setAuthToken(...). Swapping the current session would sign the admin out of their own admin session with no visible transition and no way back; what to do with the token — open it in a second tab, surface it for copy — is the caller's decision.

Agent tool events — useAgentToolEvents(options)

Observes one agent thread's tool activity, separate from its chat transcript: which tools the model called, what they returned, which are parked on a human approval, and any in-flight ctx.reportProgress(...) updates.

options: { api, threadKey, stream?, limit? } — api is the generated api (it reads api.agents.agentMessages), and stream is the same app stream reference useAgentChat takes. Returns { events }.

Each event discriminates on type:

typeFieldsSource
calltoolCallId, toolName, input, seqdurable
resulttoolCallId?, toolName?, output, status?, seqdurable
awaiting-approvaltoolCallId?, toolName?, seqdurable
progresstoolCallId, datalive stream

Durable events come first, oldest first by seq, followed by the ephemeral progress events for the in-flight turn. With no stream reference only the durable lifecycle is surfaced. The array is rebuilt on every update — treat it as derived, not identity-stable, and key rendered rows on toolCallId/seq.

import { useAgentToolEvents } from "@lunora/react";
import { api } from "@/lunora/_generated/api";

const ToolTimeline = ({ threadKey }: { threadKey: string }) => {
    const { events } = useAgentToolEvents({ api, stream: api.chat.liveEvents, threadKey });

    return (
        <ol>
            {events.map((event, index) => (
                <li key={event.type === "progress" ? `p-${index}` : event.seq}>
                    {event.type === "call" ? `→ ${event.toolName}` : null}
                    {event.type === "result" ? `← ${event.output}` : null}
                    {event.type === "awaiting-approval" ? "⏸ awaiting approval" : null}
                    {event.type === "progress" ? JSON.stringify(event.data) : null}
                </li>
            ))}
        </ol>
    );
};

Voice agents — useVoiceAgent(options)

Opens a full-duplex voice call against a voice-enabled agent — the api.agents.<name>Voice reference codegen emits. Microphone capture goes up the agent's WebSocket, synthesized speech comes back down, and transcripts plus barge-in are surfaced along the way.

options: { voice, threadKey, silenceThreshold?, silenceDurationMs?, interruptThreshold?, interruptChunks?, createMicrophone?, createSocket?, createSpeaker? }. threadKey is shared with the agent's text turns, so a voice call continues the very same conversation useAgentChat renders.

Returns { status, connected, transcript, interimTranscript, audioLevel, isMuted, error, startCall, endCall, sendText, toggleMute }. status is "idle" | "listening" | "thinking" | "speaking".

import { useVoiceAgent } from "@lunora/react";
import { api } from "@/lunora/_generated/api";

const CallButton = ({ threadKey }: { threadKey: string }) => {
    const { audioLevel, endCall, startCall, status, transcript } = useVoiceAgent({
        threadKey,
        voice: api.agents.supportVoice,
    });

    return (
        <>
            <button onClick={status === "idle" ? () => void startCall() : endCall}>{status === "idle" ? "Call" : "Hang up"}</button>
            <meter max={1} value={audioLevel} />
            <p>{transcript}</p>
        </>
    );
};

Microphone and audio lifecycle

The part the type signature does not tell you:

  • Nothing opens until startCall(). Creating the handle touches neither the microphone nor the socket. startCall is what calls getUserMedia, so it has to run from a user gesture — browsers block both the permission prompt and AudioContext resumption outside one. It is idempotent while a call is active, and a denied permission lands in error rather than throwing at the call site.
  • endCall() releases everything — the socket, the microphone tracks, and the Web Audio graph — and is idempotent. The hook also tears the call down on unmount, so a component that navigates away mid-call leaks nothing.
  • audioLevel is the live input RMS (0–1). It drives a mic meter, and it is also what the heuristics read: silenceThreshold + silenceDurationMs decide when an utterance auto-commits (defaults 0.01 / 1200ms), and interruptThreshold + interruptChunks decide when the user barges in on the agent mid-sentence (defaults 0.15 / 3 consecutive chunks). These are room-dependent; tune them against real hardware rather than trusting the defaults.
  • createMicrophone / createSpeaker / createSocket are injection seams for tests and non-DOM hosts. The defaults are getUserMedia + Web Audio + new WebSocket(url), so the primitive is inert (and mockable) anywhere those are missing.