Skip to content

Understanding 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, 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.

This is the same decision that @xmachines/play-atom makes for reactivity: one primitive, isolated behind one module, present on every runtime. There is no AtomsUnavailableError, because one package holds the reactive primitive and every consumer reads it from there, and there is no URLPattern availability error for the same reason.

The API is always present

@xmachines/play-router 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 this workspace 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. test/url-pattern-parity.test.ts 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
const machine = createMachine({
id: "app",
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.

An escaped : is the one escape that stops at the bridge. The build derives /time/10:30 for /time/10\:30, and the route matches that path — but a bridge refuses to push a path that holds a :, because a : in a CONCRETE path marks a param that nothing substituted. resolveNavigationPath therefore answers null, and the address bar follows that state never. Write %3A instead, as the section below prescribes.

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.

This is a change of behaviour, and a route of yours can hold one of these characters today. An earlier release read a route as a pattern only when the route held a : or a *. /tags/c++, /docs/rfc(2119) and /i18n/{en} therefore went to the exact-match map, and each one matched the path that its author wrote. The route map reads the whole grammar now. /tags/c++ throws InvalidRoutePatternError in the constructor of RouteMap, and the other two compile and match another path in silence. Add the escape to each route that writes one of these characters raw.

A route that carries a QUERY STRING is the same case, and it is the form a machine writes most often. meta: { route: "/dates?trip=one-way" } reached the exact-match map before, and the bridge pushed it to the address bar with its query. The grammar reads that ? as a modifier that follows no part, so RouteMap throws InvalidRoutePatternError in its constructor now 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.

Upgrading from 3.x: the reenter default changed

An earlier release wrote reenter: true on every transition that formatPlayRouteTransitions generated, so every routed state re-entered its own domain on each navigation and ran its entry actions again. The flag now comes from meta.route, and the default is false, which is the default of XState.

Nothing throws, and the entry action simply stops running. A state that needs it declares the flag for itself:

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

reenter spares the DOMAIN of the transition and not every ancestor that stays active. Under the default handler: "root" the domain is the root of the machine, so false spares the root alone and each ancestor between the root and the target still runs its exit and entry actions. handler: "local" and handler: "both" move the domain down to the parent, which is the field that spares those intermediate ancestors.

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 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.


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 matcher of our own

The 2.2.0 review asked whether a hand-written segment matcher could drop the URLPattern requirement. The answer is no, and the reason is not the size of the code.

A pattern language has to carry the largest set of features that the nine target routers can express, and not the subset that this repository happens to use today. A count of the patterns HERE — 85 of 88 are a literal plus :name — measures our own habits, and it is the wrong basis for the decision. URLPattern is the most expressive engine available, and a matcher of our own would carry less.

Two matchers that disagree is also a worse failure than one dependency. The match and the extraction of the params would then read the same pattern differently, and a route would 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-router 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