React

Start from a snapshot, fold new events, and push optimistic writes through one shared session.

Publish the state

The reducer lives with the event vocabulary, in a shared module:

model.ts
import { contract } from 'experimental-a2'
import { z } from 'zod'

export const counter = contract({
  name: 'counter',
  events: { added: z.number() },
  reducer: {
    name: 'count.v1',
    initialState: 0,
    fold: (state, event) => state + event.payload,
  },
})

A contract can publish one reducer. The client starts from a snapshot of its state and folds later events locally, for one known session ID. Queries across sessions belong in your application database.

The reducer name identifies its snapshot format: change it when the fold's meaning or stored shape changes, and run the same version on server and client. An inline fold returning a discriminated union may need as const or an explicit return type; the reducer builder fixes the state type first. Other reducers read on the server with session.state(reducer) publish no client state.

server/counter.ts
import { createServer } from 'experimental-a2/server'
import { counter } from '../model'

export const counterServer = createServer({ contract: counter })
app/api/counter/route.ts
import { counterServer } from '../../../server/counter'

export const GET = counterServer.fetch
export const POST = counterServer.fetch

GET serves the subscription and history reads; POST appends writes. Authentication and authorize live on Routes. Bootstrap is authorized as a stream operation with bootstrap: true before A2 reads state.

Bind React once

createReact binds the client's types into a Provider and hook. It creates no separate connection pool.

app/counter/session.ts
'use client'
import { createClient } from 'experimental-a2/client'
import { createReact } from 'experimental-a2/react'
import { counter } from '../../model'

export const counterClient = createClient({
  contract: counter,
  api: '/api/counter',
})

export const { SessionProvider, useSession } = createReact({
  client: counterClient,
})
app/counter/counter.tsx
'use client'
import { useSession } from './session'

export function Counter() {
  const { state, connection, push } = useSession()
  const error = 'error' in connection ? connection.error : null

  return (
    <>
      <button onClick={() => void push({ type: 'added', payload: 1 })}>
        Count: {state}
      </button>
      {error && <p role="alert">{error.message}</p>}
    </>
  )
}

useSession() returns state, hydrated, events, index, connection, push, reconnect, loadHistory, and history; presence contracts add presence and setPresence. The reference covers each.

