Skip to content

State Groups

State groups let you organise related sub-states under a common namespace prefix, addressed by dot-separated names like "Payment.Pending" and "Payment.Failed".

They solve two concrete problems:

  • State-name explosion — keep the top-level state list readable
  • Shared handlers — one handler for every Payment.* sub-state

Groups are a pure namespacing helper. The engine sees flat states after the group is spread into the config; nothing about dispatch, snapshots, or migrations changes.

Defining a Group

defineGroup(name, children) takes a prefix and a record of child schemas (or state() configs). The group itself does not merge schemas — you express shared fields at the call site by spreading a plain shape object into each child.

ts
// Shared base fields — expressed as a plain object and spread into each child
// schema. `defineGroup` itself does no schema merging, so the same pattern
// works with Valibot or ArkType instead of Zod.
const basePayment = { amount: z.number(), currency: z.string() };

const Payment = defineGroup("Payment", {
	Pending: z.object({ ...basePayment, attempt: z.number() }),
	Failed: z.object({ ...basePayment, reason: z.string() }),
	Retrying: z.object({ ...basePayment, attempt: z.number(), nextRetryAt: z.date() }),
});

Why no magic merging? Keeping schemas intact makes groups validator-agnostic — the same pattern works with Valibot (v.object({ ...baseV, ... })) or ArkType. Schema merging is a validator-specific idiom; rytejs stays out of it.

The returned value exposes:

  • states — spread into defineWorkflow's states config
  • names — an array of fully-qualified names (["Payment.Pending", "Payment.Failed", ...])
  • Dynamic string-literal accessors — Payment.Pending === "Payment.Pending"

Spreading into a Workflow

ts
const orderWorkflow = defineWorkflow("order", {
	states: {
		Draft: z.object({ items: z.array(z.string()) }),
		...Payment.states,
		Shipped: z.object({ trackingId: z.string() }),
	},
	commands: {
		RetryPayment: z.object({}),
		CancelPayment: z.object({}),
		FailPayment: z.object({ reason: z.string() }),
	},
	events: {},
	errors: {},
});

After the spread, "Payment.Pending", "Payment.Failed", and "Payment.Retrying" are first-class states alongside "Draft" and "Shipped". StateNames<TConfig> includes them all.

Handlers on a Specific Sub-State

Pass the string-literal accessor (e.g. Payment.Pending) to router.state(). Inside the handler, ctx.data has the child's full inferred type — including any base fields you spread in.

ts
router.state(Payment.Pending, ({ on }) => {
	on("RetryPayment", ({ data, transition }) => {
		// data: { amount: number, currency: string, attempt: number }
		transition("Payment.Retrying", {
			amount: data.amount,
			currency: data.currency,
			attempt: data.attempt + 1,
			nextRetryAt: new Date(Date.now() + 60_000),
		});
	});
});

Handlers on the Whole Group

Pass group.names to router.state() to register a handler that fires in every sub-state. Inside the handler, ctx.data is the union of all sub-state data types — fields shared across every child (because you spread the same base into all of them) are directly accessible, but child-specific fields require ctx.match() to narrow.

ts
router.state(Payment.names, ({ on }) => {
	on("CancelPayment", ({ data, match, transition }) => {
		// data shared fields are accessible directly: data.amount, data.currency
		// child-specific fields require match() to narrow:
		const reason = match(
			{
				"Payment.Pending": (d) => `cancel at attempt ${d.attempt}`,
				"Payment.Failed": (d) => `cancel after failure: ${d.reason}`,
				"Payment.Retrying": (d) => `cancel retry at attempt ${d.attempt}`,
			},
			() => "unknown",
		);
		console.log(`Cancelled ${data.amount} ${data.currency}: ${reason}`);
		transition("Draft", { items: [] });
	});
});

Note on match() inside a group handler: match() is typed against the workflow's full StateNames, not just the group's sub-states. Even though your handler only runs inside Payment.* at runtime, you still need the fallback form match(matchers, () => ...) to satisfy the type checker. The fallback branch will never be hit in practice.

Sub-state-specific handlers take priority over group-wide handlers. If a command matches both, only the sub-state-specific one runs.

Transitioning

Transitions work exactly as they do for flat states. transition("Payment.Failed", data) validates data against the child's schema — whatever base fields you spread in are required, plus the child's own fields.

Using state() Configs Inside a Group

Group children can be state({ schema, clientSchema }) entries, not just plain schemas. This composes naturally when a sub-state needs server-only fields:

ts
const Payment = defineGroup("Payment", {
    Pending: z.object({ ...basePayment, attempt: z.number() }),
    Failed: state({
        schema: z.object({
            ...basePayment,
            reason: z.string(),
            internalErrorCode: z.string(), // server-only
        }),
        clientSchema: z.object({ ...basePayment, reason: z.string() }),
    }),
});

When NOT to Use Groups

  • No repeated naming — just use flat states. Groups only pay off when you have several closely related states you want to handle together.
  • Full statechart semantics — rytejs doesn't support entry/exit actions, history pseudo-states, or automatic parent-handler fallthrough on missed commands. Groups are a naming mechanism, not a hierarchical state machine.

Limitations

  • Groups are flat — a child cannot itself be a group
  • Spreading into states follows JS object-spread semantics — a later key overwrites an earlier one silently
  • Shared base fields are a consumer concern — validator-native spread/merge, not a framework feature