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.
Install
pnpm add @lunora/reactProvide the client
Create one LunoraClient and mount it for the subtree with <LunoraProvider>.
This is required at the root.
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.
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.