---
title: Routes, clients, and React
description: "One route serves an instance: GET streams live state, POST answers calls. Authorize per operation, project per mount, and call it from any client or one React hook."
---

## The actor for this page

```ts vault.ts
import { NonRetriableError } from 'experimental-a2'
import { actor } from 'experimental-a2/actor'

interface Vault {
  state: {
    balance: number
    /** Internal bookkeeping; not for subscribers. */
    audit: string[]
  }
  events: {
    deposit: { amount: number }
    withdraw: { amount: number }
  }
}

export type VaultState = Vault['state']

export const vault = actor<Vault>({
  name: 'vault',
  state: { balance: 0, audit: [] },
  handlers: {
    deposit: (ctx, input) => {
      ctx.state.balance += input.amount
      ctx.state.audit.push(`deposit ${input.amount}`)
    },
    withdraw: (ctx, input) => {
      if (ctx.state.balance < input.amount) {
        throw new NonRetriableError('insufficient funds')
      }
      ctx.state.balance -= input.amount
      ctx.state.audit.push(`withdraw ${input.amount}`)
    },
  },
})
```

## The route

```ts app/vault/[vaultId]/route.ts
import { vault } from '@/vault'

async function respond(
  request: Request,
  context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
  const { vaultId } = await context.params
  // here's where you'd do auth, or any other checks
  return vault.actor(vaultId).fetch(request)
}

export const GET = respond
export const POST = respond
```

`handle.fetch` serves one instance; your route authenticates and picks
which:

- **`GET`** streams state commits as server-sent events, resumed after
  the `index` query parameter, with heartbeats. It carries state and
  nothing else: subscribers never see other callers' inputs, and a
  refusal answers only its caller.
- **`GET ?snapshot=1`** returns the current `{ state, index }`.
- **`POST`** makes a call and responds with its answer.

A `POST` body names a declared event:

```json
{ "event": "deposit", "input": { "amount": 100 }, "messageId": "m-1" }
```

It answers `{ state, index }`, `409` with the refusal's message, or
`400` for an undeclared event. The optional `messageId` makes retrying
the request idempotent. Snapshot and call responses use
`Cache-Control: private, no-store`.

### After a clear

After `handle.session.clear()`, snapshots, call answers, and stream
commits include `clearIndex`; keep it in any snapshot you pass to
`useActor`'s `initial`. The stream then sends the actor's initial state
(through `view`) as a reset, and the hook drops older history. A
delayed reset never replaces a newer snapshot.

A call cleared before its answer gets `410` with
`{ code: 'SESSION_CLEARED', error }`, which the actor client raises as
an `ActorRequestError` with status `410` and the A2 error as its
`cause`. The handler may already have run, so treat its effects as
unknown, as after a timeout. A later message reusing the id can't
answer the cleared call.

## Authorize

```ts
// app/vault/[vaultId]/route.ts, now with per-operation policy:
import { vault } from '@/vault'

export async function POST(
  request: Request,
  context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
  const { vaultId } = await context.params
  return vault.actor(vaultId).fetch(request, {
    authorize: (operation) =>
      operation.type === 'call' && operation.event !== 'withdraw',
  })
}
```

Authentication stays in your route; `authorize` decides whether each
operation is allowed, before any read, write, or subscription.
Returning `false` answers `403`. The operation is one of:

