---
title: State
description: "State is a fold over the log: raw history for facts, a reducer for the current view."
---

## History

```ts
// anywhere on the server:
const events = await ordersServer.session(orderId).history()
```

Every event in the session, oldest first: always the raw log, never a summary
or snapshot. Use it for audits, debugging, and handler questions like "did the
shop already start?" Pass inclusive `gte` or `lte` indexes to read a slice.

## Reducers

Derive a reducer from the [`orders` contract](/contract/events) and fold:

```ts reducer.ts
import { reducer } from 'experimental-a2'
import { z } from 'zod'
import { orders } from './contracts'

export const ordersReducer = reducer({
  contract: orders,
  name: 'order-status',
  initialState: { status: 'new' },
  stateSchema: z.object({
    status: z.enum(['new', 'created', 'notified', 'expired']),
    shopId: z.string().optional(),
  }),
}).fold((state, event) => {
  switch (event.type) {
    case 'created':
      return { status: 'created', shopId: event.payload.shopId }
    case 'shop.notified':
      return { ...state, status: 'notified' }
    case 'expired':
      return { ...state, status: 'expired' }
    default:
      return state
  }
})
```

```ts
// anywhere on the server, e.g. a server component before first paint:
const { state, index } = await ordersServer.session(orderId).state(ordersReducer)
```

A reducer is a `name` (its [cache identity](#snapshots-are-a-cache)), an
`initialState`, an optional `stateSchema`, and a pure `fold`,
`(state, event) => state`. The two-step shape fixes the state and event types
before `.fold()`, so you write no type arguments and literal unions survive.

`state()` returns `state` and `index`: the fold includes every event through
that index and none after. The browser resumes its live stream from there; see
[React](/client/react).

Without `stateSchema`, the state type is inferred from `initialState`, fine
while every fold arm returns the same shape. With it, the schema is the type
(the seed may lean on its defaults), `initialState` is validated at
definition, and a cached fold that fails the schema, in a server snapshot or
the [browser cache](/client/react), is discarded and refolded. That catches
shape drift a stale `name` can't.

Contract and reducer modules import only schemas and `experimental-a2`, so the
browser runs the same reducer; see [React](/client/react#publish-the-state). A
server-only app can keep the contract next to `createServer` instead.

## Read boundaries

A root session reads the latest committed frontier. A handler's session stops
at its triggering event by default, stable across concurrent appends and
retries:

```ts server.ts
import { createServer } from 'experimental-a2/server'
import { orders } from './contracts'
import { ordersReducer } from './reducer'

export const ordersServer = createServer({
  contract: orders,
  handlers: {
    created: async ({ event, session }) => {
      const atTrigger = await session.state(ordersReducer)
      // atTrigger.index === event.index

      const latest = await session.state(ordersReducer, { through: 'latest' })
      // latest also folds events appended after this one
    },
  },
})
```

Use `{ through: 'latest' }` for joins or other logic that intentionally
observes later events. A numeric `{ through: index }` reads any inclusive log
boundary; `through: 0` returns `initialState`. `state()` is observational: it
never runs handlers or waits for pending work, and its index marks committed
history, not handler completion.

A latest read and a following `ctx.session.append(name, ...events)` are
separate operations, and concurrent appends and retries can move the frontier
between them. For joins, read with `{ through: 'latest' }`, use a monotone
readiness check (once ready, always ready), and give the output event a stable
explicit `id`. Repeated attempts then converge on the same append.

## Snapshots are a cache

The store backend caches folded state as snapshots keyed by the reducer's
`name`, so two reducers over one session never share an entry. Changed the
fold's logic? Change the name (a `-v2` suffix works). The next read ignores
the old snapshots, refolds from raw events, and caches under the new name.

A missing snapshot, or one `stateSchema` rejects, rebuilds from the log. A
storage read failure propagates with its backend diagnostics instead: existing
A2 errors pass through, and others become `STORE_UNAVAILABLE` with the
original error as their cause.

Snapshot write-back runs in platform `waitUntil` after the state is ready. A
failed write doesn't fail the read: A2 reports it through the `a2.snapshot`
telemetry span (or `console.error` by default) without snapshot contents, and
the next read folds the missing tail again.

Each session and reducer has one latest snapshot. Older ones exist only while
handler events that read at their boundary are unfinished (not yet completed
or dead-lettered). This is automatic: there is no TTL, history limit, or
retention setting.

Deleting every snapshot is always safe. The log rebuilds them.

## Events don't change meaning

The log is append-only, and `created` means forever what it meant when it was
written. When a payload needs a new shape, add a new event type (`created.v2`)
instead of redefining the old one.

Reducers accumulate cases over time and never migrate anything: old sessions
keep their old events, new sessions write new ones, and the same fold handles
both.
