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/aliceis the stateprofile, andaliceis a param of it. - A state must become a URL. The state
profilewith{ 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:
| Form | Name | Matches |
|---|---|---|
/users | a literal | that path, and nothing else |
:name | a param | one path segment, reported to the machine as name |
:name(\d+) | a constraint | one segment that the regular expression accepts |
(\d+) | anonymous | one segment, reported under a NUMBER and not a name |
* | a wildcard | every remaining character, reported under a number |
{…} | a group | the forms inside it, as one unit that a modifier can carry |
\: | an escape | the next character, as a literal |
A modifier follows a param, a wildcard or a group:
| Modifier | Meaning | A path that fills it not |
|---|---|---|
| none | exactly one | does not match |
? | zero or one | matches |
+ | one or more | does not match |
* | zero or more | matches |
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.
| Form | URL to state | state to URL |
|---|---|---|
a literal, :name, :name? | yes | yes |
:name* | yes | yes — the value keeps each / that it carries |
:name+ | yes | yes — the context must hold a value |
{…} | yes | yes — an optional group with no value goes away |
:name(\d+) | yes | yes — the build drops the constraint, and it tests nothing |
*, (\d+), an anonymous param | yes | no — the param has no name, so no value can fill it |
:cat-id, a hyphen in a name | yes | yes |
an escape, \+, \(, \: | yes | yes 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?bAn 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 /booksThis 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.
| Direction | Who does the work |
|---|---|
| URL → state id | XMachines. RouteMap.getStateIdByPath runs the URLPattern match, in all nine adapters |
| actor route → URL | XMachines, and it touches URLPattern never: a map lookup, then a substitution of params |
| params | the 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:useEffectcleanup in React,onUnmountedin Vue,onCleanupin Solid. - A second
connect()on an actor that already has a bridge throwsDuplicateBridgeError. 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.
RouteMapsplits at construction: a literal path goes into an exact-matchMap, and only a pattern compiles aURLPattern. 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
| Question | Answer |
|---|---|
| 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
- Understanding State Machines — how
meta.routereaches the route map - Understanding Atoms — the same decision, made for reactivity
- Multi-router integration — the provider pattern and
basePath, end to end - Routing patterns — worked route maps
- @xmachines/play-router — API reference
- URLPattern on MDN — the standard