Skip to content

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.

Terminal window
npm install @transport-io/react

React 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.

createHooks<AppMap>() returns the hooks typed for one contract. That is the whole setup.

api.ts
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.

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.

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.

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 }): ReactNode
declare 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.

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.

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.

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.

<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.

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.