---
title: Client
description: "`experimental-a2/client`, `experimental-a2/react`, and `experimental-a2/cache-indexeddb`."
---

## `experimental-a2/client`

```ts
createClient(options: {
  contract: Contract // requires a public reducer
  api: ClientApi
  gcTime?: number // idle session lifetime; 5 minutes by default
  participant?: string // default presence identity for every session
  reconnect?: (failure: {
    sessionId: string
    error: A2ClientError | null
    attempt: number
  }) => number | false
}): A2Client

type ClientApi =
  | SessionUrl // one route: GET SSE stream + POST push
  | { type: 'http'; push: SessionUrl; stream: SessionUrl }
  | { type: 'ws'; url: SessionUrl; multiplex?: false }
  | { type: 'ws'; url: string; multiplex: true }

type SessionUrl = string | ((sessionId: string) => string)
```

The framework-agnostic session client `experimental-a2/react` is built on: the
SSE subscription with frontier resume and reconnection, the optimistic push
queue with ack and rollback, and the local fold. Use it from any framework, or
none.

**`reconnect`**

- Runs after a failed connection or a stream end, including a clean end with
  `error: null`. Not for an explicit restart, an idle-stream refresh, or a
  caller closing the stream.
- Returns the delay in ms before the next attempt (finite, non-negative, at
  most 2,147,483,647), or `false` to pause. Synchronous; a throw or invalid
  result pauses with an `A2ClientError`.
- `attempt` starts at 1, counts consecutive attempts per session, and resets
  when the stream goes live or `session.reconnect()` restarts it. It is
  separate from the lifetime `reconnects` count.
- Default: retry indefinitely, from 500 ms doubling up to five seconds.
- Paused: `connection.status` is `paused` with its last error. State,
  hydration, optimistic writes, and leases stay intact. Neither a new lease nor
  a push resumes it (push retries are independent); `session.reconnect()`
  does, with a fresh attempt count. Releasing the last lease still closes the
  stream; a later first lease starts a new connection. Resuming keeps the
  stream's own position, even if hydration advanced state while paused.

**`SessionUrl` functions** run for each HTTP request or WebSocket connection;
A2 still adds its `sessionId` and read-bound query parameters. The function
does not authorize that ID: bind any path scope or URL token to
`operation.sessionId` in `authorize`, and keep URL tokens short-lived (URLs
reach infrastructure logs). A multiplexed WebSocket URL is a plain string. See
[Transports](/client/transports).

### `client.session(id, options?)`

`client.session(id, { initial?, initialEvents?, participant? })` returns a
handle:

| Member | Does |
| --- | --- |
| `getSnapshot()`, `subscribe()` | the `useSyncExternalStore` contract; snapshots carry `state`, `events`, `index`, `history`, `hydrated`, and `connection`, as in [`useSession`](#usesessionoptions) |
| `push()`, `loadHistory()`, `reconnect()` | as in `useSession` |
| `connect()` | takes a stream lease and returns its release. Leases refcount per handle: the first connects, releasing the last closes, releasing twice is a no-op |
| `close()` | hard stop: drops every lease and closes now; a later `connect()` starts fresh |
| `setPresence()` | presence contracts only, as in `useSession`; snapshots then carry `presence` |

| Option | What it is |
| --- | --- |
| `initial` | an [`InitialSnapshot<S>`](#sessionprovider), as in React |
| `initialEvents` | earlier raw events for history UIs; they merge into `events` and never affect the folded state or stream frontier |
| `participant` | the identity `setPresence` sends under; overrides `createClient`'s |

- One `A2Client` returns the same live handle for repeated `session(id)`
  calls. A later call with a further server-rendered frontier advances it
  without dropping pending optimistic events; a stale render cannot move it
  backward.
- Idle handles are evicted after `gcTime`. This memory layer keeps identity
  across route transitions; `experimental-a2/cache-indexeddb` keeps the replica
  across reloads.
- A pending `initial` Promise delays cold connection startup, including a
  connection a push acquires, and starts no request of its own. Replacing the
  source or `close()` invalidates its pending result and any deferred adoption
  behind a write.
- Releasing the last lease does not cancel the caller's Promise; it can still
  seed cached data without reopening transport. Idle eviction detaches A2's
  listener. Repeating the same Promise in that cached runtime does not restart
  cancelled work.

## `experimental-a2/react`

### `createReact(options)`

```ts
createReact(options: {
  client: A2Client          // bind a shared client: api + contract + session identity
}): { SessionProvider, useSession }

// or, bind the contract and supply api through the Provider:
createReact(options: {
  contract: Contract       // requires a public reducer
}): { SessionProvider, useSession }
```

A factory, like `createContext`: call it once in a `'use client'` module and
export the bound pair. Server components import `SessionProvider` from it as a
client reference; client components import `useSession` from the same file.
Bind a shared `client` when code outside the provider pushes before a route
transition and the destination should adopt the same optimistic session;
`{ contract }` suits provider-bound clients. See
[Bind React once](/client/react#bind-react-once).

### `<SessionProvider>`

| Prop            | What it is                                             |
| --------------- | ------------------------------------------------------ |
| `sessionId`     | which session to subscribe to                          |
| `initial`       | optional snapshot or Promise of a snapshot; `null` means bootstrap |
| `initialEvents` | optional earlier raw events for a history UI           |
| `participant`   | overrides the client's `participant`; one of the two is required to call `setPresence` |

- Supplies scope and optional initial data. Mounted enabled hooks own
  connection leases and reconnect from the last streamed event.
- `participant` binds at the session's first resolution; later changes are
  ignored for the session's runtime lifetime.
- `initial` is the complete state/index snapshot, plus `clearIndex` once the
  session has been cleared. A new session streams from after `initial.index`,
  including events appended between render and connection. Later snapshots
  advance state but keep the resume position, so delayed events still enter
  the feed without folding twice.
- `initialEvents` only preloads the raw `events` feed with entries before that
  frontier; it never affects state hydration or stream resumption.

`InitialSnapshot<S>` is `StateSnapshot<S> | null | undefined |
Promise<StateSnapshot<S> | null | undefined>`, exported from `experimental-a2`
and `experimental-a2/client`. A cold session waits for a supplied Promise
before connecting. A snapshot seeds state; `null` or `undefined` selects
subscription bootstrap. A rejection pauses cold startup and reports through
`connection.error`; supply a new Promise, snapshot, or `null` to resume. A warm
session keeps its state and connection. The Provider never suspends. Keep
Promise identity stable. See
[Seed the first render](/client/react#seed-the-first-render).

### `useSession(options?)`

```ts
const { state, hydrated, push, reconnect, events, index, loadHistory, history, connection } = useSession()
```

| Option | Default | What it does |
| --- | --- | --- |
| `sessionId`, `client`, `participant`, `initial` | from the Provider and bound client | scope and seed |
| `enabled` | `true` | owns a connection lease. `false` defers automatic I/O: the hook observes cached state, other owners continue, and explicit pushes can still acquire a connection |
| `suspend` | `false` | `true` reads the `initial` Promise with React `use()`, through the nearest Suspense and Error boundaries. Otherwise a pending cold Promise renders the reducer seed. Automatic bootstrap never suspends, even when the Promise resolves to `null` or `undefined` |

| Member | What it is |
| --- | --- |
| `state` | the reducer's client state type. A supplied snapshot seeds the first render; otherwise `reducer.initialState` (through the client projection) shows until a snapshot replaces it atomically. Folds live events through the shared reducer |
| `hydrated` | `true` once an initial snapshot or bootstrap applies, even an empty state at index 0; `false` while only the reducer seed exists. Stays `true` through reconnects, disabled hooks, and a failed replacement preload. Not a sign of a live connection or fresh data. Shared with `session.getSnapshot()` |
| `events` | the raw feed: observed, seeded, or backscrolled. Starts after `initial.index` unless `initialEvents` or `loadHistory` add earlier entries |
| `index` | the latest server-confirmed state index, the `lastSeenIndex` for [cancellation](/server/cancellation) |
| `push`, `reconnect`, `loadHistory`, `history`, `connection` | below |
| `presence`, `setPresence` | presence contracts only; below |

Without a supplied snapshot, the server sends one first on the same
subscription; bootstrap errors and retries use `connection`.

**`loadHistory({ before?, limit? })`** fetches a bounded slice below the
frontier (same route, `gte`/`lte` query parameters) and resolves with the
events in that range.

- Merges into `events`: deduped, ordered, shared by every handle of the
  session. Never touches `state` or the optimistic overlay.
- Defaults walk back 50 at a time from the oldest loaded event, also after a
  newer snapshot hydrates. `before` is an exclusive upper bound for another
  range.
- Calls serialize per session; loaded ranges are not refetched.
- `history` is `{ loading, complete, oldestLoaded }`. `complete`: the feed
  reaches index 1, or the first event after the latest clear, or the log is
  empty.
- The `ws` api has no history lane: `loadHistory` throws a `TypeError`.

**Clears** reset `state` to the reducer's initial state and drop `events` at or
below the marker. Unconfirmed pushes stay in the overlay and apply to the reset
state. A push acked below the marker was cleared with it: it leaves the overlay
and its `confirmed` resolves.

**`push`** appends optimistically: validated locally, rolled back on rejection,
retried for `STORE_UNAVAILABLE` and transport failures. Awaiting it gives the
server ack; its `confirmed` is a lazy promise for when the stream has delivered
the batch back:

```ts
const t0 = performance.now()
const result = push({ type: 'created', payload })
await result // the server ack: push→ack latency
await result.confirmed // the stream round-trip: ack→stream latency
const roundtripMs = performance.now() - t0
```

- `confirmed` waits for the stream to deliver through the acked index. A
  bootstrap snapshot, or a stream starting after a supplied initial index,
  delivers through that index; hydration does not otherwise confirm an
  unobserved ack.
- A push never waits for bootstrap, and an HTTP push never waits for a pending
  initial Promise. A WebSocket push rides its connection, which opens once that
  Promise settles.
- Overlapping calls on one session client update the view immediately and
  enter the transport in invocation order, each waiting only for the previous
  call's ack or final rejection. Other sessions and clients run concurrently
  and converge on the log's durable order.
- A rejection rejects both promises with the same `A2ClientError`. `confirmed`
  is materialized only when accessed, so ignoring it costs nothing.

**`reconnect`** (`session.reconnect(): void`) restarts a leased subscription
now from its current cursor, including one the policy paused. It keeps state,
pending writes, and leases, and does nothing without an active lease. It
requests a restart; it returns no promise for catch-up. Use it after a custom
REST mutation. A multiplexed WebSocket keeps its socket and other session
lanes; a single-session WebSocket replaces its socket.

**`connection`** is a discriminated union; `error` only exists while
disconnected:

```ts
// in a client module:
import type { A2ClientError } from 'experimental-a2/client'

type Connection =
  | { status: 'idle' }
  | { status: 'connecting'; reconnects: number; error: A2ClientError | null }
  | { status: 'paused'; reconnects: number; error: A2ClientError | null }
  | { status: 'live'; reconnects: number }
  | { status: 'closed' }
```

"Reconnecting…" is `status === 'connecting' && reconnects > 0`. `live` means
bytes are flowing: the server heartbeats the stream (`: ping` every 15s), and a
client that hears nothing for two beats aborts and reconnects.

**`presence`** is the replicated ephemeral map,
`Record<participantId, { [field]: { value, seen, at } }>`, including this
client. **`setPresence(values)`** is fire-and-forget:

- Validated locally and merged into the local map immediately.
- Coalesced on the wire to a fixed cadence: a leading send, then at most one
  merged send per interval, later values winning per field.
- Stamped with the frontier at send time as `seen`.
- No ack, no `confirmed`, no retry. Parallel sends are safe: merges are
  last-writer-wins by `at`, and stamps are monotonic per session (wall-seeded,
  guarded so overlapping sends never invert).

Both exist only when the contract declares `presence`, typed from its schemas
through the reducer with no type arguments (the reducer never folds presence).
`setPresence` requires a `participant`, from the client or the provider. See
[Presence](/client/presence).

### `useSession(client, sessionId, options?)`

```ts
useSession(client: A2Client, sessionId: string, options?: {
  participant?: string // overrides the client's participant
  initial?: InitialSnapshot<S>
  enabled?: boolean
  suspend?: boolean
}): UseSessionResult
```

The provider-less hook, for components that reach sessions ad hoc (a sidebar
of channel sessions, a user session read from a menu). Same result as the
bound hook.

- Identity-mapped: every hook and provider mounting a session shares one
  runtime. The stream is refcounted: the first mount connects, the last
  unmount closes (StrictMode-safe).
- `initial` takes the result of `session.state(reducer)` or its Promise. Pass
  it whole: its `clearIndex` lets a cached client drop history from before a
  clear it never streamed (the automatic snapshot carries it too). Without it,
  the subscription obtains a snapshot. For deliberate full replay, supply
  `{ state: reducer.initialState, index: 0 }`.
- `enabled: false` defers automatic I/O, as above.

See [React](/client/react).

## `experimental-a2/cache-indexeddb`

```ts
indexedDb(): A2ClientCache
```

A browser-side cache for `SessionProvider`: the session's events (immutable;
writes are idempotent by index), a folded snapshot (invalidated by a reducer
rename), and the pending-push queue. `cache.clear(sessionId?)` is the logout
story. Create instances in client code; they're live objects, not serializable
props. See [Client lifetime](/client/react#client-lifetime).
