Skip to content

Views in XMachines

A view of XMachines is a fact about a STATE, and not a fact about a component tree. A state node declares meta.view, the actor derives the current view from the active states, and a renderer turns that description into the UI of one framework.

This guide tells which state supplies the view, how a parent view holds the views of its substates, how a view reads the machine context, and when a renderer keeps the UI and when it builds the UI again. It also names the four rules that every one of the five renderers follows.

After you read it, you know where to put a value that the screen shows, why a write to /context throws, and why a draft in a form survives one event and not another.


What problem a view spec solves

An application without a machine decides the screen inside its components: if (user && !loading) return <Dashboard />. Each such condition is a second copy of a decision, and the copies disagree when one of them changes.

The machine knows the state already. XMachines therefore puts the view ON the state, as data. The component tree holds no condition that selects a screen, and the machine holds no import of a framework.

The data is a spec of @xmachines/json-render-core: a flat map of elements, each with a component type, props and children. One spec renders in React, in Vue, in SolidJS, in Svelte and in the plain DOM, because each renderer reads the same grammar.


The three parts of a view

PartWhere it livesWhat it says
catalogdefineCatalog(schema, …), one for each appthe component types and the actions that exist
specmeta.view of a state nodeWHICH components show in that state, with which data
registrydefineRegistry(catalog, …), one per rendererHOW each component renders, and what each action does

The catalog is the vocabulary. It holds Zod schemas and no framework code. The spec uses the vocabulary. The registry binds each word to a component of one framework and to a handler of each action.

A machine and its specs therefore change never when you change the framework. You write a second registry.


Declaring a view

A meta.view is a ViewMeta. root names the first element, and elements holds every element by its key:

import { setup } from "xstate";
import { typedSpec } from "@xmachines/play-view";
const machine = setup({}).createMachine({
id: "app",
initial: "home",
states: {
home: {
meta: {
view: typedSpec({
root: "page",
elements: {
page: { type: "Page", props: { title: "Welcome" }, children: ["intro"] },
intro: { type: "Text", props: { text: "Choose a task." }, children: [] },
},
}),
},
},
},
});

XState types meta as Record<string, unknown>, so TypeScript checks nothing that you write there. typedSpec of @xmachines/play-view gives the literal the type ViewMeta where you write it, and it does nothing at run time. The check of an excess property works on an inline literal only. For a spec in a variable, write satisfies ViewMeta at the literal.

A ViewMeta is what you WRITE. A PlaySpec is what the actor DERIVES from it, and what a renderer receives. The derivation consumes outlets, it adds the machine context, and it stamps viewKey and viewParts.

The view is an OPTIONAL capability of the actor. Compose it with withView of @xmachines/play-xstate, from its view subpath:

import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";
import { withView } from "@xmachines/play-xstate/view";
const actor = definePlayer({ machine, actor: compose(PlayerActor, withView) })();
actor.start();

The actor then holds currentView, a ReadonlyAtom<PlaySpec | null>. An application that renders no view composes no withView, and it installs no json-render.


Which state supplies the view

The deepest active state that declares a view supplies it, unless a parent composes it. A state that declares no view shows the view of its nearest ancestor that declares one. An ancestor view without outlets does not show, because the view of its descendant replaces it:

states: {
dashboard: {
meta: { view: dashboardView }, // shows in dashboard.overview
initial: "overview",
states: {
overview: {},
settings: { meta: { view: settingsView } }, // replaces dashboardView
},
},
},

When no active state declares a view, currentView holds null, and the renderer shows its fallback content.

A parallel state follows the same region as the route derivation: the first region whose active states declare a meta.route, or the first region when no region does. The view on the screen therefore belongs to the region of the URL. When that region holds no view, the view comes from another active region, so one region can own the URL and another region can own the view. Keep the routes in one region: the State Machines guide shows what a second routed region does to the URL.

A layout and its pages: outlets

A parent view that declares outlets shows itself AND the view of its active child. Each outlet names an element of the parent view, and the view of the child goes into that element:

const shell = typedSpec({
root: "shell",
state: { collapsed: false },
elements: {
shell: { type: "Shell", props: {}, children: ["title"] },
title: { type: "Heading", props: { text: "App" }, children: [] },
},
outlets: { "*": { element: "shell" } },
});
states: {
app: {
meta: { view: shell },
initial: "home",
states: {
home: { meta: { route: "/", view: homeView } },
search: { meta: { route: "/search", view: searchView } },
},
},
},
  • The key of an outlet is the key of a child in states, or "*" for each active child that has no outlet of its own. A child with neither stays off the screen.
  • A compound state fills one outlet with its active child. A parallel state fills one outlet for each region, in the order of states.
  • slot names a named slot of the element. Omit it, and the renderer appends the child view to the children of the element.
  • The view of a child is the deepest view below it. A child view can declare outlets of its own, for a nested layout.
  • An outlet that names an element that its view does not hold throws InvalidViewOutletError. The actor reports it through onError, and it keeps the last valid view.

