Guide

Parameterized Component Factories

Select one concrete topology from a finite authoring vocabulary while keeping per-instance values in runtime data.

A Component factory chooses authored structure before a chart is assembled. It is useful when a small, canonical input selects genuinely different States, transitions, events, or contracts.

It is not a place to hide per-instance values. The first question is therefore structural:

Does the value change the topology?Authoring parameter"standard" | "escalated"Choose a concrete Component before assembly
Does each instance carry its own value?Runtime datareviewer: "Ada"Use input, events, facets, keys, or Resources

Validate the authoring vocabulary

A structural parameter should be finite enough to explain. Put its accepted values in a Schema, especially when deployment configuration or another external source selects the chart:

const ApprovalMode = Schema.Literals(["standard", "escalated"]);
type ApprovalMode = typeof ApprovalMode.Type;

Decode an untrusted value once before constructing the model. Downstream authoring code should receive ApprovalMode, not a string plus defensive fallbacks. A literal in the same module already gets the same finite TypeScript vocabulary.

The mode is an authoring input. Changing it means building a different chart definition, not sending a command to a running instance.

Select one concrete definition

This lesson gives each variant its own enclosed definition:

const StandardApproval = Component.make(() => {
  class Approved extends State.Atomic<Approved>()("Approved") {}
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class StandardApproval extends State.Compound<StandardApproval>()("Approval") {
    static states = States.make(() => [Pending, Approved]);
  }

  return StandardApproval;
});

const EscalatedApproval = Component.make(() => {
  class Approved extends State.Atomic<Approved>()("Approved") {}
  class Escalated extends State.Atomic<Escalated>()("Escalated") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
      on(Escalate, Escalated),
    ]);
  }
  class EscalatedApproval extends State.Compound<EscalatedApproval>()("Approval") {
    static states = States.make(() => [Pending, Escalated, Approved]);
  }

  return EscalatedApproval;
});

function makeApprovalComponent(mode: "standard"): typeof StandardApproval;
function makeApprovalComponent(mode: "escalated"): typeof EscalatedApproval;
function makeApprovalComponent(mode: ApprovalMode) {
  return mode === "escalated" ? EscalatedApproval : StandardApproval;
}

The overloads retain the exact selected placement surface. After selection, .as(...) and Statechart.make(...) receive one concrete Component; the runtime never sees a conditional Component, alternate executable representation, or topology dispatch table.

This is ordinary TypeScript around the public Component API. It does not create an unresolved higher-order State slot, and it cannot substitute arbitrary topology after assembly.

Now compare a fixed definition, one authoring-time selection, and runtime data on the selected placement:

Choose topology without smuggling instance state

Factories choose geometry; instances carry data.

A finite, Schema-owned authoring mode may select one concrete Component before assembly. Per-instance values still belong to decoded chart inputs, events, State data, keys, and Resource boundaries.

1 · Begin with fixed authored geometry

One Approval component has one static topology.

Pending and Approved form a reusable definition assembled exactly once. This is the default: if every placement needs the same states and transitions, a factory would add no invariant and should not exist.

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

export class Approve extends Schema.TaggedClass<Approve>()("Approve", {}) {}

const Approval = Component.make(() => {
  class Approval extends State.Compound<Approval>()("Approval") {
    static states = States.make(() => [Pending, Approved]);
  }

  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Approved extends State.Atomic<Approved>()("Approved") {}

  return Approval;
});

class OrderFlow extends State.Compound<OrderFlow>()("OrderFlow") {
  static states = States.make(() => [OrderApproval]);
}

class OrderApproval extends Approval.as<OrderApproval>()("OrderApproval") {}

export const OrderFlowChart = Statechart.make(OrderFlow);

2 · Choose one concrete definition

A finite authoring mode selects the Escalated topology.

ApprovalMode owns the accepted standard | escalated vocabulary. makeApprovalComponent selects one named, concrete Component before placement and Statechart.make; the chosen classes and transitions become ordinary geometry and therefore participate in chart identity.

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

export class Approve extends Schema.TaggedClass<Approve>()("Approve", {}) {}
export class Escalate extends Schema.TaggedClass<Escalate>()("Escalate", {}) {}

const ApprovalMode = Schema.Literals(["standard", "escalated"]);
type ApprovalMode = typeof ApprovalMode.Type;

const StandardApproval = Component.make(() => {
  class StandardApproval extends State.Compound<StandardApproval>()("StandardApproval") {
    static states = States.make(() => [Pending, Approved]);
  }
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Approved extends State.Atomic<Approved>()("Approved") {}

  return StandardApproval;
});

const EscalatedApproval = Component.make(() => {
  class EscalatedApproval extends State.Compound<EscalatedApproval>()("EscalatedApproval") {
    static states = States.make(() => [Pending, Escalated, Approved]);
  }
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
      on(Escalate, Escalated),
    ]);
  }
  class Escalated extends State.Atomic<Escalated>()("Escalated") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Approved extends State.Atomic<Approved>()("Approved") {}

  return EscalatedApproval;
});

