@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 }, { optimistic: (current) => [...(current ?? []), draft] });For a multi-query optimistic update bound to every call, use
withOptimisticUpdate(update), which returns the same handle with the update
applied by default.
useSubscription(fn, args)
Lower-level. Surfaces { data, error } so you can render error states
without a separate useState.
useAuth()
Returns { user, token, setToken(next) }. Call setToken(jwt) after a
sign-in flow; subsequent RPC calls carry the token automatically.
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.
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>
);
}"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.
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} />;
}"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
return <h1>{post.title}</h1>;
}Providers
Mount LunoraProvider once in a Client Component near the root. It creates (or
reuses) the TanStack QueryClient that HydrationBoundary hydrates into.
"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-onlyLunoraClient. Build one per request so a user'stoken(and any cookies forwarded viafetch) never leak across requests.prefetchQuery(queryClient, client, fn, args, { shardKey? }?): runs the query and seedsqueryClientunder the key the client hooks use.preloadQuery(client, fn, args, { shardKey? }?): runs the query and returns a serializablePreloadedtoken forusePreloadedQuery.dehydrate/HydrationBoundary: re-exported from@tanstack/react-queryfor 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, allowedTypes: ["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.