The derived spec is ONE flat spec. The keys of the outer view stay as you wrote them. Each key of a child view gets the id of its state as a prefix: the root element of the view of the state app.search becomes app.search/root. Two views therefore never collide. The State Machines guide shows a parallel layout with a named slot.

The view key

The derivation stamps the identity of the view on the spec, as viewKey. For a view without outlets, it is the id of the state that declares the view. For a composed view, it is the id of the outermost state that declares outlets, and viewParts lists the id of each composed state. The renderer section tells why the key matters. Never write viewKey or viewParts yourself: typedSpec refuses both, and the derivation overwrites them.


Reading the machine context

Each derived view carries the whole machine context in its state, under the key context:

derived state = { ...meta.view.state, context: <the machine context> }

A spec reads the context through the ordinary state grammar of json-render. The same path works in a prop, in a visible condition, and in repeat.statePath:

elements: {
greeting: { type: "Text", props: { text: { $state: "/context/username" } }, children: [] },
tasks: {
type: "List",
props: {},
repeat: { statePath: "/context/tasks", key: "id" },
children: ["task"],
},
task: { type: "Text", props: { text: { $item: "title" } }, children: [] },
},

The routing capability writes the URL into the context: the params go to context.params, and the query goes to context.query. A spec therefore reads /context/params/userId for a value of the URL and /context/username for a value of the machine. The path tells the origin of each value, and a param can hide no field of the machine.

The /context subtree is read-only to the spec. A $bindState write or a setState write under /context throws ReadOnlyContextError, and the message names the path. The context changes through a machine event only. A handler that writes the whole snapshot back, with the context key unchanged, does not throw: the store ignores a write of an identical value.

context is a reserved key. When meta.view.state declares a top-level context key of its own, the derivation skips the projection for that view, the authored value wins, and a warning in development names the conflict. Rename the field.

CAUTION: the projection shows the WHOLE context to the client. A debug panel, an inspector and a validator read the view store, and each one is a client-side tool. Keep a secret out of the context.

Validate the DERIVED view, actor.currentView.get(), and not the raw meta.view. The state of the derived view describes the store as it is, so a tool such as validateSpec sees a spec that agrees with itself.


Two kinds of state

A view store holds two kinds of state, at two places, with two owners.

KindWhere in the storeWritten byLives for
ephemeral statethe root: /usernamespec.state seeds it; $bindState and setState change itone viewKey
domain state/context/…the machine, through an eventthe actor

Ephemeral state is the state of the screen: a draft in a field, an open panel, a selected tab. The machine does not need it until the user commits it.

Domain state is the state of the application. Each change of it is an event, so the inspector shows each change with its cause.

This login form uses both. The field binds to /username, and the submit sends the value to the machine:

view: typedSpec({
root: "form",
state: { username: "" },
elements: {
form: {
type: "Login",
props: { username: { $bindState: "/username" } },
children: [],
on: {
submit: { action: "login", params: { username: { $state: "/username" } } },
},
},
},
}),

Keep high-frequency data in the ephemeral state or in a child actor, and not in the context. Each change of a top-level field of the context emits the view again. A keystroke is not a fact of the domain.


How an interaction becomes an event

A component emits a named event of json-render, such as submit or press. The on field of its element maps that name to an ACTION, with params that the renderer resolves from the store. The registry holds the handler of each action, and a handler of a domain action sends a machine event:

const registryResult = defineRegistry(catalog, {
components: { Login },
actions: {
login: async ({ username }) => actor.send({ type: "auth.login", username }),
},
});

The guards of the machine then decide. The renderer changes no state of the machine, and it selects no view. It sends, and it shows what the actor derives next.

The renderer implements four built-in actions over the ephemeral state: setState, pushState, removeState and validateForm. They need no handler in the registry. An on entry can hold an array of bindings, and the renderer runs them in order. For example, it sends the event, and then it clears the draft:

on: {
press: [
{ action: "addTask", params: { title: { $state: "/newTaskTitle" } } },
{ action: "setState", params: { statePath: "/newTaskTitle", value: "" } },
],
},

In an action param, { $item: "id" } resolves to the JSON pointer of the item field, such as /context/tasks/0/id, and not to its value. The built-ins need the pointer to address the entry. A handler that needs the value reads it from the state that it receives as its third argument.


When the renderer keeps the UI

A renderer holds one view store for each view, and the viewKey decides its lifetime.

