---
title: React
description: "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:

```ts 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](/contract/sessions).

The reducer name identifies its [snapshot format](/contract/state#snapshots-are-a-cache):
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](/contract/state#reducers) fixes the state type first.
Other reducers read on the server with `session.state(reducer)` publish no
client state.

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

export const counterServer = createServer({ contract: counter })
```

```ts 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](/server/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.

```tsx 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,
})
```

```tsx 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](/reference/client) covers each.

Without a [seed](#seed-the-first-render), `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.

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

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

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

```tsx 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>
  )
}
```

```tsx 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](/client/transports).

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