HighX
Client

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:

src/main.tsx
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
socketConstructorThe WebSocket implementation. Defaults to the global one; pass your own in tests
token, refreshTokenFor token sessions. Omit both for cookies — the browser attaches them to the handshake
heartbeatIntervalMsHow often to prove the connection is alive. Default 30s
backoffReconnect delays
onStatusChangeconnecting, open, closed
maxQueuedMutationsHow 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
dataThe rows, or undefined before the first snapshot
isLoadingTrue only when there is neither data nor seeded initial data
sequenceThe server sequence this data is at
Option
sortHow to order the rows. Needed for any ordered query — see below
initialData, sequenceA server-rendered snapshot to seed from → SSR
subscriptionIdAn 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:

src/transport.ts
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.

Next

On this page