> ## Documentation Index
> Fetch the complete documentation index at: https://developers.reintersect.com/llms.txt
> Use this file to discover all available pages before exploring further.

# State and actions

> Store block state in Reintersect, change it with named actions, and push updates from your own systems.

> Reintersect apps are in developer preview. Names and limits can still change before general
> release.

Using Effect? See [Blocks and references](/effect/blocks-and-references) in the Effect guide.

## State

Each block has its own state, stored by Reintersect and validated against the block's `state`
schema on every write. Your app keeps no database for blocks.

```ts theme={null}
import { defineBlock } from "@reintersect/sdk/app";
import { z } from "zod";

const Launch = defineBlock({
  type: "launch",
  title: "Launch",
  props: z.object({ title: z.string() }),
  state: z.object({
    status: z.enum(["open", "shipped", "aborted"]),
    deadline: z.string().optional(),
    notes: z.array(z.string()),
  }),
  initialState: { status: "open", notes: [] },
});
```

State is stored as JSON, so the schema must accept and return the same shape. A schema that
transforms values, such as one that parses a string into a `Date`, is a compile error:
`Block state is stored as JSON and Standard Schema has no encode step: the state schema must not transform values`.
Store dates as ISO strings. On the [Effect entry](/effect), Effect Schema has an encode step, so
transforming schemas are allowed there.

`initialState` is checked against the schema. A typo such as `{ status: "pending" }` doesn't compile.
Stored state is at most 64 KB as JSON.

## Actions

An action is a named function that runs when a member clicks a button, submits a form, picks a
choice with `onChange`, searches a `Select`, or approves an agent's tool call. Define actions on
their own and attach them to blocks:

```ts src/actions/launch.ts theme={null}
import { defineAction } from "@reintersect/sdk/app";
import { z } from "zod";

const launchState = z.object({ status: z.enum(["open", "shipped", "aborted"]) });

export const ship = defineAction(
  "ship",
  {
    description: "Ship the launch to production",
    args: z.object({ note: z.string() }),
    state: launchState,
    destructive: true,
  },
  async ({ args, state, actor }) => {
    if (state.status !== "open") return { notice: "This launch is already closed" };

    await deploy(args.note);

    return {
      state: { status: "shipped" },
      notice: { tone: "success", text: `Shipped by ${actor?.name ?? "an agent"}` },
    };
  },
);

export const abort = defineAction("abort", { state: launchState }, async () => ({
  state: { status: "aborted" },
}));
```

### Options

