Skip to content

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.view spec with $bindState for 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 shape
interface LoginContext {
isAuthenticated: boolean;
username: string | null;
errorMessage: string | null;
params: Record<string, string>;
query: Record<string, string>;
}
// Event union — lowercase dot-separated names
type 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 support
const 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
const createPlayer = definePlayer({ machine: loginMachine });
const actor = createPlayer();
actor.start();
// 4. Navigate to login page
actor.send({ type: "play.route", to: "#login" });
console.log(actor.getSnapshot().value); // "login"
// 5. Login with username
actor.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 authenticated
actor.send({ type: "auth.login", username: "bob" }); // guard fires, transition rejected
console.log(actor.getSnapshot().context.username); // still "alice"
// 7. Logout
actor.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:

DirectiveDirectionUsage
{ $bindState: "/username" }Two-wayProp reads and writes the state store key /username
{ $state: "/username" }Read-onlyReads 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 before createMachine.
  • setup.assign(...): Use the scoped assign from your setup instance, not the bare one from xstate. 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.
  • always transitions: 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" — not SCREAMING_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 helpers
const 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 ready
const mount = createRenderer(catalog, { Home, Login, Dashboard });
const actor = createPlayer();
actor.start();
const disconnect = mount(actor, document.getElementById("app")!);
// Cleanup
window.addEventListener("beforeunload", () => {
disconnect();
actor.stop();
});

Key points:

  • actions in defineCatalog must include every action name referenced in meta.view on: bindings. TypeScript enforces this via CatalogHasActions.
  • on("submit").emit() resolves params from 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 $bindState directive only wires the read direction (prop ← store); the write direction (store ← input) must be implemented in the component.

Next Steps