React
@transport-io/react is the binding. It is a separate package with its own version, and no
core code depends on it or refers to it.
npm install @transport-io/reactReact and react-dom are peer dependencies, and the floor is React 19.2, because
useEvent is built on useEffectEvent. There is no runtime dependency beyond that.
The complete app is
examples/react:
chat with live cursors on these hooks, under Vite, with nothing else in the way.
Bind the hooks to your map
Section titled “Bind the hooks to your map”createHooks<AppMap>() returns the hooks typed for one contract. That is the whole setup.
import { defineContract, type MapOf, reliable, rpc, streaming } from 'transport-io'import { createHooks } from '@transport-io/react'
export const contract = defineContract({ chat: reliable<{ from: string; body: string }>(), save: rpc<{ text: string }, { n: number }>(), ask: streaming<{ prompt: string }, string>(),})export interface AppMap extends MapOf<typeof contract> {}
export const api = createHooks<AppMap>()Then api.useEvent('chat', …) anywhere, or destructure it:
export const { useEvent, useCall } = createHooks<AppMap>().
Write the MapOf line. Without it, every hook’s hover shows the whole contract with your
validator’s internals in it.
The named exports (useEvent, useCall, …) read the globally registered map instead; see
Registering the map.
An application with no fallback says so, and useClient() is then the Client, with call
and stream on it, instead of a union that has to be narrowed through useNative():
import { createHooks } from '@transport-io/react'import type { AppMap } from './api.ts'
export const native = createHooks<AppMap>({ fallback: false })It is checked: a client built with withFallback under these hooks throws on first use.
The provider takes a client
Section titled “The provider takes a client”It does not make one. A module-level client is a cross-request state leak on anything rendering more than one user, so build it inside the component.
'use client'import { Client } from 'transport-io'import { connectBrowser } from 'transport-io/browser-transport'import { TransportProvider } from '@transport-io/react'import { type ReactNode, useState } from 'react'import { type AppMap, contract } from './api.ts'
export function Providers({ children }: { children: ReactNode }): ReactNode { // `useState` with an initialiser, so one client per mounted tree rather than one per // render, and never one shared between server requests. const [client] = useState( () => new Client<AppMap>({ contract, connect: () => connectBrowser({ url: 'https://127.0.0.1:4433/' }), }), ) return <TransportProvider client={client}>{children}</TransportProvider>}new Client here rather than browserClient: TransportProvider wants the client before
it is connected, since it does the connecting itself in an effect, so the client has to exist
synchronously inside the useState initialiser.
TransportProvider connects while it is mounted. Pass autoConnect={false} to drive the
connection yourself. connect and disconnect are refcounted in core, so two providers or a
StrictMode double mount cannot tear down each other’s session.
Your connect function must be able to produce a new connection each time it is called.
A reconnect is a new session, and StrictMode calls it twice in development. connectBrowser
already does this; a hand-rolled one that returns a single fixed connection will hang on the
second call.
A client built with withFallback fits the provider as well. On a fallback session, which
carries emits and nothing else, useCall and useStream report unavailable before anything
is asked, useNative() is null, and useConnection().transport is 'websocket'. Nothing
is discovered by calling: the call’s promise rejects with WT_LANE_UNAVAILABLE and asks the
server nothing.
Connection state
Section titled “Connection state”import type { ReactNode } from 'react'import { api } from './api.ts'
export function Status(): ReactNode { const { status, rooms, lastError, refused } = api.useConnection() // Refused by the server's authorize. Final: nothing is retrying, so ask for a sign-in. if (refused !== null) return <p>Please sign in again.</p> // Your own words, chosen by the code. `lastError.message` is written for a log. if (status === 'closed' && lastError !== null) { return <p>{lastError.code === 'WT_NO_SUPPORT' ? 'This browser is not supported.' : 'Offline.'}</p> } return ( <p> {status}, in {rooms.length} room(s) </p> )}All state comes through useSyncExternalStore, and the object this returns is referentially
stable: it changes only when the connection state does, so putting it in a dependency array
is safe. transport and fallbackReason are on the same object: what carries the session,
and why it is a fallback when it is. The fallback covers the rest.
refused is there too: { reason } when the server’s authorize refused this client, and
null otherwise. Authenticating a peer has the reasons and the way
back in.
During server rendering it reports idle. That is true, since no connection exists on a
server, and it makes the server’s HTML identical to the client’s first render, so hydration
has nothing to reconcile. The connect effect then drives the only transition.
Signing in again, without a reload
Section titled “Signing in again, without a reload”A refusal is final, so the page has to start the next attempt itself, and under the provider
it does not own the connection: the provider holds the one connect() for the tree. The way
through is the pair from useConnection(). disconnect() takes that hold to zero and
connect() takes it back to one, so the provider’s own disconnect() on unmount still
closes the client. connect in the client’s options runs on every attempt, so the attempt
the pair starts reads the token as it is by then.
'use client'import { Client } from 'transport-io'import { connectBrowser } from 'transport-io/browser-transport'import { TransportProvider } from '@transport-io/react'import { type ReactNode, useState } from 'react'import { api, type AppMap, contract } from './api.ts'
// Your sign-in: the form stores the token wherever your app keeps it, and this reads it.declare function SignInForm(props: { onSignedIn: () => void }): ReactNodedeclare function currentToken(): string
function urlWithToken(): string { const url = new URL('https://example.com:4433/') // Read on every attempt, so it is whatever the last sign-in stored. url.searchParams.set('token', currentToken()) return url.href}
export function SignedIn({ children }: { children: ReactNode }): ReactNode { const { refused, connect, disconnect } = api.useConnection() if (refused === null) return children return ( <SignInForm onSignedIn={() => { disconnect() // A second refusal lands in `refused` again, so the rejection is not news here. void connect().catch(() => undefined) }} /> )}
export function App({ children }: { children: ReactNode }): ReactNode { const [client] = useState( () => new Client<AppMap>({ contract, reconnect: { minMs: 500, maxMs: 30_000 }, connect: () => connectBrowser({ url: urlWithToken() }), }), ) return ( <TransportProvider client={client}> <SignedIn>{children}</SignedIn> </TransportProvider> )}Call the two together and in that order. connect() alone would take the hold to two, and
the client would stay connected after the provider unmounted. Under transport-io dev the
URL comes from the manifest, so the token goes in connectDev({ query: () => ({ token }) })
instead; Authenticating a peer has that form.
Events
Section titled “Events”import { type ReactNode, useState } from 'react'import { api } from './api.ts'
export function Messages(): ReactNode { const [lines, setLines] = useState<string[]>([])
// A fresh arrow every render, and the subscription still happens once. api.useEvent('chat', (msg) => { setLines((prev) => [...prev, `${msg.from}: ${msg.body}`]) })
return <ul>{lines.map((l) => <li key={l}>{l}</li>)}</ul>}You do not have to memoise the handler. useEvent wraps it in an Effect Event, so the
subscription depends only on the client and the event name while the handler always sees the
latest render’s closure. Unsubscribing is wired to effect cleanup.
Handlers attach to the client, not to a session. A component mounted before the connection opens receives everything from the first session, and from every session a reconnect produces, without doing anything about it.
State is a discriminated union rather than independent flags, so checking status narrows
data and the impossible combinations cannot be written down.
import type { ReactNode } from 'react'import { api } from './api.ts'
export function Save(): ReactNode { const [save, state] = api.useCall('save')
return ( <> <button type="button" onClick={() => void save({ text: 'hello' })}> Save </button> {state.status === 'pending' && <span>saving…</span>} {state.status === 'error' && <span>{state.error.code}</span>} {state.status === 'success' && <span>{state.data.n} characters</span>} {state.status === 'unavailable' && <span>not on this connection</span>} </> )}The function resolves to the answer as well, so a handler that wants the value right away has it without reading the state:
import { api } from './api.ts'
export function useSaveAndTell(): (text: string) => Promise<string> { const [save] = api.useCall('save') return async (text) => { const { n } = await save({ text }) return `${n} characters` }}It rejects with the TransportError on failure, with WT_ABORTED when a newer call
superseded it or the component unmounted, and with WT_LANE_UNAVAILABLE on a fallback
session. A caller that ignores the promise, as the button above does, reads the failure from
state and never sees an unhandled rejection.
unavailable is the state on a fallback session, where there is no stream to carry a call.
It is there before the button is pressed, and pressing it asks the server nothing.
Unmounting aborts an in-flight call. An unmounted component’s answer goes nowhere, and
aborting is a QUIC stream reset that costs no application message. That bites when the call
has a server-side effect that must finish regardless, so pass
api.useCall('save', { abortOnUnmount: false }) for those.
Streams
Section titled “Streams”import type { ReactNode } from 'react'import { api } from './api.ts'
export function Ask(): ReactNode { const [ask, state, stop] = api.useStream('ask')
return ( <> <button type="button" onClick={() => ask({ prompt: 'hello' })}> Ask </button> {state.status === 'streaming' && ( <button type="button" onClick={stop}> Stop </button> )} {state.status === 'unavailable' && <span>not on this connection</span>} {state.status !== 'idle' && <p>{state.elements.join('')}</p>} </> )}elements keeps its identity between renders and is replaced only when something is
appended, so memoising on it works. It also grows for the life of the stream: pass
{ onElement } to render without accumulating.
Unmounting cancels, and so does stop. Either resets the QUIC stream, the responder sees
STOP_SENDING, its ctx.signal fires and any finally in its generator runs, so a component
going away does not leave a generator producing into nothing. After stop the state is
done, holding what had arrived.
Server components
Section titled “Server components”Every hook is a client-side thing and the entry carries 'use client'. A server component
that calls one gets React’s own error saying hooks are not available there. A hook used
outside the provider throws an error naming
TransportProvider rather than reading a property of undefined.
Devtools
Section titled “Devtools”<TransportDevtools client={client} /> from @transport-io/devtools/react puts a panel in
the page with the session’s frames, streams and drops. It takes the client as a prop, renders
null unless the build is a development one, and mounts nothing unless you render it. See
Devtools.
StrictMode
Section titled “StrictMode”Development mounts every component twice. Refcounting makes that safe, and it is worth knowing what it actually does: the refcount goes 1, 0, 1, and at zero the session genuinely tears down and is rebuilt. One wasted connection cycle, in development only.