Guide

.update

Merge event- and Query-derived patches into active state data in order.

State data already has a precise owner and lifetime. .update changes part of one active state by merging a Schema-shaped patch, while every field omitted from that patch stays intact.

Change state-owned data without replacing the state.

.update merges a partial record into one active state. Its computation can use the current event and the results of earlier actions in the same ordered fold.

1 · Handle without changing data

The event is handled, but the message stays put.

Sign accepts ChangeMessage with a targetless handler. Sending a decoded message returns Handled, yet the state-owned fields remain unchanged because the edge declares no update action.

import { State, Statechart, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

const ChangeMessage = Schema.TaggedStruct("ChangeMessage", {
  message: Schema.String,
});

export class Sign extends State.Atomic<Sign>()("Sign", {
  message: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("Welcome")),
  ),
  revision: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
  foldedMessage: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static transitions = Transitions.make(this, ({ on }) => [on(ChangeMessage)]);
}

export const SignChart = Statechart.make(Sign);

2 · Merge an event-derived patch

.update changes the field its target owns.

The same handler now updates Sign with a partial record derived from event.message. Sign remains active, its message changes, and the untouched revision fields retain their values.

import { State, Statechart, Transitions } from "@motive/motive";
import * as Effect from "effect/Effect";
import { Schema } from "effect";

const ChangeMessage = Schema.TaggedStruct("ChangeMessage", {
  message: Schema.String,
});

export class Sign extends State.Atomic<Sign>()("Sign", {
  message: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("Welcome")),
  ),
  revision: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
  foldedMessage: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(ChangeMessage).update(Sign, ({ event }) => ({
      message: event.message,
    })),
  ]);
}

export const SignChart = Statechart.make(Sign);

3 · Fold updates in order

A later update reads the result folded so far.

A second update reads Sign after the message patch. It records the new message and increments revision, proving that chained updates run in authored order rather than against one stale starting value.

import * as Effect from "effect/Effect";
import { Schema } from "effect";
import { Query, State, Statechart, Transitions } from "@motive/motive";

const ChangeMessage = Schema.TaggedStruct("ChangeMessage", {
  message: Schema.String,
});

const recordFold = Query.gen(function* () {
  const sign = yield* Sign;
  return {
    foldedMessage: sign.message,
    revision: sign.revision + 1,
  };
});

export class Sign extends State.Atomic<Sign>()("Sign", {
  message: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("Welcome")),
  ),
  revision: Schema.Finite.pipe(
    Schema.withConstructorDefault(Effect.succeed(0)),
  ),
  foldedMessage: Schema.String.pipe(
    Schema.withConstructorDefault(Effect.succeed("")),
  ),
}) {
  static transitions = Transitions.make(this, ({ on }) => [
    on(ChangeMessage)
      .update(Sign, ({ event }) => ({ message: event.message }))
      .update(Sign, recordFold),
  ]);
}

export const SignChart = Statechart.make(Sign);

Handling is not mutation

The first frame gives Sign a targetless ChangeMessage handler. Send the event and the client returns Handled, but message, revision, and foldedMessage do not change.

An event does not mutate chart data merely because its object contains a similarly named field. The model must author the data action explicitly.

An update names its owner

The second frame chains an update to the same handler:

on(ChangeMessage).update(Sign, ({ event }) => ({
  message: event.message,
}));

Sign is both the update target and the Schema that defines the accepted patch fields. Returning an undeclared property is a type error. Omitting revision and foldedMessage preserves their current values rather than replacing the whole facet.

Update only active state data

An update target must be active when the action runs. That is what makes its data available and its lifetime meaningful. Updating an inactive sibling is a defect, not an implicit way to activate or preload that state.

Place longer-lived data on a longer-lived owner when several child states need to update it. The topology should express that shared lifetime instead of weakening the active-state rule.

The computation may read the event

The inline callback receives the decoded ChangeMessage event selected by this edge. Its event.message is already a string because the event Schema admitted it before transition selection.

For more involved patches, pass a Query instead. Its strict and optional reads make every state and event dependency explicit in the same way as the Queries category.

Chained updates form an ordered fold

The final frame keeps the message patch first and adds recordFold second. That query reads the current Sign value, copies sign.message into foldedMessage, and increments revision.

Send Platform 4. Both displayed message fields become Platform 4, proving that the second update read the result of the first—not the old Welcome value. Reverse the action order and the folded message records the old value instead.

One update does not require a transition target

All three handlers are targetless. .update changes data; the missing transition target preserves the active configuration. A targeted transition may carry updates too, but movement and mutation remain separate authored choices.

Entry and exit branches use the same action link. Their lifecycle boundary decides when the update runs; .update still decides which active state owns the patch.

Test the fold

  1. Send different messages in all three frames and compare handled events with actual data changes.
  2. Reverse the two updates in the final frame and predict foldedMessage before running it.
  3. Add a second field to ChangeMessage and update two declared Sign fields in one patch.