The emissionWhat the renderer does
a new viewKeyit seeds a NEW store from spec.state
the same viewKeyit replaces /context in the live store, and it keeps the rest
the same viewKey, new viewPartsas above, and it writes each authored key that the store lacks
no viewKey, from a hand-built actorit seeds a new store for each emission
a different actorit discards the store with the actor
nullit shows the fallback, and it resolves no store

With the same viewKey, every renderer keeps the DOM nodes, the focus and the ephemeral state. A context change therefore updates a list under a field that has the focus, and the draft in the field stays.

With a new viewKey, the ephemeral state starts again from spec.state. Two sibling states that each declare a view therefore reset a draft on each transition between them, also when their two specs are equal.

A parent keeps the ephemeral state across a move between its children in one of two ways:

  • Declare the view on the parent, and no view on the children. The viewKey stays the id of the parent.
  • Declare outlets on the parent. The viewKey stays the id of the parent, and each child still shows its own view. A change of the page keeps the store of the layout, so the collapsed value of the layout above does not change.

In a composed view, all the views of one layout share ONE store. The first time that a page shows, the renderer writes each key of its authored state that the store does not hold. A page that comes back finds the values that it left. Two pages that declare the same key therefore share one value: give each page its own keys. When two composed views declare the same key, the outer view wins, and a warning names the key.

currentView also emits only on a real change of the view on the screen. An event that changes no context field and moves to no other view keeps the previous reference, and no subscriber runs. The gate compares the top-level fields of the context. It does not enter a value, so a new object in a field emits again, also when its contents are equal.

In the controlled mode, you give the provider a store. The provider then ignores spec.state, and you own the seed and the lifetime of that store. The renderer still owns /context, and it refreshes the subtree in your store on each emission.


When a view fails

A failure of the DERIVATION stays inside the actor. currentView catches it, it keeps the last valid view, and it reports the error to the onError hook of the actor in a microtask. The transition continues for every other observer.

A failure of the RENDER stays inside the provider. When a component throws, or the store does not resolve, the provider shows its fallback and it calls its own onError prop:

RendererThe onError prop of the provider
@xmachines/play-react(error, info, reset) => void
the four other renderers(error, reset) => void

The provider reports one failure one time for one set of inputs — the actor, the view and the store — and not one time for each render. A new emission, or a call of reset, makes the next failure report again. Without onError, the provider writes the failure to console.error, so that no failure is silent. A handler that throws does not break the containment: the provider writes that failure to console.error too.

onRenderError receives the failure of one catalog component, with the name of the component, and it replaces every handler of defineRegistry.


The view invariants

1. The machine selects the view. The renderer never does

The renderer reads currentView and shows it. No component decides which screen shows. A condition that selects a screen belongs in a state and its guards.

2. /context is read-only to the spec

A spec reads the machine context, and it writes it never. The store throws ReadOnlyContextError for a write, and the machine changes the context through an event. The renderer refreshes the subtree through a store below the guard, so the rule binds the spec and not the machinery.

3. The viewKey decides the life of the ephemeral state

The same key keeps the store, and a new key seeds a new one. Put a view, or a layout with outlets, on the state whose lifetime the ephemeral state must have.

4. A renderer reads currentView and send, and nothing else

A provider takes a ViewActor: the actor contract and the Viewable capability. It names no engine and no router. The five renderers read no router package, and the routers read no view package. The two halves meet in the application only.


Which renderer to use

RendererMount
@xmachines/play-react<PlayUIProvider> around <PlayRenderer />
@xmachines/play-vue<PlayUIProvider> around <PlayRenderer />
@xmachines/play-solid<PlayUIProvider> around <PlayRenderer />
@xmachines/play-svelte<PlayUIProvider> around <PlayRenderer />
@xmachines/play-domcreatePlayUI(registryResult)(actor, element)

Each one exports schema for defineCatalog, and defineRegistry for its own components. PlayUIProvider takes the actor, the registryResult, and the optional store, fallback, onError and onRenderError. The Getting Started guide shows a complete mount for each framework.

@xmachines/play-view holds the part that the five share: the store lifecycle, the /context guard and the report rules. A shared contract suite runs the same scenarios against each renderer, so the five agree on what a viewer sees.


Summary

QuestionAnswer
Where does a view come from?meta.view of the deepest active state that declares one
How does a layout show its page?The layout declares outlets, and the page view goes into one
What shows when no state declares a view?The fallback of the provider
How does a spec read the machine context?{ $state: "/context/…" }, in a prop, a condition or a repeat
Can a spec write the context?No. Send the actor an event
Where does a draft go?In spec.state, at the root of the store, with $bindState
When does a draft reset?When the viewKey changes: the outermost state of the view changes
Which side selects the screen?The machine, in every renderer
Who reports a failure of a render?The provider, one time, through its onError

See also