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:
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.
import { createServer } from 'experimental-a2/server'
import { counter } from '../model'
export const counterServer = createServer({ contract: counter })import { counterServer } from '../../../server/counter'
export const GET = counterServer.fetch
export const POST = counterServer.fetchGET 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.
'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,
})'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.
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:
'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:
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:
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>
)
}'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.