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 { compose, definePlayer, PlayerActor } from "@xmachines/play-xstate";import { formatPlayRouteTransitions } from "@xmachines/play-xstate/routing";import { withView } from "@xmachines/play-xstate/view";
// 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 actor — withView derives the meta.view spec that a renderer readsconst createPlayer = definePlayer({ machine: loginMachine, actor: compose(PlayerActor, withView),});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 @xmachines/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 component writes the value to the store. 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 "@xmachines/json-render-core";import { z } from "zod";import type { ComponentFn } from "@xmachines/play-dom";import type { ViewActor } from "@xmachines/play-view";
// The started actor of the complete code abovedeclare const actor: ViewActor & { stop(): void };
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 ?? ""; // Write each keystroke to the store path that $bindState binds to the prop const username = ctx.bound<string>("username"); input.addEventListener("input", () => username.set(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 disconnect = mount(actor, document.getElementById("app")!);
// Call teardown() when the app ends before the page does, for example when the component// that holds it unmounts. At the end of the page, do nothing: the browser frees the page.// A teardown on `beforeunload` also runs when the page goes into the back/forward cache,// and Back then shows a page that does not respond.function teardown(): void { 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.bound("username").set(value)writes to the store path that$bindStatebinds to the prop. The$bindStatedirective gives the prop its value from the store. The component writes the input back to the store.
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, and$state@xmachines/play-router— Route extraction and tree building