Tutorial 3 · Make it React
Tutorials → 2 · Connect a typed client → 3 · Make it React → 4 · Store your data
@super-line/react turns the wire into hooks: requests, events, and topics from Tutorial 2, plus live data that re-renders as rows change. You declare your contract once and every hook in the package is typed by it — no factory threading, no generics at call sites. And because this docs site can run super-line in the page, the demo below is two real React apps, not a recording.
Register interface RegisterOwn useSuperLineClientLive useCollection
First, see it run
Two real react-dom roots — each its own client connection, both on one in-tab server. Add a todo on either side, toggle the seed row — the other React app re-renders from the live subscription. The component source is further down this page, and it is the same module running here: imported, not transcribed.
1. Install
In a React project (Vite, Next, anything React 18+):
pnpm add @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zodnpm install @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zodyarn add @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zod2. Register your contract — once
Declare the app's contract + role by declaration merging, one time, in any ambient file your app compiles (superline.d.ts is the convention). Every module-level hook (useRequest, useEvent, useSubscription, useCollection, useDoc, useEnv, useClient) is typed by it from then on — and skipping it is a hard type error at the provider, never a silent fall-back to loose types:
// superline.d.ts
import type { chat } from './contract' // Tutorial 1's contract
declare module '@super-line/react' {
interface Register {
contract: typeof chat
role: 'user'
}
}One app, one contract
Register is a program-wide singleton — exactly one declaration per app. Multi-contract apps and tests use the createSuperLineHooks<C, R>() factory instead, which returns the same hooks bound to their own context.
3. Own the client, provide it, use the hooks
Construction connects — so a client must be built in a committed effect, not during render, or React StrictMode's double-invoke leaks a socket. useSuperLineClient is that pattern, packaged; it returns null until the first commit, and SuperLineProvider accepts null (hooks idle instead of throwing).
import { useState } from 'react'
import { createSuperLineClient } from '@super-line/client'
import { webSocketClientTransport } from '@super-line/transport-websocket'
import {
SuperLineProvider,
useSuperLineClient,
useEvent,
useSubscription,
useRequest,
} from '@super-line/react'
import { chat } from './contract'
export function App() {
const client = useSuperLineClient(
() =>
createSuperLineClient(chat, {
transport: webSocketClientTransport({ url: 'ws://localhost:3000' }),
role: 'user',
params: { name: 'ada' },
}),
[], // deps — rebuild (reconnect) when these change
)
return (
<SuperLineProvider client={client}>
<Room />
</SuperLineProvider>
)
}
function Room() {
const [log, setLog] = useState<string[]>([])
useEvent('message', (m) => setLog((l) => [...l, `${m.from}: ${m.text}`])) // event → handler
const presence = useSubscription('presence') // topic → latest value (undefined before the first push)
const join = useRequest('join') // no input argument = manual mode: fire via call()
const send = useRequest('send')
return (
<>
<p>{presence ? `${presence.count} online in ${presence.room}` : 'no presence yet'}</p>
<button onClick={() => join.call({ room: 'lobby' })}>join lobby</button>
<button onClick={() => send.call({ room: 'lobby', text: 'hi from React' })} disabled={send.loading}>
say hi
</button>
<ul>{log.map((line, i) => <li key={i}>{line}</li>)}</ul>
</>
)
}Run Tutorial 1's server (npm run server) and this component talks to it — the same three patterns as Tutorial 2, now rendering.
useRequest has two modes, switched by arity
useRequest('send') is manual — nothing fires until you call(input); that's your mutation shape. useRequest('getProfile', { id }) — with an input argument — auto-fetches on mount, when the input changes, and when the client swaps, exposing { data, error, loading, refetch }. One hook, TanStack-style, for both.
4. The live data hook
The demo at the top isn't using useEvent — it's using useCollection, the hook over super-line's synced, typed row-sets. Here is the actual source it runs. First the contract — one new block, collections:
import * as z from 'zod'
import { defineContract } from '@super-line/core'
// One typed table, declared on the contract — the server validates every write
// against this schema, and RowOf<typeof app, 'todos'> flows to both ends.
export const app = defineContract({
collections: {
todos: {
schema: z.object({
id: z.string(),
text: z.string(),
done: z.boolean(),
createdAt: z.number(),
}),
key: 'id',
},
},
roles: { user: { clientToServer: {} } },
})Then the component — TodoTab is what's mounted twice above:
import { StrictMode, useState, type FormEvent } from 'react'
import type { ClientTransport } from '@super-line/core'
import { createSuperLineClient } from '@super-line/client'
import { SuperLineProvider, useSuperLineClient, useCollection } from '@super-line/react'
import { app } from './todos-contract'
// Declare your contract + role ONCE and every module-level hook is typed by it —
// no factory call, no generics at call sites.
declare module '@super-line/react' {
interface Register {
contract: typeof app
role: 'user'
}
}
/** One "browser tab": owns a client (StrictMode-safe) and provides it to the hooks. */
export function TodoTab({ transport, user }: { transport: ClientTransport; user: string }) {
// Built in a committed effect, closed on unmount — never leaks a socket under StrictMode.
const client = useSuperLineClient(
() => createSuperLineClient(app, { transport, role: 'user', params: { user } }),
[transport, user],
)
return (
<StrictMode>
<SuperLineProvider client={client}>
<TodoList user={user} />
</SuperLineProvider>
</StrictMode>
)
}
function TodoList({ user }: { user: string }) {
// A live, typed row-set: snapshot first (`ready`), then every change — yours and
// the other tab's — lands in `rows` and re-renders this component.
const { rows, ready, insert, update } = useCollection('todos', {
orderBy: [{ field: 'createdAt', dir: 'asc' }],
})
const [draft, setDraft] = useState('')
const add = async (e: FormEvent) => {
e.preventDefault()
const text = draft.trim()
if (!text) return
setDraft('')
await insert({ id: crypto.randomUUID(), text, done: false, createdAt: Date.now() })
}
return (
<div className="ri-tab">
<header className="ri-head">
<b>{user}</b>
<span className="ri-state">{ready ? `${rows.length} todos · live` : 'loading…'}</span>
</header>
<ul className="ri-list">
{rows.map((todo) => (
<li key={todo.id}>
<label className={todo.done ? 'is-done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => update({ ...todo, done: !todo.done })}
/>
<span>{todo.text}</span>
</label>
</li>
))}
</ul>
<form className="ri-add" onSubmit={add}>
<input
className="ds-field"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={`add a todo as ${user}`}
aria-label={`add a todo as ${user}`}
/>
<button className="ds-btn ds-btn--primary" type="submit" disabled={!ready || !draft.trim()}>
add
</button>
</form>
</div>
)
}useCollection('todos', query) gives you rows (identity-stable, ordered), ready (snapshot applied — so "loading" and "genuinely empty" are distinguishable), error, and typed insert / update / delete / batch mutations. Every change any client writes lands in every subscribed component.
Where do those rows actually live?
On the server — validated against the schema, guarded by policies, persisted by a backend. You just used all of that without seeing it. Pulling that curtain back is exactly the next lesson.
What just happened
| What you wrote | What it does |
|---|---|
declare module … Register | Types every module-level hook from your one contract. No factory, no generics at call sites. |
useSuperLineClient(make, deps) | StrictMode-safe client ownership: built in a committed effect, closed on unmount, rebuilt on deps change. |
<SuperLineProvider client={…}> | Feeds the one context every hook reads. null = "not connected yet" — hooks idle. |
useEvent / useSubscription / useRequest | Tutorial 2's three wire patterns, as hooks. |
useCollection(name, query) | A live, typed row-set with ready/error and typed mutations. |
Next: the machinery under useCollection
Declare a collection, secure it with row-level policies, give the server a storage backend — and find out why the demo's rows can outlive the server that served them.
Continue the series
Tutorial 4 · Store your data → — collections, policies, and swappable storage backends, with a kill-the-server-keep-the-data demo.
Or branch off from here
- Use the React hooks — the full hook surface, including
useDoc,useEnv, and the factory form. - Querying with TanStack DB — joins, live queries, and optimistic writes over the same sync source.
- Debug a tab with DevTools — watch these hooks' frames in the browser panel.