// File: --- title: Introduction description: A small finite state machine for JavaScript, shaped after SCXML. No dependencies. ESM only. slug: / --- ## What is 1state A small finite state machine for JavaScript, shaped after [SCXML](https://www.w3.org/TR/scxml/). No dependencies. ESM only. It exists for multi-step forms: flows where the next question depends on the answers already given, where the user leaves halfway through, and where the flow gains a step while they are away. Three packages: | Package | What | | ------------------ | -------------------------------------------- | | `@1state/core` | the engine | | `@1state/validate` | static checks for a definition, plus a CLI | | `@1state/mermaid` | render a definition as a diagram, plus a CLI | Node 20+. ## A quick example ```javascript import { createMachine, assign } from '@1state/core' const signup = { context: { email: null, password: null }, initial: 'email', states: { email: { on: { answer: { target: 'email', actions: assign({ email: (_ctx, e) => e.value }) }, }, go: [{ target: 'password', cond: (ctx) => ctx.email !== null }], }, password: { on: { answer: { target: 'password', actions: assign({ password: (_ctx, e) => e.value }) }, }, go: [{ target: 'done', cond: (ctx) => ctx.password !== null }], }, done: { type: 'final' }, }, } const machine = createMachine(signup) await machine.start() // { value: "email", context: {...}, done: false } await machine.send('answer', { value: 'a@example.org' }) // value: "password" await machine.path() // ["email", "password"] await machine.send('answer', { value: 'hunter2' }) // done: true ``` ## Two things carry the model `on` records an answer as a **self-transition**: the state exits and re-enters, so its guards run again. `go` is **eventless**: after every step the machine retries the `go` transitions of whatever is active, and keeps going until nothing opens. Where it stops is where the user is. Everything else on the site follows from those two, and from the property they produce. ## Position is derived, never stored Nothing anywhere records "the user is on step 3". The position is a pure function of the answers, recomputed from the initial state on every `start()`. Guards read the data. ```javascript const resumed = createMachine(signup, { context: { email: 'a@example.org' } }) await resumed.start() // value: "password" ``` No cursor was loaded. That one property buys resume, no skip-ahead and forward migration — three features that are otherwise three implementations. See [Derived position](/docs/concepts/derived-position). ## Next steps - [Quick Start](/docs/quick-start) — install and write a flow. - [Derived position](/docs/concepts/derived-position) — the central idea, and the rule guards have to follow to keep it. - [Composition](/docs/concepts/composition) — sections, `onFinal`, and two entry points from one definition. - [URLs](/docs/concepts/urls) — the node id is the path, and `route()` is the whole page contract. - [Limits](/docs/concepts/limits) — what this does not do. // File: concepts/composition/ --- title: Composition description: A section is a machine that ends in final and names no successor. onFinal chains them, and it belongs to the composing flow. --- A section is a self-contained machine that ends in a `final` state and names no successor. Chain sections with spread: ```javascript const section = (key) => ({ initial: key, states: { [key]: { on: { answer: { target: key, actions: assign({ [key]: (_c, e) => e.value }) } }, go: [{ target: 'done', cond: (ctx) => ctx[key] !== undefined }], }, done: { type: 'final' }, }, }) const profile = section('name') const billing = section('card') const wizard = { initial: 'profile', states: { profile: { ...profile, onFinal: 'billing' }, billing: { ...billing, onFinal: 'review' }, review: { type: 'final' }, }, } ``` `onFinal` fires when a state's subtree reaches `final`, and it belongs to the composing flow, not the section. ## Two entry points, one definition Because no section names its own successor, the same definition drives the wizard and the standalone page: ```javascript createMachine(wizard) // the wizard createMachine(billing, { context: loadedFromRow }) // /settings/billing ``` Only the starting context differs — and, once actions are named rather than inline, the script registry. The settings page closes immediately when the answer is already on the row, which is the same settling rule doing the same thing at a different entry point. ## Applicability lives on the transition in **Applicability lives on the transition into a section, never inside it.** ```javascript profile: { ...profile, onFinal: [ { target: 'billing', cond: (ctx) => ctx.needsBilling }, { target: 'review' }, ], }, ``` A section that skipped itself with an internal guard would still be *entered*, which puts it on the trail as a page Back can land on. Routed around, it is never visited at all. It also keeps the section usable standalone — from settings you can open a section the wizard skipped. ## Transition lists A transition is `"target"`, `{ target, cond, actions }`, or a list tried in order. The first whose `cond` passes wins, and no match means stay put. So the list above reads as: billing if it applies, otherwise review. A target resolves against the source's siblings, then outward through each ancestor's children, so `"done"` and `"billing.done"` both work from anywhere. ## A worked example `examples/onboard.js` in the repository composes twenty-odd sections into one onboarding wizard, with sections that route around each other, a list sub-flow used three times, and a terminal state that waits on an external scan. See [Onboarding wizard](/docs/examples/onboarding). // File: concepts/derived-position/ --- title: Derived position description: The position is a pure function of the answers, recomputed on every start(). Guards read the answers, never a stored cursor. --- Nothing anywhere records "the user is on step 3". The position is a pure function of the answers, recomputed from the initial state on every `start()`. Guards read the data. ```javascript const resumed = createMachine(signup, { context: { email: 'a@example.org' } }) await resumed.start() // value: "password" ``` No cursor was loaded. `start()` entered `email`, found its guard open, moved to `password`, found that guard closed, and stopped. ## What it buys That one property buys three things that are otherwise separate features: - **Resume.** Load the row into `context`, start, land on the first gap. - **No skip-ahead.** A later state is unreachable until its predecessors' guards pass. There is no cursor to forge, because there is no cursor. - **Forward migration.** Add a state and every already-"complete" record re-opens at it. Turning on a policy that inserts a step is how you reach users who finished last year. None of the three is a feature of the engine. They are consequences of settling from the answers, and they stop being true the moment something stores a position. ## How settling works Two rules do all of it. `on` records an answer as a **self-transition**. The transition targets the state it fires in, so the state exits and re-enters, and its guards run again against the answer just written. `go` is **eventless**. After every step the machine retries the `go` transitions of whatever is active and keeps going until nothing opens. Where it stops is where the user is. So a `start()` on a half-filled context walks the flow from the top, opening guard after guard, until it reaches the first question whose answer is missing. That walk is the resume. ## Guards must read the answer The corollary is that guards must read the **answer**, not a marker of progress: ```javascript go: [{ target: 'done', cond: (ctx) => 'newsletter' in ctx }] // yes go: [{ target: 'done', cond: (ctx) => ctx.newsletter }] // no — "no" reads as unanswered ``` Test for presence (`in`, `=== false`), not truthiness. `false`, `0` and `""` are answers. A truthiness guard on a boolean question traps every user who answers "no": the guard stays closed, the flow never leaves, and the page re-asks a question that was already answered. Where an answer genuinely cannot be told apart from its default, write a separate `answeredAt` marker and guard on that. ```javascript retention: { on: { answer: { target: 'retention', actions: 'patchOrganization' } }, // every option, including the platform default, is a real choice go: [{ target: 'done', cond: (ctx) => 'retention' in ctx.organization }], }, ``` ## Never restore from the URL Settling from context is the entire no-skip-ahead guarantee. Feeding `toValue(segments)` into `options.state` restores a position the user typed, which walks straight past every guard in front of it. See [URLs](/docs/concepts/urls) for the supported way to serve a step page. // File: concepts/limits/ --- title: Limits description: What 1state does not do — series-only replay, no history states, external transitions only, one-shot invoke, no scheduler. --- ## path() and back() are for series flows They replay `go` guards and `onFinal` hand-offs forward from the initial state. A parallel region has no single predecessor, and event-driven jumps and loops are not replayable. When the replay cannot reach the current state, `path()` returns just that state and `back()` stays put. `toPath`/`toValue` are series-only for the same reason — give each region its own segments if you need URLs over a parallel state. ## No history states Re-entering a compound state goes to its `initial`. ## Transitions are always external Targeting the state you are in exits and re-enters it, running `onExit` then `onEntry`. There is no internal-transition flag. This is what makes the self-transition idiom re-run guards, and it means `onEntry` must be idempotent. ```javascript codes: { // idempotent: the answer self-transition re-enters this state onEntry: 'generateRecoveryCodes', on: { acknowledge: { target: 'codes', actions: 'patchAccount' } }, go: [{ target: 'done', cond: (ctx) => !!ctx.account.recoveryCodesAckedAt }], }, ``` ## Event names match exactly No SCXML wildcards, no `error.*` prefixes. ## invoke is a one-shot promise, not a concurrent session `src` is awaited inline while the machine settles. No actors, no spawning, no cancellation. It re-runs every time its state is entered. ## No delayed transitions and no scheduler Nothing happens between calls. ## Non-settling machines throw A machine whose eventless transitions never settle throws after 1000 microsteps rather than hanging. // File: concepts/persistence/ --- title: Persistence description: There is no persistence adapter, deliberately. There is no machine state to persist — the answers are your row. --- ## There is no persistence adapter Deliberately. There is no machine state to persist. The position is derived from the answers, and the answers are your row. Persist the row — which you were going to do anyway — and you are done. To resume, build the machine with `context` loaded from it and call `start()`. ```javascript const machine = createMachine(onboard, { scripts, context: await load(accountId) }) await machine.start() // exactly where they left off ``` ## Writes belong in actions Name them in the definition and point them at your database through `options.scripts`; the definition does not change between an in-memory test and production. ```javascript createMachine(onboard, { scripts: { patchAccount: (ctx, e) => db.account.update(ctx.id, e.patch) }, context, }) ``` A script is a function `(context, event)` — sync or async — or the name of one in `options.scripts`. The engine awaits the result. ## Why a snapshot would be worse An adapter that snapshotted the machine would be strictly worse: - the snapshot can disagree with the row, - it goes stale the moment an answer changes, - and it pins users to the version of the flow that was live when they started — which is exactly the forward migration you wanted. ## options.state `options.state` does exist, for genuinely non-derivable positions. It lands on the stored value without running entry actions and without settling, so a resume cannot re-fire an `invoke`. Do not reach for it to make a wizard resume; that is free. ```javascript // only for a position the answers cannot reproduce createMachine(flow, { state: { region: 'step' } }) ``` ## Context is mutated, and merged shallowly `options.context` is merged over `definition.context`, shallowly, and the machine mutates its context. If `definition.context` holds nested objects, build it with a factory instead — otherwise every machine compiled from that definition shares them. ```javascript export const initialContext = () => ({ account: {}, passkey: [], emailCount: 0, }) ``` // File: concepts/urls/ --- title: URLs description: The node id is the URL path. route(segments) gives a step page its redirect target and its Back link off one replay of the trail. --- The node id **is** the URL path. `profile.name` ⇄ `/profile/name`. No route table. ```javascript import { toPath, toValue } from '@1state/core' toPath({ profile: 'name' }) // ["profile", "name"] toValue(['profile', 'name']) // { profile: "name" } ``` ## route(segments) `route(segments)` gives a step page everything it needs off one replay of the trail: ```javascript await machine.route(['billing', 'card']) // { target: null, prev: ["profile", "name"] } await machine.route(['profile', 'name']) // { target: null, prev: null } await machine.route(['review']) // { target: ["billing", "card"], prev: null } ``` - `target` — `null` means allow. Otherwise redirect there. The current step and any earlier one are allowed; that is back-navigation and re-editing. Unknown, off-flow or ahead bounces to current. - `prev` — the Back link for the **requested** step, not for the current one. Re-editing an earlier answer has to go back to what preceded *it*. `null` on the first step, and while redirecting. Change an earlier answer and both change with it, because both come from the same replay. ## A step page The whole contract for a step page is one call. ```javascript const machine = createMachine(onboard, { scripts, context: await load(accountId) }) await machine.start() const { target, prev } = await machine.route(params.segments) if (target) redirect(302, `/${target.join('/')}`) render({ step: params.segments, back: prev && `/${prev.join('/')}` }) ``` The redirect is not an access check bolted on top. A step ahead of the current one is unreachable because its predecessors' guards have not passed, and `route()` reports that rather than enforcing it. ## Never restore from the URL > **Never restore from the URL.** Do not feed `toValue(segments)` into > `options.state`. Settling from context is the entire no-skip-ahead guarantee; a > typed URL that restores directly walks straight past it. Build from `context`, call `start()`, and ask `route()` what to do with the requested segments. ## Series only `toPath` and `toValue` are series-only, for the same reason `path()` and `back()` are: a parallel region has no single predecessor. Give each region its own segments if you need URLs over a parallel state. See [Limits](/docs/concepts/limits). // File: examples/onboarding/ --- title: Onboarding wizard description: A real composed flow — account, organization and domain sections chained by onFinal, with a reusable list sub-flow and a blocking terminal state. --- `examples/onboard.js` in the repository is one onboarding wizard built from twenty-odd sections. `examples/ONBOARD.md` is what `1state-mermaid` renders it to, and `examples/onboard.test.js` runs it. Every export is a self-contained series machine: one state per question, an event that records the answer as a self-transition, and a `go` guard that leaves once the answer is on the context. ## A section ```javascript export const accountName = { initial: 'name', states: { name: { on: { answer: { target: 'name', actions: 'patchAccount' } }, go: [{ target: 'done', cond: (ctx) => !!ctx.account.name }], }, done: { type: 'final' }, }, } ``` Actions are named, not inline. `patchAccount` is in-memory in the example and a database write in the application; the definition is the same object either way. ## Chaining ```javascript export const accountOnboard = { initial: 'terms', states: { terms: { on: { agree: { target: 'terms', actions: 'createAccount' } }, go: [{ target: 'username', cond: (ctx) => !!ctx.account.termsAt }], }, username: { ...accountUsername, onFinal: 'passkey' }, passkey: { ...accountWebauthnPassKey, onFinal: 'recoveryCodes' }, recoveryCodes: { ...accountRecoveryCodes, onFinal: [ { target: 'securityKey', cond: (ctx) => ctx.mfaRequire }, { target: 'name' }, ], }, securityKey: { ...accountWebauthnSecurityKey, onFinal: 'name' }, name: { ...accountName, onFinal: 'emailAddress' }, // ... }, } ``` Whether the flow asks for a second factor is `mfaRequire`, and it lives on the transition **into** `securityKey`, not inside it. A section that skipped itself with an internal guard would still be entered, which would put it on the trail as a page Back could land on. Adding the second factor to the flow re-opens finished accounts, which is how an admin turning `mfaRequire` on reaches existing members. That is forward migration doing its job. ## Presence, not truthiness ```javascript export const organizationRetention = { initial: 'retention', states: { retention: { on: { answer: { target: 'retention', actions: 'patchOrganization' } }, go: [{ target: 'done', cond: (ctx) => 'retention' in ctx.organization }], }, done: { type: 'final' }, }, } ``` Every option — including "none" and the platform default — is a real choice, so presence decides, not the value. Answers that genuinely cannot be told apart from their default get an `At` marker instead, and the guard reads that. ## A list sub-flow `listFlow(key, { min, max })` builds the three pages every list question needs: "add one?", the row, and review — looping until the user says no more or the list is full. ```javascript export const organizationBillingEmail = listFlow('billingEmail', { min: 1, max: 8 }) export const organizationPromoCode = listFlow('promoCode', { max: 10 }) export const organizationCollaborator = listFlow('collaborator', { max: 128 }) ``` `Wanted` is how many rows the user has **asked** to enter; the add page rests until the list catches up. At rest the loop edge is closed, which is what keeps the trail replayable: Back walks the section in a straight line. Inside the loop it can only reach back to the first page, which is the honest answer — a repeated page has no single predecessor. `min > 0` drops the "add one?" page entirely rather than guarding it away, because a skipped page would leave a phantom step on the trail for Back to link to. ## The events are a trust boundary ```javascript const merge = (target, patch = {}) => { for (const key of Object.keys(patch)) if (key !== '__proto__') target[key] = patch[key] return target } ``` `event.patch` is whatever the form posted. `Object.assign` would apply a `__proto__` key through its setter, replacing the target's prototype instead of adding a property — and since every guard here asks `"answer" in config`, a request could then satisfy a question it never answered, with `Object.keys` still showing nothing. Own keys only, and never that one. Presence guards make this the one place in the flow where input sanitising is not optional. ## A terminal state that waits ```javascript reportOnly: { on: { scan: { target: 'reportOnly', actions: 'patchFqdn' } }, go: [{ target: 'done', cond: (ctx) => !!ctx.fqdnConfig.setupComplete }], }, ``` Blocking on purpose: nothing the user clicks satisfies it. The flow waits for an external scan to observe the deployed configuration, and the guard reads what the scan wrote. No scheduler, no polling inside the machine — the next `send` comes from whatever observed the world. ## Two entry points ```javascript createMachine(onboard, { scripts, context: initialContext() }) // the wizard createMachine(accountName, { scripts, context: loadedContext }) // /settings/name ``` No sub-machine names its successor, so the same definitions drive both. Only the starting context and the script registry differ. `initialContext` is a factory, not a literal: the machine mutates it, and the nested objects would otherwise be shared between every machine built from the definition. // File: packages/core/ --- title: core description: The engine. createMachine, assign, toPath, toValue. No dependencies, ESM only, Node 20+. --- The engine. No dependencies. ## Install ```bash npm install @1state/core ``` ## Exports | Export | What | | --------------------------------- | ------------------------------------------------------- | | `createMachine(definition, opts)` | compiles a definition and returns a machine. | | `assign(updates)` | builds an action that writes keys onto the context. | | `toPath(value)` | a state value as URL segments. | | `toValue(segments)` | the reverse of `toPath`. | Types ship in `index.d.ts`. See [API](/docs/reference/api) for the full surface and [Definition](/docs/reference/definition) for the shape it compiles. ## createMachine ```javascript import { createMachine, assign } from '@1state/core' const machine = createMachine(signup, { scripts: { saveEmail: (ctx, e) => db.account.update(ctx.id, { email: e.value }) }, context: await load(accountId), }) await machine.start() ``` Compilation is eager: bad targets, missing `initial`s and unknown script names throw from `createMachine`, not from the first `send()` that reaches them. ## Machine ```javascript await machine.start(event?) // enter, settle, resolve to the state await machine.send(type, payload?) // process an event, settle, resolve await machine.back(event?) // previous step on the trail; does not settle await machine.path() // ["profile.name", "billing.card"] await machine.route(segments) // { target, prev } machine.state() // { value, context, done }, synchronous ``` ## assign ```javascript on: { answer: { target: 'email', actions: assign({ email: (_ctx, event) => event.value }), }, }, ``` Values may be static or `(context, event)` functions. A function is always called, so there is no way to assign a function as a value. ## toPath / toValue ```javascript import { toPath, toValue } from '@1state/core' toPath({ profile: 'name' }) // ["profile", "name"] toValue(['profile', 'name']) // { profile: "name" } ``` Series-only, like `path()` and `back()`. Do not feed `toValue(segments)` into `options.state` — see [URLs](/docs/concepts/urls). ## Performance `npm run perf --workspace @1state/core` runs the benchmark suite in `index.perf.js`. The test suite is held at 100% line, branch and function coverage by the package's own `test` script, and the repository runs mutation testing over it with Stryker. // File: packages/mermaid/ --- title: mermaid description: Render a machine definition as a mermaid stateDiagram-v2, plus the 1state-mermaid CLI and its --check mode for CI. --- Renders a definition as a mermaid `stateDiagram-v2`, fenced in a `mermaid` block ready to drop into a markdown file. It reads the definition, not a running machine, so it draws **every** branch — including the ones current answers would skip. ## Install ```bash npm install --save-dev @1state/mermaid ``` ## CLI ```bash 1state-mermaid --title 'Onboarding flow' examples/onboard.js > examples/ONBOARD.md 1state-mermaid --title 'Onboarding flow' --check examples/ONBOARD.md examples/onboard.js ``` | Flag | Meaning | | ---------------- | -------------------------------------------------------------- | | `--title ` | document heading. | | `--check ` | compare against `` instead of writing; non-zero on drift. | | `-h`, `--help` | usage. | A `.js` module renders every export that looks like a machine, each as its own section. A `.json` file is a single machine. Export order is definition order, which reads bottom-up — you meet a section before the flow that nests it. ## --check is the point `--check` re-renders and compares instead of writing, so a committed diagram that no longer matches its machine fails CI instead of quietly going stale. ```json { "docs": "1state-mermaid --title 'Onboarding flow' examples/onboard.js > examples/ONBOARD.md", "docs:check": "1state-mermaid --title 'Onboarding flow' --check examples/ONBOARD.md examples/onboard.js" } ``` ## What it draws Nesting is the point: a composed flow is sub-machines chained by `onFinal`, and each one becomes a mermaid composite state at its own depth. Inline guards print as written — collapsed to one line, shortened, and stripped of the characters mermaid parses as syntax. A named script prints its name. Only named actions get an edge label; an inline closure is plumbing. ``` stateDiagram-v2 %% accountEmailAddress [*] --> address state "address
answer → patchAccount, sendEmailCode" as address address --> code : !!ctx.account.emailAddress state "code
answer → verifyEmailCode
resend → sendEmailCode" as code code --> [*] : ctx.emailCount > 0 ``` That is one more reason to name actions in the definition rather than inline them: the diagram then shows which port each page calls. ## Library ```javascript import mermaid from '@1state/mermaid' const diagram = mermaid(definition, { title: 'Signup' }) ``` `title` is written as a `%%` comment on the first line of the diagram. The package does not depend on `@1state/core` at runtime — it restates the definition shape in its own types — so it can be installed on its own. // File: packages/validate/ --- title: validate description: Static checks for a machine definition — JSON Schema, reachability, and a compile through the engine — plus the 1state-validate CLI. --- Static checks for a definition. Returns every problem it found; an empty list means valid. Never mutates the definition. ## Install ```bash npm install --save-dev @1state/validate ``` ## CLI ```bash 1state-validate examples/onboard.js 1state-validate --scripts ./ports.js machines/checkout.json ``` ``` machines/checkout.js ✓ address ✗ checkout state "orphan" is unreachable: nothing targets it 2 machines, 1 invalid ``` Non-zero exit on failure, so it drops into CI as-is. | Flag | Meaning | | -------------------- | -------------------------------------------------------------------- | | `--scripts ` | module whose default (or `scripts`) export is the registry. | | `-h`, `--help` | usage. | A `.js` module is scanned for **every** export that looks like a machine, so one file of composed sections is one call. A `.json` file is a single machine, and borrows the file's name. Globs are the shell's job. Named scripts resolve against the module's own `scripts` export unless `--scripts` overrides it. A `--scripts` module that will not load, or has no registry, is fatal rather than a silent fall-through — otherwise the check that was asked for would report green and never run. ## Three passes It runs three passes and reports everything at once. **Schema.** JSON Schema, strict about unknown keys — `onEnter` for `onEntry` is otherwise a silent no-op. If the shape is wrong it stops here; the other two passes are only worth running on a definition that parses. **Reachability.** A state nothing points at is dead weight the engine will never mention. Targets resolve outward, so a name used in one section can legitimately reach a state in another: the pass deliberately over-counts rather than calling a state dead when it isn't. What it catches is the state you added and never routed to. Parallel regions are entered by their parent, so they are never orphans. **Compile.** The definition goes through `createMachine`, so targets, script names and missing `initial`s are answered by the engine itself rather than reimplemented here. ## Library ```javascript import validate from '@1state/validate' const errors = validate(definition, { scripts }) if (errors.length) throw new Error(errors.join('\n')) ``` `options` is the same object `createMachine` takes, and is handed to it. In practice only `scripts` changes the outcome, since a name with no registry entry is one of the things reported. The JSON Schema itself is exported separately: ```javascript import schema from '@1state/validate/schema' ``` // File: quick-start/ --- title: Quick Start description: Install 1state, write a two-question flow, and resume it from a stored row. --- ## Install ```bash npm install @1state/core npm install --save-dev @1state/validate @1state/mermaid ``` Node 20+. ESM only — there is no CommonJS build. ## A flow is an object One state per question. An event records the answer as a self-transition, and a `go` guard leaves once the answer is on the context. ```javascript import { createMachine, assign } from '@1state/core' const signup = { context: { email: null, password: null }, initial: 'email', states: { email: { on: { answer: { target: 'email', actions: assign({ email: (_ctx, e) => e.value }) }, }, go: [{ target: 'password', cond: (ctx) => ctx.email !== null }], }, password: { on: { answer: { target: 'password', actions: assign({ password: (_ctx, e) => e.value }) }, }, go: [{ target: 'done', cond: (ctx) => ctx.password !== null }], }, done: { type: 'final' }, }, } ``` `answer` targets the state it fires in. The state exits and re-enters, so its `go` guards run again against the answer that was just recorded. ## Run it ```javascript const machine = createMachine(signup) await machine.start() // { value: "email", context: { email: null, password: null }, done: false } await machine.send('answer', { value: 'a@example.org' }) // { value: "password", ... } await machine.path() // ["email", "password"] await machine.send('answer', { value: 'hunter2' }) // { value: "done", done: true } ``` `start()` enters `email`, retries its `go` transitions, finds the guard closed, and stops. That is where the user is. ## Resume it Load the stored answers into `context` and start. Nothing else. ```javascript const resumed = createMachine(signup, { context: { email: 'a@example.org' } }) await resumed.start() // value: "password" ``` There was no cursor to load. `start()` entered `email`, found its guard open, moved to `password`, found that guard closed, and stopped. ## Write through named scripts Inline functions are fine in a test. In an application, name the actions in the definition and point them at your database through `options.scripts`, so the definition does not change between the two. ```javascript const machine = createMachine(signup, { scripts: { saveEmail: (ctx, e) => db.account.update(ctx.id, { email: e.value }), }, context: await load(accountId), }) ``` A script is a function `(context, event)` — sync or async — or the name of one in `options.scripts`. ## Check it ```bash npx 1state-validate signup.js npx 1state-mermaid --title 'Signup' signup.js > SIGNUP.md ``` `1state-validate` runs a JSON Schema pass, a reachability pass and a compile through the engine, and reports everything at once. `1state-mermaid --check` fails when a committed diagram no longer matches its machine, which is what makes it a CI job. ## Next Read [Derived position](/docs/concepts/derived-position). It is one page, it is the whole model, and the rule at the bottom of it is the one mistake worth avoiding. // File: reference/api/ --- title: API description: createMachine, its options, the six methods a running machine exposes, and the helpers. --- ```javascript const machine = createMachine(definition, options) ``` ## Options | Option | Meaning | | -------------- | ---------------------------------------------------------------- | | `scripts` | name → function, for string references in the definition. | | `context` | merged over `definition.context`, shallowly. | | `state` | a stored value to restore instead of entering and settling. | | `onSend` | `(event)` before an event is processed. | | `onTransition` | `(state)` after `start`, `send` and `back`. | | `onChange` | `({...context}, event)` after a transition that carried actions. | | `onFinal` | `(state)` when the root reaches `final`. | The merge is shallow, and the machine mutates its context. If `definition.context` holds nested objects, build it with a factory instead — otherwise every machine compiled from that definition shares them. ## Methods | Method | Returns | | ---------------------- | ---------------------------------------------------------------------- | | `start(event?)` | enters the initial state, settles, resolves to the state. | | `send(type, payload?)` | processes an event, settles, resolves to the state. | | `back(event?)` | moves to the previous step on the trail. Deliberately does not settle. | | `path()` | the trail as dotted ids, e.g. `["profile.name", "billing.card"]`. | | `route(segments)` | `{ target, prev }`. | | `state()` | `{ value, context, done }`. Synchronous. | Everything except `state()` is async. `start()` is idempotent. `send()` before `start()` throws. `back()` does not settle on purpose: settling from the previous step would walk straight forward again, because the answer that opened the guard is still there. ## State value A snapshot is `{ value, context, done }`. `value` follows the xstate convention: `"idle"`, `{ parent: "child" }`, `{ region: { a: "x", b: "y" } }`. `state()` called before `start()` reports `undefined` for `value`. ## Helpers ### assign(updates) Builds an action from an object of values or `(context, event)` functions. ```javascript assign({ email: (_ctx, event) => event.value }) ``` ### toPath(value) / toValue(segments) ```javascript toPath({ profile: 'name' }) // ["profile", "name"] toValue(['profile', 'name']) // { profile: "name" } ``` Series-only. See [URLs](/docs/concepts/urls). ## Errors The engine throws rather than degrading, at compile time where it can: - `target "x" not found from "y"` — a transition points at nothing reachable. - `state "x" has child states but no initial` — a compound state without an entry. - `parallel state "x" has no regions` - `script "x" is not a function` — a name with no `options.scripts` entry. - `call start() before send()` - `microstep limit reached: machine has a transition loop` — 1000 microsteps without settling. `@1state/validate` reports the first group of these, plus schema and reachability problems, without running the flow. // File: reference/definition/ --- title: Definition description: Every key a state can carry, how transitions resolve, and what counts as a script. --- A definition is a plain object. So is every state inside it — which is why composition is a spread. ## Keys | Key | Meaning | | --------- | --------------------------------------------------------------------------------- | | `type` | `compound` \| `parallel` \| `atomic` \| `final`. Inferred from `states` if absent. | | `initial` | a direct child's name, or `{ target, actions }`. Required on compound. | | `states` | child states, keyed by name. The key is the URL segment. | | `context` | root only. `options.context` is merged over it. | | `on` | `{ event: transitions }`. Unhandled events bubble to ancestors. | | `go` | eventless transitions, retried until none opens. | | `onEntry` | script or list, run on entering. | | `onExit` | script or list, run on leaving. | | `onFinal` | transitions taken when this state's subtree reaches `final`. | | `invoke` | `{ src, onSuccess, onError }`. `src` is awaited on entry. | ## Transitions A transition is `"target"`, `{ target, cond, actions }`, or a list tried in order — the first whose `cond` passes wins, and no match means stay put. ```javascript go: 'done' go: { target: 'done', cond: (ctx) => 'answer' in ctx } go: [ { target: 'detail', cond: (ctx) => ctx.wantsDetail === true }, { target: 'done', cond: (ctx) => ctx.wantsDetail === false }, ] ``` A target resolves against the source's siblings, then outward through each ancestor's children, so `"done"` and `"billing.done"` both work from anywhere. `actions` run after exiting the source and before entering the target. ## Scripts A script is a function `(context, event)` — sync or async — or the name of one in `options.scripts`. The engine awaits the result: an action's is discarded, a guard's is read for truthiness, and an invoke's arrives as `event.data`. Naming scripts rather than inlining them is what lets one definition run against an in-memory registry in a test and a database in production. It is also what `1state-mermaid` draws as edge labels — an inline closure is plumbing, a named script is a port. ## invoke ```javascript lookup: { invoke: { src: 'fetchProfile', onSuccess: { target: 'confirm', actions: assign({ profile: (_c, e) => e.data }) }, onError: 'manual', }, }, ``` `src` is awaited inline while the machine settles. `event.data` carries what it resolved to, `event.error` what it threw. It re-runs every time its state is entered — see [Limits](/docs/concepts/limits). ## assign ```javascript assign({ email: (_ctx, event) => event.value, reviewedAt: new Date().toISOString(), }) ``` `assign(updates)` builds an action from an object of values or `(context, event)` functions. There is no way to assign a function: one is always called. ## Types `@1state/core` ships `index.d.ts`. `Context` is inferred from `definition.context`, or failing that from `options.context`, and an explicit `createMachine(...)` takes over when neither is a literal. A definition held in its own `const` needs `satisfies MachineDefinition`, or an annotation: `type: "final"` widens to `string` otherwise, and `string` is not one of the four state types. ```typescript const flow = { initial: 'a', states: { a: { go: 'done' }, done: { type: 'final' } }, } satisfies MachineDefinition ```