Form Validation with Typed Context
Managing login form state using setup({ types }), typed assign, guards, and meta.view with $bindState.
Use Case
This example mirrors the authMachine login pattern: a form state with a local state store ($bindState two-way binding), a guard on the submit action, and a meta.view spec describing the component tree. It covers:
- Typed context mutations with
setup.assign - Guards as inline functions checking context
meta.viewspec with$bindStatefor two-way form binding- Sending typed domain events from the view layer
Complete Code
import { setup } from "xstate";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
// Context shapeinterface LoginContext { isAuthenticated: boolean; username: string | null; errorMessage: string | null; params: Record<string, string>; query: Record<string, string>;}
// Event union — lowercase dot-separated namestype LoginEvent = | { type: "play.route"; to: string; params?: Record<string, string>; query?: Record<string, string>; } | { type: "auth.login"; username: string } | { type: "auth.logout" };
// 1. Typed setup — always use setup() before createMachine()const loginSetup = setup({ types: { context: {} as LoginContext, events: {} as LoginEvent, input: {} as Partial<LoginContext> | undefined, },});
// 2. Machine — wraps config in formatPlayRouteTransitions for play.route supportconst loginMachine = loginSetup.createMachine( formatPlayRouteTransitions({ id: "login", initial: "idle", context: ({ input }) => ({ isAuthenticated: input?.isAuthenticated ?? false, username: input?.username ?? null, errorMessage: null, params: input?.params ?? {}, query: input?.query ?? {}, }),
// Root-level event handlers — accessible from any state on: { "auth.login": { target: ".dashboard", // Guard: allow login only when not already authenticated guard: ({ context }) => !context.isAuthenticated, actions: loginSetup.assign({ isAuthenticated: true, errorMessage: null, // Typed by the event union — TypeScript narrows event.username safely username: ({ event }) => (event.type === "auth.login" ? event.username : null), }), }, "auth.logout": { target: ".idle", guard: ({ context }) => context.isAuthenticated, actions: loginSetup.assign({ isAuthenticated: false, username: null, }), }, },
states: { idle: { id: "idle", meta: { route: "/", view: { root: "root", elements: { root: { type: "Home", props: { title: "Welcome" }, children: [] }, }, }, }, },
login: { id: "login", meta: { route: "/login", view: { root: "root", // Local state store — initial value shown in the form field state: { username: "" }, elements: { root: { type: "Login", props: { title: "Sign In", // $bindState wires the prop to the local state store (two-way) username: { $bindState: "/username" }, }, children: [], on: { // emit("submit") → resolves username from $state, calls login action submit: { action: "login", params: { username: { $state: "/username" } }, }, }, }, }, }, }, },
dashboard: { id: "dashboard", meta: { route: "/dashboard", view: { root: "root", elements: { root: { type: "Dashboard", props: { title: "Dashboard" }, children: [], on: { logout: { action: "logout" }, }, }, }, }, }, // always-guard: redirect to login if not authenticated always: { guard: ({ context }) => !context.isAuthenticated, target: "login", }, }, }, }),);
// 3. Factory and actorconst createPlayer = definePlayer({ machine: loginMachine });const actor = createPlayer();actor.start();
// 4. Navigate to login pageactor.send({ type: "play.route", to: "#login" });console.log(actor.getSnapshot().value); // "login"
// 5. Login with usernameactor.send({ type: "auth.login", username: "alice" });console.log(actor.getSnapshot().value); // "dashboard"console.log(actor.getSnapshot().context.username); // "alice"
// 6. Guard prevents re-login while authenticatedactor.send({ type: "auth.login", username: "bob" }); // guard fires, transition rejectedconsole.log(actor.getSnapshot().context.username); // still "alice"
// 7. Logoutactor.send({ type: "auth.logout" });console.log(actor.getSnapshot().value); // "idle"console.log(actor.getSnapshot().context.username); // null
actor.stop();$bindState and $state Pattern
meta.view uses the @json-render/core spec format (PlaySpec). Two special directives wire the component to a local per-state state store:
| Directive | Direction | Usage |
|---|---|---|
{ $bindState: "/username" } | Two-way | Prop reads and writes the state store key /username |
{ $state: "/username" } | Read-only | Reads the current value from the state store at /username |
The state: key in meta.view sets the initial values for the local store. When the user types in the form field, the renderer keeps the store in sync. On submit, action params read the final value via $state.
// In the machine meta.view — the renderer handles the rest:view: { root: "root", state: { username: "" }, // initial store value elements: { root: { type: "Login", props: { username: { $bindState: "/username" }, // two-way binding }, on: { submit: { action: "login", params: { username: { $state: "/username" } }, // read on submit }, }, }, },}Key Concepts
setup({ types }): Always declare context, events, and input types beforecreateMachine.setup.assign(...): Use the scopedassignfrom yoursetupinstance, not the bare one fromxstate. This provides full type inference.- Guards as inline functions:
({ context }) => !context.isAuthenticated— guards check state invariants (“can I BE in this state?”), not event details. alwaystransitions: Entry guards on states. Used for protected routes — if the guard fires, the machine redirects before the state is fully entered.- Lowercase dot-separated event types:
"auth.login","auth.logout","play.route"— notSCREAMING_SNAKE_CASE.
Connecting the Renderer
The machine spec above defines view structure — but nothing renders until you wire a renderer. Use createRenderer from @xmachines/play-dom:
import { createRenderer, schema } from "@xmachines/play-dom";import { defineCatalog } from "@json-render/core";import { z } from "zod";import type { ComponentFn } from "@xmachines/play-dom";
const catalog = defineCatalog(schema, { components: { Home: { props: z.object({ title: z.string() }) }, Login: { props: z.object({ title: z.string(), username: z.string().optional() }) }, Dashboard: { props: z.object({ title: z.string() }) }, }, actions: { // Must declare all actions that appear in meta.view on-bindings login: { params: z.object({ username: z.string() }) }, logout: {}, },});type AppCatalog = typeof catalog;
// Component implementations receive typed props + emit/on helpersconst Home: ComponentFn<AppCatalog, "Home"> = ({ props }) => { const el = document.createElement("section"); el.textContent = props.title; return el;};
const Login: ComponentFn<AppCatalog, "Login"> = ({ props, on, ctx }) => { const section = document.createElement("section"); const input = document.createElement("input"); input.value = props.username ?? ""; // Keep the local state store in sync as the user types input.addEventListener("input", () => { ctx.store.update((s) => ({ ...s, username: input.value })); }); const button = document.createElement("button"); button.textContent = "Log In"; const submit = on("submit"); // bound to the spec's on.submit binding button.addEventListener("click", () => submit.emit()); // resolves $state, calls login action section.append(input, button); return section;};
const Dashboard: ComponentFn<AppCatalog, "Dashboard"> = ({ props, on }) => { const section = document.createElement("section"); section.textContent = props.title; const btn = document.createElement("button"); btn.textContent = "Log Out"; const logout = on("logout"); btn.addEventListener("click", () => logout.emit()); section.append(btn); return section;};
// createRenderer builds the factory once — call mount() when the actor is readyconst mount = createRenderer(catalog, { Home, Login, Dashboard });
const actor = createPlayer();actor.start();
const disconnect = mount(actor, document.getElementById("app")!);
// Cleanupwindow.addEventListener("beforeunload", () => { disconnect(); actor.stop();});Key points:
actionsindefineCatalogmust include every action name referenced inmeta.viewon:bindings. TypeScript enforces this viaCatalogHasActions.on("submit").emit()resolvesparamsfrom the current state store at call time — the$state: "/username"in the spec is read at that moment, not at render time.ctx.store.update(fn)is the standard write path for two-way binding. The spec’s$bindStatedirective only wires the read direction (prop ← store); the write direction (store ← input) must be implemented in the component.
Next Steps
- Basic State Machine — Foundational concepts without a view layer
- Routing Patterns — Parameter routes, relative routes, and
alwaysauth guards PlaySpec— Spec type governingmeta.view,$bindState,$state, andcontextProps@xmachines/play-router— Route extraction and tree building