Use the React hooks
@super-line/react has one binding for the whole app. Declare your contract and role once, by declaration merging, and every module-level hook is typed by it — no factory call, no destructuring, no generic threading at call sites:
// superline.d.ts (any ambient .ts in your app source)
import type { chat } from './contract'
declare module '@super-line/react' {
interface Register {
contract: typeof chat
role: 'user'
}
}import { createSuperLineClient } from '@super-line/client'
import { webSocketClientTransport } from '@super-line/transport-websocket'
import { SuperLineProvider, useSuperLineClient } from '@super-line/react'
function Root() {
// StrictMode-safe ownership: built in a committed effect, closed on unmount.
const client = useSuperLineClient(() => createSuperLineClient(chat, {
transport: webSocketClientTransport({ url: 'ws://localhost:3000' }),
role: 'user',
params: { name: 'ada' },
}), [])
return (
<SuperLineProvider client={client}>
<Room room="lobby" />
</SuperLineProvider>
)
}Skipping the registration is a hard type error at the provider (__superLineRegisterMissing), never a silent fall back to loose types. Register is one declaration per TypeScript program — an app declares it exactly once. (A package that ships code registering would leak the declaration to its consumers; keep it in a source-only ambient file the .d.ts build does not emit.)
Using plugin-auth? Mount <SuperLineAuthProvider> instead — it owns the session lifecycle and feeds this same context, so every hook below follows sign-in/sign-out with no wiring.
Own the client with useSuperLineClient
createSuperLineClient connects on construction and close() is terminal, so a client created during render (a useState(() => …) initializer) leaks a connected socket every time React double-invokes the render in dev. useSuperLineClient(make, deps?) builds in a committed effect, closes in its cleanup, and rebuilds when deps change — it returns null until the first commit, which is exactly the state every hook idles on. See reconnection & delivery for what survives a drop.
Call requests, read topics, handle events
import { useEvent, useMaybeClient, useRequest, useSubscription } from '@super-line/react'
function Room({ room }: { room: string }) {
// With an input, useRequest AUTO-FETCHES: on mount, when the input changes, and on a new session.
const { data: joined, loading, refetch } = useRequest('join', { room })
// Without an input it is purely manual — nothing fires until you call. This is the mutation form.
const { call: send, loading: sending } = useRequest('send')
// useSubscription: latest topic value (or undefined before the first message)
const presence = useSubscription('presence')
// useEvent: run a handler on each pushed event
useEvent('message', (m) => append(m))
const online = presence?.room === room ? presence.count : (joined?.count ?? 0)
const onSubmit = (text: string) => send({ room, text }).catch(() => {})
// ...
}Arity is the mode switch for useRequest: supply an input argument and it auto-fetches (re-running when the input's JSON changes and when the client swaps), omit it and only call(input) ever fires. A request that takes no input opts into auto-fetching with an explicit undefined input; { enabled: false } holds fire until you flip it (enabled: !!channelId covers "wait until known"). Auto-fetch fires exactly once per (client, input) even under StrictMode's double-mounting.
| Hook | Returns | Behavior |
|---|---|---|
useRequest(method, input?, { enabled? }) | { data, error, loading, refetch, call } | Auto-fetches when an input is supplied; call(input) is the manual path; refetch() re-runs with the hook's input (rejects in manual mode). Last call wins the rendered state. |
useSubscription(topic) | latest value (undefined before the first message) | Subscribes on mount, re-renders on each new value, unsubscribes on unmount. |
useEvent(event, handler) | — | Invokes handler for each pushed event; the latest handler is always used (no stale closures). |
useClient() / useMaybeClient() | SuperLineClient<C, R> (throws with no client) / … | null | The underlying client, for anything the hooks don't cover. |
useEnv() | EnvOf<C, R> | null | Tracks the connection's server-vended env; null until the first push, then the latest value. |
For the wire patterns behind these — see requests, events & rooms, and topics.
Track persisted state with collections
The contract's collections get two reactive hooks. Both subscribe on mount, clean up on unmount, and expose ready — the difference between "the snapshot hasn't landed" and "genuinely empty".
useCollection(name, query?) tracks a live row set: the filtered snapshot re-renders as the server pushes matching inserts/updates/deletes, and insert/update/delete/batch write through. Pass null as the query for the explicit idle state (no subscription at all — distinct from undefined, which subscribes to the whole collection).
function Channel({ id }: { id: string }) {
const { rows, ready, insert } = useCollection('messages', { filter: eq('channelId', id) })
if (!ready) return <Spinner />
return (
<>
{rows.map((m) => <p key={m.id}>{m.text}</p>)}
<button onClick={() => insert({ id: crypto.randomUUID(), channelId: id, text: 'hi' })}>send</button>
</>
)
}The underlying surfaces stay reachable: handle (the client's CollectionHandle, stable per client+name — one-shot reads via handle.query(q)) and sub (the live LiveRowSet). They're windows, not ownership — the hook closes the subscription itself.
useDoc(name, id, deps?) opens a CRDT document and tracks it reactively. The id can be a string, null/undefined (the idle state — nothing opens), or a resolver () => id | Promise<id> for ids that arrive asynchronously (a registry lookup, a request); the resolver re-runs when deps change, and an inline arrow is safe.
function Doc({ id }: { id: string | null }) {
const { data, ready, error, deleted, update, delete: del } = useDoc('scenes', id)
if (error) return <p>Can't open this doc.</p>
if (deleted) return <p>This doc was deleted.</p>
if (!ready) return <p>Loading…</p>
return <input value={data!.title} onChange={(e) => update({ title: e.target.value })} />
}readyflips true only after the catch-up snapshot has applied. A just-opened doc exposes a (possibly empty) local snapshot before that, so gate onready, not ondata— especially before binding an editor tonative(an early binding writes real ops that merge with the arriving content).errorcarries a denied or absent open (NOT_FOUND,FORBIDDEN) and resolver failures — they used to be invisible.update(partial)merges a partial;delete(path)surgically removes the value at a key path — concurrent edits to sibling keys merge instead of clobbering.deletedflips totrueonce the server fans the document's deletion across the cluster.handleis the reactiveDocHandleandnativethe CRDT engine's own document (narrow it with the engine's accessor,yDocOffor Yjs) — both identity-stable until the underlying document is replaced.
For client query joins and optimistic UI over collections, wire them into TanStack DB — see the TanStack DB adapter. For the raw LiveRowSet → React glue (a set you built yourself from the client surface), useLiveQuery(make, deps) is the context-free primitive useCollection itself is built on.
Read the connection's env
useEnv() tracks the connection's server-vended, client-visible env reactively: null until the first push (or for a role with no env), then the latest value — re-renders on every server-side setEnv.
function Toolbar() {
const env = useEnv() // typed EnvOf<C, R>; null until the first push
if (!env) return null
return <span>project: {env.projectId}</span>
}env carries credentials — wire it into effects/calls, never render a raw secret. See connection env for declaring the shape, seeding it at connect, and rotating it live.
StrictMode is supported
Every client-owning surface builds in a committed effect and pairs every build with exactly one close — useSuperLineClient for your own client, <SuperLineAuthProvider> for a session, plugin-chat's <ChatProvider> for its chat client. StrictMode's dev-mode double-invoke therefore opens and closes one extra, properly-paired connection and nothing leaks; the examples all run with StrictMode on.
The factory escape hatch
createSuperLineHooks<C, R>() returns the same hooks bound to a private context — for the rare app that talks to two contracts (each factory instance is its own world), and for tests. Don't mix the two: hooks from a factory instance never see what the registered provider feeds, and vice versa. Pick one surface per app and use it end to end.
const { Provider, useRequest, useCollection } = createSuperLineHooks<typeof chat, 'user'>()Next: Testing.