| Option | Type | Description |
| - | - | - |
| `description` | `string` | What the action does, in one sentence. Agents read it. |
| `args` | Schema | Values bound when the view renders, with `.with(args)`. At most 2 KB once serialized. |
| `input` | Schema | Values a member types into a [form](/blocks/sections-and-forms#forms). |
| `state` | Schema | The slice of block state the action reads and writes. Omit it for a stateless action. |
| `inputs` | `Record<string, choice(…)>` | Viewer inputs whose tallies the action reads. |
| `destructive` | `boolean` | Buttons and menu items bound to it are marked `intent: "destructive"`: they must pass `confirm`, they're drawn in the danger style, and they're never the primary button. Agents always ask for approval. |
| `readOnly` | `boolean` | The action changes nothing. Its result can only carry `notice`, `fieldErrors`, `openUrl` and `options`. |
| `idempotent` | `boolean` | Running it twice with the same arguments has the same effect as once. A hint for agents. |
| `agent` | `boolean` | Default `true`. Set `false` to keep the action away from agents. |
| `scopes`, `env`, `config` | | What the handler uses. See [Composing an app](/concepts/composition). |

The name is unique within a block: 1 to 64 characters from `a-z`, `0-9`, `_`, `.` and `-`.

### Handler context

| Field | Description |
| - | - |
| `args` | Parsed `args`, as bound at render time. |
| `input` | Parsed form input when the action backs a `Form`, or `{ [inputName]: picks }` when it's a `ChoiceGroup`'s `onChange`. |
| `state` | The current values of the action's state slice. |
| `tallies` | Counts for the inputs the action declared, such as `tallies.verdict.go`. |
| `block` | `{ id, type, props, state, revision }` of the block the action runs on. |
| `actor` | `{ memberId, name }`: the member who clicked, submitted or approved. |
| `query` | The search text, when the action backs a `Select`. |
| `interactionToken` | A short-lived token for this one block. The client below uses it for you. |
| `reintersect` | A client limited to the scopes this action declared. |
| `env`, `config` | Declared environment and install config. |
| `waitUntil` | Keeps background work running after the handler returns. See [Long-running work](#long-running-work). |

### Results

Return nothing, or an object with any of these fields:

| Field | Description |
| - | - |
| `state` | A partial state. Reintersect merges it shallowly into the block's state and validates the result. |
| `notice` | A short message shown to the member who acted. A string, or `{ tone, text }` with a tone of `neutral`, `info`, `success`, `warning` or `danger`. At most 140 characters. |
| `fieldErrors` | Errors keyed by form field name, shown next to each field. The form keeps its values. |
| `openUrl` | An `https://` URL to open for the member who acted, such as a checkout or a log page. Reintersect opens it once, best effort (a browser may block it), and the block shows an **Open \<host>** link as a fallback. |
| `final` | `true` or `{ summary }`. Locks the block. See [Final blocks](/blocks/audience-and-lifecycle#final-blocks). |
| `notify` | `[{ memberId, text }]`. Sends notifications that link to the block. Needs `notifications:write`. An app sends at most 60 notifications a minute. |
| `revalidate` | `true` renders the block again without changing state, for example after an external change. |
| `options` | For `Select` search actions: `{ value, label, description? }[]`, at most 50. |

After the handler returns, the SDK merges the state, renders the block with it, and sends the new
state and tree back in the same response. Members see the result as soon as Reintersect stores it.

If the handler throws, takes longer than 5 seconds, or returns a state that fails validation,
Reintersect keeps the previous state and tree and shows an error inline to the member who acted.
If the block changed since the member loaded it, the click fails as stale and the block shows its
latest version.

## Binding actions in the view

The render context has bound versions of the block's actions. Pass them to components:

```tsx theme={null}
.render((props, { actions }) => (
  <Block summary={props.title}>
    <Actions>
      <Button
        label="Ship"
        action={actions.ship.with({ note: props.title })}
        confirm={{ title: `Ship ${props.title}?`, body: "This deploys to production." }}
      />
      <Button label="Abort" action={actions.abort} />
    </Actions>
  </Block>
))
```

Because `ship` is destructive, its button needs `confirm` and can't be primary. **Abort** is the
primary button here, and **Ship** is outlined in the danger colour. In the confirm dialog, the
confirm button repeats the label **Ship**, and **Cancel** gets focus first.

The types enforce how each action is used:

| Mistake | Result |
| - | - |
| Passing `actions.ship` when `ship` takes `args` | `Action "ship" takes args: pass actions.ship.with({ ... })` |
| Wrong args, such as `.with({ notes: "x" })` | A type error against the `args` schema |
| A destructive action without `confirm` | A type error on the button, and a runtime error if types are bypassed |
| Passing a `Form` an action without `input` | A type error on the form |
| Using an action the block didn't attach | `actions.setDeadline` doesn't exist on the context |

### What the browser sees

When your view renders, the SDK replaces each bound action with an opaque reference such as `a0` and
sends Reintersect a table of what each reference means: the action name and its bound args.
Reintersect keeps that table on the server. The browser only ever sends back the reference, the block
revision and any form input, so a member can't change the args you bound. Reintersect checks that the
member can act on the block (circle access, audience, install active, block not final) before it calls
your app.

Agents follow the same rule. An agent can only run an action that is rendered in the block right now,
with the args you bound, so it can do exactly what a member could click. See [Agents](/agents).

## Sharing actions across blocks

An action declares only the state it needs. Attaching it to a block checks that slice against the
block's state:

```ts theme={null}
const narrow = defineBlock({
  type: "narrow",
  title: "Narrow",
  props: z.object({}),
  state: z.object({ status: z.enum(["open", "shipped"]) }),
  initialState: { status: "open" },
  actions: { abort },
  //         ^ Action "abort" writes state.status values this block's state does not allow
});
```

The checks report each kind of mismatch in plain English: a state key the block lacks, a value the
block doesn't allow, a block state that is wider than the action accepts, and tallies of an input
the block doesn't declare.

## Long-running work

Reintersect waits up to 5 seconds for an action. For work that takes longer, return quickly and finish
in the background with `waitUntil`, then push the result:

```ts theme={null}
export const deploy = defineAction(
  "deploy",
  {
    state: z.object({ status: z.enum(["idle", "deploying", "live", "failed"]) }),
    destructive: true,
    scopes: ["blocks:write"],
  },
  async ({ block, reintersect, waitUntil }) => {
    if (block === undefined) return;

    waitUntil(
      runDeployment().then((ok) =>
        reintersect.blocks.update(DeployBlock, block.id, { state: { status: ok ? "live" : "failed" } }),
      ),
    );

    return { state: { status: "deploying" } };
  },
);
```

Inside an action, calls about that same block authenticate with the action's interaction token,
which lasts 15 minutes. Calls about other blocks use the install's token. On Reintersect hosting,
`waitUntil` keeps the Worker alive until the work finishes; on Node it runs the work detached.

## Pushing state from your systems

Update blocks from events, routes, cron jobs or your own backend with the client. The block
definition is the first argument, so state is typed and the app must include the block:

```ts theme={null}
import { createReintersectClient } from "@reintersect/sdk";
import { app } from "./app";
import { TaskBlock } from "./taskBlock";

const reintersect = createReintersectClient(app, { installId });

const { error } = await reintersect.blocks.update(TaskBlock, blockId, { state: { status: "done" } });
```

| Method | Behaviour |
| - | - |
| `blocks.insert(Block, { conversationId \| replyTo \| circleId, props })` | Inserts a new block into a conversation, as a reply to a message, or into a circle's chat. See [Writing to circles](/writing-to-circles#replies-and-blocks). |
| `blocks.update(Block, id, { state, expectedVersion? })` | Shallow merge of a partial state. |
| `blocks.replace(Block, id, { state, expectedVersion? })` | Replaces the whole state. Every required field must be present. |
| `blocks.revalidate(id)` | Renders again without changing state. |
| `blocks.get(Block, id)` | Reads props, state, version and whether the block is final. |
| `blocks.query(Block, { stateMatch, cursor?, limit? })` | Pages through this install's blocks of a type whose state matches. |
| `blocks.updateWhere(Block, { stateMatch, state })` | Merges `state` into every matching block. |

`insert`, `update`, `replace` and `updateWhere` need `blocks:write`. Every block has a `version` that
increases with each change. Pass `expectedVersion` to make an update conditional; a stale version
fails with `ReintersectAppBlockVersionConflictError` and nothing changes. Updating a final block
fails with `ReintersectAppBlockFinalError`. Your app can only change blocks its own install
created.

Each update is validated against the state schema, rendered, and synced to everyone viewing the
block. See [Client](/reference/client) for authentication and errors, and the
[API reference](/api-reference/blocks/update) for the HTTP equivalents.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.