- `{ type: 'state', id }` for snapshot reads
- `{ type: 'stream', id, startAfter }` for subscriptions
- `{ type: 'call', id, event, input, messageId? }` for calls
- `{ type: 'presence', id, participant, values }` for
  [presence](#presence) announcements

## Views: what each mount shows

```ts
// app/vault/[vaultId]/public/route.ts, a projected mount of the same actor:
import { vault } from '@/vault'

export async function GET(
  request: Request,
  context: { params: Promise<{ vaultId: string }> },
): Promise<Response> {
  const { vaultId } = await context.params
  return vault.actor(vaultId).fetch(request, {
    view: (state) => ({ balance: state.balance }),
  })
}
```

A `view` projects everything its mount serves: snapshots, stream
frames, and call answers alike, so calls never leak what the stream
hides. Audiences are a route concern, like auth: an admin mount with no
view sees everything, and the mount above hides `audit`. For a
per-viewer view, close over your route's identity:
`view: (state) => ({ ...visible, mine: state.perUser[user.id] })`. View
output must be plain JSON, like state.

Clients of a projected mount pass its shape as the second type argument
(`createActorClient<typeof vault, PublicVault>` or
`useActor<typeof vault, PublicVault>`).

Views hide internal fields, not secrets. State commits are log rows,
and the log is forever: keep secrets outside, pass references, and
resolve them inside handlers.

## Any client

```ts
// in the browser, or any client at all:
import {
  ActorRefusedError,
  createActorClient,
} from 'experimental-a2/actor/client'
import type { vault } from '@/vault'

const savings = createActorClient<typeof vault>({ api: '/vault/savings' })

try {
  const { state } = await savings.call.deposit({ amount: 100 })
  // state is the answer: the fold after this deposit
} catch (error) {
  if (error instanceof ActorRefusedError) {
    // the server's refusal, revived with its message
  }
}
```

`createActorClient` is the typed call surface over the route's `POST`,
for browsers, other frameworks, and other servers. The `vault` import
is type-only, so the server module never enters a client bundle. `client.state()` reads a fresh snapshot. The optional
`onSnapshot` callback receives successful reads and call answers before
their promises resolve, including older results from idempotent
retries, so compare indexes before replacing live state.

`ActorRequestError` carries an HTTP `status` when available. A network
failure, timeout, or server error doesn't establish whether a call
committed: retry with the same explicit `id` and input to recover its
original answer. The client never retries calls automatically.

## React

```tsx app/vault/[vaultId]/page.tsx
import { vault } from '@/vault'
import { VaultClient } from './vault-client'

export default async function VaultPage({
  params,
}: {
  params: Promise<{ vaultId: string }>
}) {
  const { vaultId } = await params
  const initial = await vault.actor(vaultId).state()
  return <VaultClient vaultId={vaultId} initial={initial} />
}
```

```tsx app/vault/[vaultId]/vault-client.tsx
'use client'
import { useActor } from 'experimental-a2/actor/react'
import type { ActorSnapshot } from 'experimental-a2/actor/react'
import type { vault, VaultState } from '@/vault'

export function VaultClient({
  vaultId,
  initial,
}: {
  vaultId: string
  initial: ActorSnapshot<VaultState>
}) {
  const { state, call, connection } = useActor<typeof vault>({
    api: `/vault/${vaultId}`,
    id: vaultId,
    initial,
  })

  return (
    <div>
      <p>
        Balance: {state.balance}
        {connection.status === 'live' ? '' : ' (reconnecting)'}
      </p>
      <button onClick={() => void call.deposit({ amount: 25 })}>
        Deposit $25
      </button>
    </div>
  )
}
```

The server component hands down the first snapshot; `useActor` keeps it
live and returns:

- **`state`**: the live view (no reducers, schemas, or contracts in
  browser code).
- **`call`**: the same typed calls as `createActorClient`, which the
  hook wraps.
- **`index`**: the latest adopted state index.
- **`events`**: the state commits the stream observed after the initial
  snapshot. A delayed commit still enters once, even when a call answer
  or refresh already advanced `state`.
- **`refresh()`**: reads and adopts a fresh snapshot before resolving.
  Use it after a refusal or when a suspended tab returns.
- **`connection`**: the same union as
  [`useSession`](/client/react#events-and-history), heartbeat watchdog
  included.

State is live, not optimistic: handlers decide on the server against
fresh state. For pending UI, await the call.

A successful call updates `state` before its promise resolves (React
renders on its normal schedule) and returns its own answer, even if
newer state already arrived. State only advances: an older answer or
delayed stream frame never replaces newer state. Refusals don't change
state or trigger a refresh.

## Presence

```ts board.ts
import { actor } from 'experimental-a2/actor'

interface Board {
  state: { strokes: number }
  events: { draw: { path: string } }
  presence: { cursor: { x: number; y: number }; name: string }
}

export const board = actor<Board>({
  name: 'board',
  state: { strokes: 0 },
  presence: true,
  handlers: {
    draw: (ctx) => {
      ctx.state.strokes += 1
    },
  },
})
```

Declare [presence](/client/presence) fields as types in the protocol
and arm the wire with `presence: true`. Presence replicates to the
instance's subscribers and expires; it never enters the log, and
handlers can't see it. The route doesn't change: `GET` interleaves
presence with state commits, and `POST` accepts announcements. In
React, pass a `participant` identity and read the map:

```tsx app/board/[boardId]/board-client.tsx
'use client'
import { useActor } from 'experimental-a2/actor/react'
import type { ActorSnapshot } from 'experimental-a2/actor/react'
import type { board } from '@/board'

export function BoardClient({
  boardId,
  initial,
  user,
}: {
  boardId: string
  initial: ActorSnapshot<{ strokes: number }>
  user: string
}) {
  const { presence, setPresence } = useActor<typeof board>({
    api: `/board/${boardId}`,
    id: boardId,
    initial,
    participant: user,
  })

  return (
    <div
      onPointerMove={(event) =>
        setPresence({ cursor: { x: event.clientX, y: event.clientY } })
      }
    >
      {Object.keys(presence).length} here
    </div>
  )
}
```

`setPresence` is fire-and-forget: throttled, resent after a reconnect,
and `null` clears a field. The map holds each participant's latest
fields with their stamps; decide how long to paint a value from `at`.

Three guards, none of them a schema:

- **Types**: the protocol types `setPresence` and the map, so honest
  clients can't send the wrong shape.
- **The floor**: A2 rejects values that aren't plain JSON trees, values
  over 8 KB, and object-plumbing field names.
- **[`authorize`](#authorize)**: every announcement arrives as a
  `presence` operation before it is accepted. Clamp, verify the
  participant matches the authenticated user, or rate-limit here.

No guard makes peer values trustworthy: presence is authored by whoever
holds a connection, so render it like user input.

## The session wire underneath

For the raw session protocol (the full event feed, pushes, presence,
the `ws` transport), mount `def.server.fetch` as a
[session route](/server/routes) and use the core
[client](/client/react). That wire shows the whole mailbox, inputs
included, so reserve it for trusted audiences.
