Skip to content

Routing in XMachines

A route of XMachines is a fact about a STATE, and not a fact about a component tree. A state node declares meta.route, and the library carries that declaration in both directions: a URL becomes a state, and a state becomes a URL.

This guide states the pattern language one time, it tells when a navigation re-enters a state, and it names the three rules that every one of the nine router adapters follows.

After you read it, you know which patterns a route can declare, which side resolves a location, and why a bridge comes in a connect() and disconnect() pair.


What problem URLPattern solves

A route pattern has to say two different things.

  • A URL must become a state id. /profile/alice is the state profile, and alice is a param of it.
  • A state must become a URL. The state profile with { userId: "alice" } is the path /profile/alice.

The first direction needs a matcher, and every router writes its own. Express uses path-to-regexp, svelte-spa-router uses regexparam, and Vue Router, React Router, SolidJS Router, TanStack Router and SvelteKit each wrote a parser of their own. A library that spans nine of them cannot adopt one of those parsers, because each one carries a different set of features, and a machine that declares a pattern one router refuses is a machine that runs on eight of the nine.

URLPattern is the WHATWG standard for this, its syntax comes from path-to-regexp, and it is a superset of it. XMachines takes it as the pattern language, and it reads the whole of it.

@xmachines/play-atom makes the same decision for reactivity: one primitive, isolated behind one module, present on every runtime. The Atoms guide describes it.

The API is always present

@xmachines/play-url carries urlpattern-polyfill as an ordinary dependency. It uses the native URLPattern when the runtime has one, and the polyfill when it does not. Install nothing, and load nothing.

The native API arrived in Chrome 95, in Firefox 142 and in Safari 26. The browser floor of XMachines is Chrome 110, Firefox 115 and Safari 16.4. Chrome carries the API at that floor, and Firefox and Safari carry it far above it, so a Firefox or a Safari at the floor runs the polyfill. The test suite of @xmachines/play-url measures the two implementations against each other, form by form. They agree on every form of the grammar, and they disagree about one shape that no bridge can produce — see the prefix rule below.

The cost is 18 kB, minified and with no dependency of its own, and a consumer whose runtime has the native API pays it too. A conditional load would save those bytes and it would make the module asynchronous: RouteMap compiles in its constructor, and a navigation matches inside an event handler. Neither can await.


The pattern grammar

A meta.route path is a URLPattern pathname pattern. These are the forms:

FormNameMatches
/usersa literalthat path, and nothing else
:namea paramone path segment, reported to the machine as name
:name(\d+)a constraintone segment that the regular expression accepts
(\d+)anonymousone segment, reported under a NUMBER and not a name
*a wildcardevery remaining character, reported under a number
{…}a groupthe forms inside it, as one unit that a modifier can carry
\:an escapethe next character, as a literal

A modifier follows a param, a wildcard or a group:

ModifierMeaningA path that fills it not
noneexactly onedoes not match
?zero or onematches
+one or moredoes not match
*zero or morematches
import { createMachine } from "xstate";
const machine = createMachine({
id: "app",
initial: "home",
states: {
home: { meta: { route: "/" } },
profile: { meta: { route: "/profile/:userId" } },
settings: { meta: { route: "/settings/:section?" } },
},
});

Which forms work in which direction

Both directions read ONE parse. parsePattern of @xmachines/play-pattern answers the match, and buildPath of the same package walks the same parts back into a path. @xmachines/play-url re-exports both, so the match reads them from there. A form that the route map matches therefore derives a URL, with the exceptions that the table names.

