Guide

Parallel States

Keep independent dimensions coactive without multiplying combinations.

Parallel states contain several regions and keep one active branch in each. Use them when parts of the domain change independently but still belong to one chart.

Keep independent facts independent.

Door position and payment status can change in either order. Parallel topology keeps both facts explicit without multiplying them into combined state names.

1 · Flat combinations

Flattening multiplies the model.

Door position and payment status can change independently, yet a flat chart must enumerate ClosedUnpaid, OpenUnpaid, ClosedPaid, and OpenPaid—then connect every valid combination.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
const Pay = Schema.TaggedStruct("Pay", {});
const Refund = Schema.TaggedStruct("Refund", {});

export class Order extends State.Compound<Order>()("Order") {
  static states = States.make(() => [
    ClosedUnpaid,
    OpenUnpaid,
    ClosedPaid,
    OpenPaid,
  ]);
}

class ClosedUnpaid extends State.Atomic<ClosedUnpaid>()("ClosedUnpaid") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(OpenDoor, OpenUnpaid),
    on(Pay, ClosedPaid),
  ]);
}

class OpenUnpaid extends State.Atomic<OpenUnpaid>()("OpenUnpaid") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(CloseDoor, ClosedUnpaid),
    on(Pay, OpenPaid),
  ]);
}

class ClosedPaid extends State.Atomic<ClosedPaid>()("ClosedPaid") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(OpenDoor, OpenPaid),
    on(Refund, ClosedUnpaid),
  ]);
}

class OpenPaid extends State.Atomic<OpenPaid>()("OpenPaid") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(CloseDoor, ClosedPaid),
    on(Refund, OpenUnpaid),
  ]);
}

export const OrderChart = Statechart.make(Order);

2 · Parallel topology

State.Parallel activates every region.

Order now keeps one active leaf beneath Door and one beneath Payment. Each region owns its local states and can progress without manufacturing Cartesian combinations.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
const Pay = Schema.TaggedStruct("Pay", {});
const Refund = Schema.TaggedStruct("Refund", {});

export class Order extends State.Parallel<Order>()("Order") {
  static states = States.make(() => [Door, Payment]);
}

class Door extends State.Compound<Door>()("Door") {
  static states = States.make(() => [Closed, Open]);
}

class Closed extends State.Atomic<Closed>()("Closed") {
  static transitions = Transitions.make(this, ({ on }) => [on(OpenDoor, Open)]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(CloseDoor, Closed),
  ]);
}

class Payment extends State.Compound<Payment>()("Payment") {
  static states = States.make(() => [Unpaid, Paid]);
}

class Unpaid extends State.Atomic<Unpaid>()("Unpaid") {
  static transitions = Transitions.make(this, ({ on }) => [on(Pay, Paid)]);
}

class Paid extends State.Atomic<Paid>()("Paid") {
  static transitions = Transitions.make(this, ({ on }) => [on(Refund, Unpaid)]);
}

export const OrderChart = Statechart.make(Order);

3 · Shared event

One event can move several regions.

When Open and Paid are coactive, both regions accept Reset. The chart moves them to Closed and Unpaid together in the resulting configuration.

import { State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
const Pay = Schema.TaggedStruct("Pay", {});
const Refund = Schema.TaggedStruct("Refund", {});
const Reset = Schema.TaggedStruct("Reset", {});

export class Order extends State.Parallel<Order>()("Order") {
  static states = States.make(() => [Door, Payment]);
}

class Door extends State.Compound<Door>()("Door") {
  static states = States.make(() => [Closed, Open]);
}

class Closed extends State.Atomic<Closed>()("Closed") {
  static transitions = Transitions.make(this, ({ on }) => [on(OpenDoor, Open)]);
}

class Open extends State.Atomic<Open>()("Open") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(CloseDoor, Closed),
    on(Reset, Closed),
  ]);
}

class Payment extends State.Compound<Payment>()("Payment") {
  static states = States.make(() => [Unpaid, Paid]);
}

class Unpaid extends State.Atomic<Unpaid>()("Unpaid") {
  static transitions = Transitions.make(this, ({ on }) => [on(Pay, Paid)]);
}

class Paid extends State.Atomic<Paid>()("Paid") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Refund, Unpaid),
    on(Reset, Unpaid),
  ]);
}

export const OrderChart = Statechart.make(Order);

Flattening multiplies combinations

The first frame models door position and payment status as one compound state. Two binary dimensions already require four atomic combinations and eight transitions. A third independent dimension would double the state count again.

Those names also obscure ownership: opening a door should not need separate rules for every possible payment status.

Parallel states activate every region

The second frame changes Order to State.Parallel and gives it two compound regions, Door and Payment. A parallel state enters every direct region, so the live configuration contains one active door state and one active payment state simultaneously.

OpenDoor is handled inside Door; Pay is handled inside Payment. Progress in either region preserves the other region instead of hopping between Cartesian combination labels.

Parallel does not mean threads or uncontrolled execution. It is a statement about topology: several regions are coactive inside one chart configuration.

One event may affect several regions

The final frame adds Reset to Open and Paid. When both are active, the chart can select one compatible transition in each region. The resulting configuration contains Closed and Unpaid; there is no authored half-reset state between them.

If only one region accepts an event, only that region moves. Parallel topology expands the active configuration; it does not require every event to affect every region.

Test the topology

  1. Add a third binary region and compare two local states with the eight flattened combinations.
  2. Send Reset while only one region differs from its initial state and predict which edge is selected.
  3. Add LockDoor to the Door region. Confirm that Payment needs no new states or transitions.