Without a seed, state starts at reducer.initialState (through the reducer's client projection when present), which can look empty while an existing session loads. The subscription's first frame is a snapshot that replaces it atomically. Later events fold on top, including any appended while the snapshot was read. connection stays connecting until then; bootstrap failures retry like any stream failure, and a reconnect keeps the last state.

A push during bootstrap is sent right away, and its optimistic update appears once the snapshot is applied. If it is still unacknowledged when the snapshot arrives, it stays hidden until its ack (the snapshot may already contain it), along with the pushes queued behind it, so the view never skips a write. The push promise resolves on the server ack; .confirmed waits for stream delivery, and the snapshot counts as delivery through its index.

Without server rendering

A Provider needs only the session ID. It supplies scope and optional data; mounted hooks own connection leases.

app/counter/page.tsx
import { Counter } from './counter'
import { SessionProvider } from './session'

export default function Page() {
  return (
    <SessionProvider sessionId="shared">
      <Counter />
    </SessionProvider>
  )
}

Or skip the Provider. The bound hook already knows its client:

app/counter/standalone.tsx
'use client'
import { useSession } from './session'

export function StandaloneCounter({ sessionId }: { sessionId: string }) {
  const { state, push } = useSession({ sessionId })
  return (
    <button onClick={() => void push({ type: 'added', payload: 1 })}>
      {state}
    </button>
  )
}

Without the factory, the direct useSession(client, sessionId, options?) export behaves the same.

Seed the first render

Server rendering can skip the bootstrap read. Supply the state and its index together:

app/seeded-counter/page.tsx
import { counterServer } from '../../server/counter'
import { Counter } from '../counter/counter'
import { SessionProvider } from '../counter/session'

export default async function Page() {
  const initial = await counterServer.session('shared').state()
  return (
    <SessionProvider sessionId="shared" initial={initial}>
      <Counter />
    </SessionProvider>
  )
}

Index 10 means the state includes events through 10, so the stream starts after 10. Index 0 with the reducer's initial state requests replay from the beginning; an index alone cannot describe a state. The standalone hook takes the same initial option.

Seeds are render-local: one request's seed never changes another's state, and hydration first renders the supplied snapshot even when the browser cache has advanced.

hydrated turns true once a supplied snapshot or bootstrap initializes the session (index 0 counts) and stays true through disconnections, reconnects, and disabled readers while the session keeps its state. It is false while only the reducer seed shows, including during a pending or rejected cold preload. Use it to tell an empty queue from an unloaded one; it says nothing about freshness, connectivity, or permission to write. Imperative code reads client.session(id).getSnapshot().hydrated.

Start a preload on the server

initial also accepts a Promise of a snapshot:

app/preloaded-counter/page.tsx
import { Suspense } from 'react'
import { counterServer } from '../../server/counter'
import { SessionProvider } from '../counter/session'
import { PreloadedCounter } from './counter'

export default function Page() {
  const initial = counterServer.session('shared').state()
  return (
    <SessionProvider sessionId="shared" initial={initial}>
      <h1>Counter</h1>
      <Suspense fallback={<p>Loading counter…</p>}>
        <PreloadedCounter />
      </Suspense>
    </SessionProvider>
  )
}
app/preloaded-counter/counter.tsx
'use client'
import { useSession } from '../counter/session'

export function PreloadedCounter() {
  const { state, push } = useSession({ suspend: true })
  return (
    <button onClick={() => void push({ type: 'added', payload: 1 })}>
      {state}
    </button>
  )
}

A cold session waits for the Promise before subscribing, showing reducer.initialState meanwhile. A snapshot result skips the bootstrap read; null or undefined falls back to it. A2 starts no extra request.

suspend: true reads the Promise with React use(), so the nearest Suspense and Error boundaries handle pending and rejected states. Without it, the server response renders the reducer seed; to render the loaded session there, await the snapshot or suspend inside a boundary. The Provider and automatic bootstrap never suspend. Create the Promise outside the consuming component so its identity stays stable.

A rejected cold preload pauses startup and reports through connection. A2 cannot retry your Promise: supply a new Promise, a snapshot, or null. To fall back to bootstrap instead, catch the failure in your Promise and return null.

A warm session keeps its state and connection while a new Promise is pending; a newer snapshot hydrates as usual, and a late rejection does not disrupt it. Replacing the source or calling the session's close() discards unapplied results. Unmounting releases the hook's lease but never cancels your fetch, and repeating the same Promise in a cached session does not restart cancelled work.

Connections and activation

Hooks on the same client instance and session ID share one runtime, connection, state, and optimistic queue, under any Providers. Another client instance is isolated, even with the same URL. A reconnect resumes after the last delivered event, without another snapshot.

useSession({ enabled: false }) observes cached or initial data without taking a connection lease (default true). Disabling or unmounting a hook releases only its own lease. Explicit pushes still acquire a connection, so disabling is not an authorization boundary.

The bound hook accepts sessionId, client, participant, initial, enabled, and suspend. It inherits the Provider's initial data and participant only when the resolved client and session ID match the Provider's.

Events and history

events is the raw feed: supplied initialEvents, loaded history, live events, and pending optimistic events. A snapshot adds no historical rows, so without initialEvents the feed starts after the snapshot's index.

loadHistory({ before, limit }) loads older entries into events, by default up to 50 before the oldest loaded one, without touching state or the live cursor. history reports loading, the oldest loaded index, and whether the beginning is loaded. History reads need the HTTP transport.

events lives in memory, unpaginated, so long-lived sessions accumulate events. Snapshots bound replay work, not the size of state or retained events: choose reducer contents that fit.

Client lifetime

Define the client once in a module, as session.ts does, so components share its sessions. createClient({ gcTime }) keeps an idle session cached, five minutes by default; active connections, listeners, and pending work keep it alive.

The cache is in memory, so a reload discards it. A2 ships no IndexedDB adapter (experimental-a2/cache-indexeddb has no implementation) and no persistent offline write queue. Pushes retry a bounded number of times and roll back when they fail. To persist state yourself, restore a state/index pair through initial, only for the same session and reducer version, and keep authentication and retention policy in your application.