FormURL to statestate to URL
a literal, :name, :name?yesyes
:name*yesyes — the value keeps each / that it carries
:name+yesyes — the context must hold a value
{…}yesyes — an optional group with no value goes away
:name(\d+)yesyes — the build drops the constraint, and it tests nothing
*, (\d+), an anonymous paramyesno — the param has no name, so no value can fill it
:cat-id, a hyphen in a nameyesyes
an escape, \+, \(, \:yesyes for \+ and \( — \: derives a path the bridge refuses

buildRouteUrl of @xmachines/play-xstate performs the outbound half. It throws MissingRouteParamError when a part needs a value that the context has not, and deriveCurrentRoute answers null for that error — so the address bar waits for the next snapshot that carries the value.

It throws InvalidRouteParamError when a param carries a dot segment: ., .., or any percent-encoding of the two. A URL parser resolves such a segment away BEFORE it decodes the escapes, so the derived path is one that the route matches never. deriveCurrentRoute passes that error on, because the value corrects itself never: the derivation of currentRoute reports it through onError of the actor and keeps the last route, and initialRoute throws from the constructor. Check a param value that carries a name from a user before the machine puts it in the context.

An anonymous param is the one form that derives no URL. URLPattern numbers such a group from 0, and this library reports that name to no machine, so a machine can put no value under it. A route of this shape is correct for a state that only RECEIVES a deep link, such as a catch-all. Give the param a name when the machine navigates to it.

A constraint describes a value, and the build tests no value against it. The derived path carries the value alone — /page/:number(\d+) with { number: "12" } gives /page/12 — so a value that the constraint refuses derives a path that the route matches never. The machine owns the value.

A param that carries * or + matches across a separator, so its value holds a path and not one segment. The build encodes each segment of such a value and it keeps the / between them: /files/:path* with { path: "a/b" } gives /files/a/b.

The build resolves every escape, so the derived path carries the character and not the backslash that protects it. /tags/c\+\+ derives /tags/c++, which is the path that the route matches.

A literal that the grammar would read

:, +, ?, ( and { are characters of the grammar, so a route that writes one of them raw does not mean the literal. The two halves fail differently.

URLPattern refuses :, + and ?: /tags/c++ reads + as a modifier of the literal before it, and a modifier needs a part; /time/10:30 reads : as the start of a param name, and 30 is no name. A route map throws InvalidRoutePatternError for each of them, and the error names the pattern.

URLPattern accepts ( and {, and it reads them as grammar: /docs/rfc(2119) is the literal /docs/rfc and an anonymous param that the expression 2119 constrains, so it matches /docs/rfc2119; /i18n/{en} is a group, so it matches /i18n/en. Neither route reports a fault, and neither one matches the path that its author wrote.

Escape the character when you mean it literally. A : and a ? are the two exceptions, and the paragraphs below give the form to write for each.

/tags/c\+\+ matches /tags/c++
/docs/rfc\(2119\) matches /docs/rfc(2119)
/time/10%3A30 matches /time/10:30
/a%3Fb matches /a?b

An escaped : matches, and it reaches no address bar. The build resolves the escape, so /time/10\:30 derives /time/10:30, which is the path that the route matches — but a bridge refuses a concrete path that holds a :, because that character marks a param that nothing substituted. Write %3A instead: /time/10%3A30 is an ordinary literal path, and both halves read it end to end.

A ? has no literal form, and the escape does not give it one. A URL pathname ends at its first ?, so getStateIdByPath cuts every location there. The route /a\?b compiles with no fault, and no location reaches it. Write %3F instead: /a%3Fb is an ordinary literal path, and it resolves.

This rule is about the PATTERN that a state declares. A param VALUE needs no escape, because buildRouteUrl percent-encodes each value: /search/:q with { q: "a+b" } becomes the location /search/a%2Bb, and extractRouteParams reads a+b back from it.

encodeURIComponent leaves * as it stands, and that is the one exception. A bridge refuses a path that still holds a : or a *, because such a character marks a param that nothing substituted. A value of a*b therefore stops the push, and the URL stays where it is.

A route that carries a QUERY STRING is the same case as a raw ?, and it is the form a machine writes most often. The grammar reads the ? of meta: { route: "/dates?trip=one-way" } as a modifier that follows no part, so RouteMap throws InvalidRoutePatternError in its constructor and the application starts never. A route declares a PATHNAME: put the default in context.query, which the generated play.route transition assigns and buildRouteUrl writes to the URL.

The prefix rule

A / directly before a param belongs TO that param. A / directly before a group does not.

/settings/:section? matches /settings — the modifier removed the separator too
/a/:b?/c matches /a/c
/a/{b}?/c matches /a//c, and NOT /a/c
/books{/:id}? matches /books
/books/{:id}? matches /books/, and NOT /books

This one rule decides every optional route in this library. Write {/:id}? and not /{:id}? when you want the bare path to work.

A bare path of a doubled slash is unreachable. sanitizePathname joins every run of separators into one, and every bridge calls it before the match, so the location /a//c arrives as /a/c — which /a/{b}?/c matches never. Put the separator INSIDE the group, and the bare path is an ordinary one.

The two URLPattern implementations also disagree about a path of a doubled slash, and about nothing else: the native API matches /{/v2} against //v2, and urlpattern-polyfill does not. The collapse above is what keeps that difference away from your routes.

The one divergence from URLPattern

A param name of URLPattern is a JavaScript identifier, and it carries no hyphen: /docs/:cat-id is the param cat followed by the literal -id for the standard.

XMachines reads cat-id as one name. It compiles the pattern as :cat_id and it maps the group back, so the machine receives { "cat-id": "intro" }. A route names its params the way the application does.

The cost is that you cannot write URLPattern’s own reading of :cat-id. Use :cat followed by a separate literal segment when you need it.


Re-entry on a navigation

formatPlayRouteTransitions reads the reenter flag of each transition from meta.route. The default is false, which is the default of XState.

The flag decides one thing: whether the DOMAIN of the transition runs its exit and entry actions again. Under the default handler: "root" the domain is the root of the machine. "local" and "both" put a transition on the parent, and while the parent is active that transition answers the event, so the domain is the parent.

Each state below the domain on the path to the target runs its exit and entry actions on each navigation, whatever the flag says. The target itself is one of them, unless the route targets the root of the machine: the root is then the domain, and the flag decides. Under "root", false therefore spares the root alone, and each ancestor between the root and the target runs its actions again. handler is the field that spares those intermediate ancestors.

CAUTION: a parallel state loses the position of its other regions when it is the domain or a state below the domain. XState exits each active state below the domain, and not only the states on the path to the target. Each other region therefore enters its initial state again. A parallel root under handler: "root" is such a domain. With reenter: true, a "local" or "both" transition also exits the parent. Where the parent is a region of a parallel state, the other regions of that state enter their initial state again too.

A state whose navigation must re-enter the domain too declares the flag for itself. Here the root runs its exit and entry actions on each navigation to dashboard:

states: {
dashboard: { meta: { route: { path: "/dashboard", reenter: true } } },
},

CAUTION: "local" also SCOPES the route. The generated transition then sits on the parent alone, so a play.route event that arrives while the parent is inactive matches nothing and the actor does not move — a deep link or a press on BACK therefore leaves the URL and the actor divergent. Choose "local" where that scope is the intent, such as a step that a person reaches only inside its wizard. Choose "both" where the state must stay reachable from everywhere.


The routing invariants

1. XMachines resolves the state id. The router never does

The router is a source of locations and a sink for navigations. It resolves a state id never, because a state id is a concept of the machine that the router knows nothing about.

DirectionWho does the work
URL → state idXMachines. RouteMap.getStateIdByPath runs the URLPattern match, in all nine adapters
actor route → URLXMachines, and it touches URLPattern never: a map lookup, then a substitution of params
paramsthe URLPattern extraction, except where the framework already parsed them

That follows from the architecture: a host registers ONE catch-all route — /:pathMatch(.*)* in the Vue demo — so the framework matches nothing that belongs to the machine.

Vue Router and SolidJS Router parse their own params, and their bridges keep that parse when it covers every required name of the pattern. That is an optimization, and never a second source of truth: a bridge under a mount ignores the framework entirely, because the framework matched a route of the HOST there and a name that collides carries the value of the host.

2. A basePath divides the two sides

A machine can own the whole router, or it can sit under a mount. basePath is the prefix that belongs to the host, and the machine owns the suffix alone. Every path that reaches the machine has the prefix removed already, and every path that the machine pushes gets it back.

3. connect() and disconnect() come in a pair, and one actor takes one bridge

A bridge installs an atom subscription on the actor and a subscription on the router. Neither releases itself.

  • Call disconnect() from the teardown of your component: useEffect cleanup in React, onUnmounted in Vue, onCleanup in Solid.
  • A second connect() on an actor that already has a bridge throws DuplicateBridgeError. The actor takes one bridge, so that two bridges cannot drive it in opposite directions.
  • connect() gives all or nothing. It installs the watcher and the subscription BEFORE the first synchronization, because that synchronization drives both directions — and when the synchronization throws, connect() removes everything it installed and frees the actor again.

Which patterns cost what

  • A static route costs nothing. RouteMap splits at construction: a literal path goes into an exact-match Map, and only a pattern compiles a URLPattern. A route map of literal paths alone reaches the API never.
  • A parameterized route pays one compilation in the constructor, and one match for each distinct path, behind an LRU cache.
  • A route that a framework already parsed pays neither, for as long as its parse covers every required name.

Why not a smaller matcher

A matcher that reads only a literal and :name covers most routes. It carries fewer features than the routers that XMachines supports, which is the problem that the first section describes.

One engine also keeps the match and the params in agreement. With two matchers, a route could resolve while its params came back empty.


Summary

QuestionAnswer
What is the pattern language?The URLPattern pathname grammar, plus hyphenated param names
Do I install a polyfill?No. @xmachines/play-url carries one
Which side turns a URL into a state?XMachines, in every adapter
Which side turns a state into a URL?XMachines, with no URLPattern at all
When does the framework parse the params?Vue Router and SolidJS Router, when the parse covers every name
Who releases the bridge?You, through disconnect() in the teardown of your component

See also