function makeApprovalComponent(mode: "standard"): typeof StandardApproval;
function makeApprovalComponent(mode: "escalated"): typeof EscalatedApproval;
function makeApprovalComponent(mode: ApprovalMode) {
  return mode === "escalated" ? EscalatedApproval : StandardApproval;
}

const Approval = makeApprovalComponent("escalated");

class OrderFlow extends State.Compound<OrderFlow>()("OrderFlow") {
  static states = States.make(() => [OrderApproval]);
}

class OrderApproval extends Approval.as<OrderApproval>()("OrderApproval") {}

export const OrderFlowChart = Statechart.make(OrderFlow);

3 · Keep instance variation in the chart

Reviewer assignment is data, not a factory parameter.

Every instance shares the chosen escalation topology, while Assign updates Schema-owned data on the concrete OrderApproval placement. Instance-specific values travel through events, inputs, facets, or Resource keys rather than changing authored classes.

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

export class Approve extends Schema.TaggedClass<Approve>()("Approve", {}) {}
export class Escalate extends Schema.TaggedClass<Escalate>()("Escalate", {}) {}
export class Assign extends Schema.TaggedClass<Assign>()("Assign", {
  reviewer: Schema.String,
}) {}

const ApprovalMode = Schema.Literals(["standard", "escalated"]);
type ApprovalMode = typeof ApprovalMode.Type;

const reviewer = {
  reviewer: Schema.String.pipe(Schema.withConstructorDefault(Effect.succeed("Unassigned"))),
};

const StandardApproval = Component.make(() => {
  class StandardApproval extends State.Compound<StandardApproval>()("StandardApproval", reviewer) {
    static states = States.make(() => [Pending, Approved]);
  }
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Approved extends State.Atomic<Approved>()("Approved") {}

  return StandardApproval;
});

const EscalatedApproval = Component.make(() => {
  class EscalatedApproval extends State.Compound<EscalatedApproval>()(
    "EscalatedApproval",
    reviewer,
  ) {
    static states = States.make(() => [Pending, Escalated, Approved]);
  }
  class Pending extends State.Atomic<Pending>()("Pending") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
      on(Escalate, Escalated),
    ]);
  }
  class Escalated extends State.Atomic<Escalated>()("Escalated") {
    static transitions = Transitions.make(this, ({ on }) => [
      on(Approve, Approved),
    ]);
  }
  class Approved extends State.Atomic<Approved>()("Approved") {}

  return EscalatedApproval;
});

function makeApprovalComponent(mode: "standard"): typeof StandardApproval;
function makeApprovalComponent(mode: "escalated"): typeof EscalatedApproval;
function makeApprovalComponent(mode: ApprovalMode) {
  return mode === "escalated" ? EscalatedApproval : StandardApproval;
}

const Approval = makeApprovalComponent("escalated");

class OrderFlow extends State.Compound<OrderFlow>()("OrderFlow") {
  static states = States.make(() => [OrderApproval]);
}

class OrderApproval extends Approval.as<OrderApproval>()("OrderApproval") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Assign).update(OrderApproval, ({ event }) => ({
      reviewer: event.reviewer,
    })),
  ]);
}

export const OrderFlowChart = Statechart.make(OrderFlow);

The selected geometry becomes chart identity

standard selects Pending → Approved. escalated selects Pending → Escalated → Approved. Those concrete nodes and transitions enter the canonical chart declaration, so the two assembled charts have different hashes.

The parameter is not copied into a secret metadata field merely because it was passed to a factory. Identity follows the resulting declaration. Two inputs that produce exactly the same canonical topology and contracts should not manufacture different chart identities.

Component names remain chart-unique. The two Approval variants in this example are alternatives for one placement. If one chart must contain both definitions at once, give them distinct Component root names rather than relying on the factory call to disambiguate them.

Per-instance values stay inside the model

Reviewer assignment does not add or remove a State. Assign is a decoded event and OrderApproval.reviewer is Schema-owned State data:

on(Assign).update(OrderApproval, ({ event }) => ({
  reviewer: event.reviewer,
}));

Two running instances may now carry different reviewers while sharing the exact same chart hash. Chart input, events, State data, Activity input, child keys, and Resource keys are the normal homes for per-instance variation. A factory parameter is not a shortcut around those boundaries.

A factory must own a real invariant

If every consumer needs the same topology, write one Component.make directly. A factory earns its place when it centralizes a real structural choice—for example:

  • selecting a policy-specific branch;
  • choosing one Schema-owned finite family of State definitions;
  • binding a compile-time contract that changes child or Resource requirements;
  • producing several named chart variants from one reviewed authoring boundary.

Keep that surface small. Every accepted value should have an explicit effect on nodes, transitions, or contracts, and every rejected value should fail at the decoding or authoring boundary rather than falling through to a default chart.

Test the authoring/runtime boundary

  1. Assemble every accepted mode and compare the exact topology and chart hash.
  2. Decode an unknown mode and prove no chart is constructed.
  3. Place the selected Component and verify its typed State and occurrence references.
  4. Change runtime reviewer data and prove the chart hash and topology remain unchanged.
  5. Try to place two different definitions with one Component name and require the chart-unique-name authoring refusal.

Next, Parameterization Safety and Identity turns these boundaries into stricter rules for canonical inputs, collisions, and migration-visible identity.