Skip to content

Tutorial 3 · Make it React

Tutorials2 · Connect a typed client3 · Make it React4 · 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.

~8 minutesBuilds on Tutorial 2React 18+ · StrictMode-safe

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.

tutorial 3 · two React roots, one live row-setbooting…
What's real here: two real react-dom roots running the TodoTab component shown below — real StrictMode, real useSuperLineClient/useCollection, one real server with the todos collection. In-tab substitution: the loopback wire instead of WebSocket.

1. Install

In a React project (Vite, Next, anything React 18+):

bash
pnpm add @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zod
bash
npm install @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zod
bash
yarn add @super-line/react @super-line/client @super-line/core @super-line/transport-websocket zod

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

ts
// 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).

tsx
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:

ts
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:

tsx
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 wroteWhat it does
declare module … RegisterTypes 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 / useRequestTutorial 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

Released under the MIT License.