Skip to content
DocsframeworksDocumentation

React

Use Lunora with React — a provider, live useQuery/useMutation hooks, and the SSR-seed → live reactive-loader handoff.

Last updated:

@lunora/react is the official React binding. It's a thin layer over @lunora/client that maps the live cache to useSyncExternalStore, so concurrent React works correctly out of the box. It is the reference adapter: the only one verified end-to-end through the full live-loader path (see Bring your framework).

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

Bring your framework. Your loaders are live.

Install

pnpm add @lunora/react

Provide the client

Create one LunoraClient and mount it for the subtree with <LunoraProvider>. This is required at the root.

src/main.tsx
import { LunoraClient } from "lunorash/client";
import { LunoraProvider } from "@lunora/react";

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

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

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

Live queries

useQuery(fn, args) subscribes to a query and re-renders on every server delta. It returns T | undefined (undefined until the first response). Pass "skip" as args to short-circuit with no network call. Components that pass identical args share one underlying subscription.

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

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

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

    return (
        <ul>
            {messages?.map((m) => (
                <li key={m._id}>{m.text}</li>
            ))}
        </ul>
    );
}

useSubscription(fn, args) is the lower-level form that surfaces { data, error } so you can render error states inline.

Mutations

useMutation(fn) returns { mutate, pending, data, error, isError, reset, withOptimisticUpdate } — mutate is the callable, the hook result itself is not. You pass optimistic updates per call, and they roll back automatically if the server rejects.

import { useMutation } from "@lunora/react";

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

function Composer({ channelId }: { channelId: string }) {
    const { mutate: send, pending } = useMutation(api.messages.send);

    const onSend = () =>
        void send(
            { channelId, text: "hi" },
            {
                // Name the query the write affects — `send` and `list` are
                // different functions, and only you know they're related.
                optimisticUpdate: (store, args) => {
                    const current = store.getQuery(api.messages.list, { channelId: args.channelId }) ?? [];

                    store.setQuery(api.messages.list, { channelId: args.channelId }, [...current, { _id: "tmp", text: "hi" }]);
                },
            },
        );

    return (
        <button disabled={pending} onClick={onSend}>
            Send
        </button>
    );
}

There is also a per-call optimistic: (current) => next shortcut, but it patches only the subscription registered under the mutation's own reference and args — a query and a mutation that share a path (a counter, a document by id). For the send-updates-list shape above, optimisticUpdate is the option.

Auth

useAuth() returns { status, user, token, setToken(next) }. Call setToken(jwt) after a sign-in flow, and every subsequent RPC carries the token automatically.

Gate on status ("loading" / "authenticated" / "unauthenticated" / "unreachable"), not on whether user is null: user is null both when signed out and when a held credential's identity has not resolved yet, so a user gate shows the sign-in form to a signed-in user on an offline reload.

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

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

Reactive loaders

Render with data already present on the first paint, then go live. Run the query on the server with the socket-free @lunora/react/server entry, then either hydrate the TanStack cache or thread an explicit Preloaded token.

app/posts/page.tsx (Server Component)
import { createServerClient, dehydrate, HydrationBoundary, prefetchQuery } from "@lunora/react/server";
import { QueryClient } from "@tanstack/react-query";

import { api } from "@/lunora/_generated/api";
import { PostList } from "./post-list";

export default async function PostsPage() {
    const client = createServerClient({ url: process.env.LUNORA_URL! });
    const queryClient = new QueryClient();

    await prefetchQuery(queryClient, client, api.posts.list, {});

    return (
        <HydrationBoundary state={dehydrate(queryClient)}>
            <PostList />
        </HydrationBoundary>
    );
}

On the client, useQuery(api.posts.list, {}) reads the prefetched value from cache on first render (no loading flash), then a live subscription attaches on mount. To thread a single value without a HydrationBoundary, use preloadQuery on the server and read it with usePreloadedQuery on the client. See Reactive loaders for the full handoff and @lunora/react for the server reference.

Create a createServerClient per request. The bearer token and forwarded cookies are per-user, so a shared module-level client would leak identity.

One worker, one deploy

With TanStack Start, Lunora owns the worker entry (a class-A framework in Bring your framework), so the realtime plane and your SSR share one worker with no extra wiring. On the Next.js App Router, load data through @lunora/react/server as above. See Deploy your framework for the one-worker deploy story.