React
The provider, queries, mutations, and run streaming.
Wiring it up
@highx/react contains no WebSocket code. You give it a transport — an
object that owns the connection — and @highx/client builds one:
import { createRoot } from "react-dom/client";
import { HighXProvider } from "@highx/react";
import { createBrowserTransport } from "@highx/client";
const transport = createBrowserTransport("wss://example.com/ws");
createRoot(element).render(
<HighXProvider cache={transport.cache} transport={transport}>
<App />
</HighXProvider>,
);Create it outside your components. A transport built during a render opens a second socket on every re-render.
Pass the transport's own cache, as above. If the provider had a cache of its own, patches would arrive in one and components would read the other — the UI loads correctly and then never updates.
Transport options
createBrowserTransport(url, options);| Option | |
|---|---|
socketConstructor | The WebSocket implementation. Defaults to the global one; pass your own in tests |
token, refreshToken | For token sessions. Omit both for cookies — the browser attaches them to the handshake |
heartbeatIntervalMs | How often to prove the connection is alive. Default 30s |
backoff | Reconnect delays |
onStatusChange | connecting, open, closed |
maxQueuedMutations | How many mutations may wait for a connection before the client rejects |
It also exposes client, the HighXClient underneath.
useQuery
import { useQuery } from "@highx/react";
import { api } from "../.highx/api.js";
const notes = useQuery(api.notes.list, {}, { sort: byUpdatedAt });| Returns | |
|---|---|
data | The rows, or undefined before the first snapshot |
isLoading | True only when there is neither data nor seeded initial data |
sequence | The server sequence this data is at |
| Option | |
|---|---|
sort | How to order the rows. Needed for any ordered query — see below |
initialData, sequence | A server-rendered snapshot to seed from → SSR |
subscriptionId | An explicit ID, when one subscription must outlive a component |
There is no refetch, because there is nothing to refetch. The subscription is
released automatically when the last component using it unmounts.
Declare sort for ordered queries. A patch says which rows changed, not
where they belong — the client merges by id, so a changed row keeps its position
and a new one lands at the end. Without sort, an ordered list drifts as edits
arrive.
Two components calling useQuery with the same path and arguments share one
server subscription and one cache entry, reference counted. Argument key order
does not matter, so call it in as many components as you like.
useMutation
const save = useMutation(api.notes.save);
const id = await save({ title: "New", text: "…", color: "amber" });It resolves with what the handler returned and rejects with an error
carrying the server's code.
The returned function is stable across renders, changing only when the transport or path does. That is worth knowing about:
useEffect(() => { void announce(); }, [announce]);would otherwise re-run on every render — and in a live application that is not a slow loop but a runaway one, since the write patches a subscription, the patch re-renders, and the render writes again.
For an optimistic version, see optimistic updates.
useConnectionStatus
const status = useConnectionStatus(); // "connecting" | "open" | "closed" | undefined
{status === "closed" && <p>Offline — your edits will be sent when the connection returns.</p>}undefined means the transport does not report a status, which is not the
same as being disconnected. A test fake, an adapter over something that is not a
socket, and server rendering all land there.
Worth showing the healthy state too: a badge that only appears when something is wrong is a badge nobody has learned to look for.
useRunStream
useRunStreamState renders model output while it is being produced and then
reconciles it with the durable run result supplied by the application query:
import { useRunStreamState } from "@highx/react";
const stream = useRunStreamState(runId, durableRun);
return <>
<p aria-live="polite">{stream.text}</p>
{stream.approvalRequired && <p>Waiting for approval.</p>}
{stream.error !== undefined && <p role="alert">{stream.error}</p>}
</>;The state includes idle, streaming, waiting, completed, failed, and
cancelled statuses. Preview text is bounded to 200,000 characters. The
text-only useRunStream(runId, durableRun) form is available when metadata is
not needed.
Writing a transport by hand
createBrowserTransport is a convenience, not a privileged path. The interface
is three members:
import type { QueryTransport } from "@highx/react";
import { HighXClient, webSocketFactory, type ClientCache } from "@highx/client";
export class BrowserTransport implements QueryTransport {
private readonly client = new HighXClient({ socket: webSocketFactory(url, WebSocket) });
public get cache(): ClientCache { return this.client.cache; }
public subscribe(path: string, args: unknown): () => void {
return this.client.subscribe(path, args);
}
public mutate(path: string, args: unknown, requestId: string): Promise<unknown> {
return this.client.mutate(path, args, { requestId });
}
public close(): void { this.client.close(); }
}It mounts exactly like the built one, because the provider depends on the shape rather than on either implementation.