Since xstate-tree is designed around a hierarchical tree of machines, routing can't function similar to how react-router works
Instead, routing is based around the construction of route objects representing specific urls. Route objects can be composed together to create hierarchies, designed to mimic the xstate-tree machine hierarchy, but not required to match the actual hierarchy of machines
First you must build a createRoute
function, this can be done by calling buildCreateRoute
exported from xstate-tree. buildCreateRoute
takes two arguments, a history object and a basePath. These arguments are then stapled to any routes created by the returned createRoute
function from buildCreateRoute
so the routes can make use of them. These arguments must match the history and basePath arguments provided to buildRootComponent
To construct a Route object you can use the route
function or the simpleRoute
function. Both are "curried" functions, meaning they return a function when called which requires more arguments. The argument to both is an optional parent route, the argument to the second function is the route options object.
A route gives you full control over the matching and reversing behavior of a route. It's up to you to supply a matcher
and reverser
function which control this. The matcher function is supplied the url to match as well as the query string parsed into an object. It then returns either false to indicate no match, or an object containing the extracted params/query data as well as a matchLength
property which indicates how much of the URL was consumed by this matcher. The passed in URL will always be normalized to start with /
and end with /
The match length is required because matching nested routes start from the highest parent route and matches from parent -> child until either a route doesn't match, or the entire URL is consumed, the route does not match if there is any URL left unconsumed. This also means that routes can't attempt to match the full URL if they are a parent route, ie no using regexes with $
anchors. If matching the URL with a regex the matchLength
will be match[0].length
where match
is the result of regex.exec(url)
The reverser
function is supplied an object containing params
if the route defines them, and query
if the route defines them. query
can be undefined even if the route provides them because they are only passed to the reverser function for the actual route being reversed, not for any parent routes. The reverser function returns a url representing the given params/query combination.
The other arguments are the event, paramsSchema, querySchema and meta type.
Simple route is built on top of route
, for when you aren't interested in full control of the matcher and reverser functions. It takes the same arguments as route
, without matcher
/reverser
and with an additional url
. The url
is a string parsed by path-to-regexp to generate the matcher
/reverser
functions automatically. Simple routes can be composed with normal routes.
Examples
// In practice you would always use a simpleRoute for this, this is just to show how `route` works
const parentRoute = createRoute.route()({
event: "GO_FOO",
matcher: (url) => {
if (url === "/foo/") {
return {
matchLength: 5,
};
}
return false;
},
reverser: () => "/foo/",
});
const childRoute = createRoute.simpleRoute(parentRoute)({
url: "/bar/:barId",
event: "GO_BAR",
paramsSchema: Z.object({
barId: Z.string()
})
});
const routeWithMeta = createRoute.simpleRoute()({
url: "/whatever",
event: "GO_WHATEVER",
meta: {} as { metaField: string }
});
The parent route does not extend another route so the first function call takes no arguments, it does not define any params as part of the URL or consume any query string arguments so it does not require any arguments
The child route extends the parent route, adding it as an argument to the first function call, and defines params as part of the URL so has a params schema defined with Zod
Since the child route composes with the parent route the resulting URL that it will match against is actually /foo/bar/123. If the parent route had defined a params schema or a meta type, those would also have been composed with the the routes params schema/meta type
Routes (both route and simpleRoute) can define an async redirect function. This function is called whenever a route is matched, for all routes in the routing chain. The function is called with the params/query/meta object that the route was originally matched with and you can return a new set of params and/or query objects to perform a redirect. If you return undefined
no redirect will be performed. You may also navigate to a different route inside this function.
The results of calling the redirect functions is merged with the original params/query/meta objects, from top to bottom (so if two routes override the same param, the one from the parent route will be overwritten).
If the URL is updated while the async redirect function is running then the redirect will be aborted and the redirect will be ignored. An AbortSignal is passed to the redirect functions to enable hooking this into any async processes you may be running.
const parentRoute = createRoute.simpleRoute()({
url: "/foo/:bar",
event: "GO_FOO",
paramsSchema: Z.object({
bar: Z.string()
}),
redirect: async ({ params }) => {
if (params.bar === "123") {
return {
params: {
bar: "456"
}
};
}
}
});
const childRoute = createRoute.simpleRoute(parentRoute)({
url: "/baz/:qux",
event: "GO_BAR",
paramsSchema: Z.object({
qux: Z.string()
}),
redirect: async ({ params }) => {
if (params.qux === "789") {
return {
params: {
bar: "123",
qux: "012"
}
};
}
}
});
So if the URL is /foo/123/baz/789, the redirect functions will be called in the following order:
- parentRoute with { params: { bar: "123" } }
- childRoute with { params: { qux: "789" } }
Since parentRoute returns a redirect to { bar: "456" } but the child route returns a redirect to { bar: "123", qux: "012" } the final params will be { bar: "123", qux: "012" } because the child route overrode the parent route's redirect
When you call history.pushState you can also supply "state" data, this is stored in the history stack. When a popstate event is fired it contains the "state" of that history entry that was stored with pushState, but this state is not actually part of the URL. It's just additional data we can attach to a history entry
What this allows for is attaching "enriched" data to a routing event that isn't required for the route to function, since it won't be present during the initial page load, but can be used by handlers of the route to access extra data for some purpose.
The meta field on a routing event (if it has a meta type defined) is optional, but will be defined if the route event was broadcast with meta (ie if you attached meta to a Link route) or from a popstate event where it extracts the state associated with the history and attaches it to the meta property of the event
This is used in browse-data when opening a datasheet to pass the dataset the sheet is opening along with the routing event so it doesn't require the datasheet to load the dataset again. On the initial page load where meta won't be set for that route the dataset sheet fetches the dataset from the API
Because the meta information is attached from popstate events it means that if the user closes the datasheet and then presses the back button, opening the datasheet again, the dataset is already loaded from the history state instead of having to fetch it from the API again.
The two most common usages of routes are using it with the Link
component or creating a navigation function with useRouteNavigator
The Link
component accepts a Route in the to
prop and then requires query/params/meta props as per that route. It renders an a
tag pointing at the full URL of the route (relative to the configured base path). Any props an a
tag accepts can be used, barring onClick
and href
useRouteNavigator
takes a Route as the only argument and returns a function that can be called with params/query/meta objects and navigates to the URL for the route when called
There are a couple other functions on them like reverse
, navigate
, getEvent
and matches
but those are primarily for internal use
When the page loads or when the url is updated xstate-tree takes the URL and query string (if it exists) and iterates through the list of routes it knows about (how it knows we will get to after this) looking for a route that matches the current URL
- Collect all of the routes parent routes into an array
- Iterates through that array generating events from those routes based on the current URL params/query
- broadcasts the events for those routes in reverse order, ie the topmost route -> its child -> its child -> the route that was matched
- Stores the routing events that were just broadcast
- When a new child machine is invoked it gets sent every routing event (in the same order) that it has a handler for
It is done this way so that you don't need to have handlers at every layer of the machine tree handling every routing event that a sub machine might route to.
How this works in practice is like so, given the following routes
const topRoute = createRoute.simpleRoute()({ url: "/foo", event: "GO_FOO" });
const middleRoute = createRoute.staticRoute(topRoute)({ url: "/bar", event: "GO_BAR" });
const bottomRoute = createRoute.staticRoute(middleRoute)({ url: "/qux", event: "GO_QUX" });
if you were to load up the URL /foo/bar/qux
which is matched by the bottomRoute
the following happens
broadcast GO_FOO broadcast GO_BAR broadcast GO_QUX
Assuming you have a hierarchy of four machines, with the root invoking the top, which invokes the middle which invokes the bottom, you design it as so
root machine -> GO_FOO -> invokes top machine
top machine -> GO_BAR -> invokes middle machine
middle machine -> GO_QUX -> invokes bottom machine
That way the root machine doesn't need to have a handler for all three of GO_FOO/GO_BAR/GO_QUX events causing it to invoke the top machine
If you were already on the /foo/bar/qux
url and navigated to the /foo/bar
url then GO_FOO and GO_BAR would be broadcast
If it does not find a matching route, either because no routes matched, or because the matching route threw an error parsing the query/params schema it logs an error message currently
404 and routing "errors" don't currently have any way to handle them, this will be worked on when it is needed (soon?)
buildRootComponent
takes a 2nd optional routing configuration object. This object requires you to specify an array of routes (routes are matched in the order they are in the array), a history object, and a basePath.
The routes should be fairly self explanatory, export a routes array from the routes definition file and ensure the routes are in the right order for matching.
The history object must be a shared history object for the project, in Matais case that is the ~matai-history
import. It is important that the same history object is used in Matai as it is also used in React router
The basePath is prepended to route links and stripped from routing events when they come in. This should be set to the URL that the root machine is going to be rendered at. In Matai React router will handle navigation to that URL and any sub URLs are handled by xstate-tree routing
There are two optional arguments that won't be needed in Matai but will be needed in Rimu, getPathName
and getQueryString
which are functions that return pathname/query string respectively.
These by default return window.location.pathname and window.location.search which is fine for Matai but won't work in Rimu
const home = createRoute.simpleRoute()({ url: "/", event: "GO_HOME" });
const products = createRoute.simpleRoute()({ url: "/products", event: "GO_PRODUCTS" });
const product = createRoute.simpleRoute(products)({
url: "/:productId(\\d+)",
event: "GO_PRODUCT",
paramsSchema: Z.object({
// All params come in as strings, but this actually represents a number so transform it into one
productId: Z.string().transform((id) => parseInt(id, 10))
})
});
// Routes only match exact URLs, so '/' won't match '/products', unlike react-router
const routes = [home, products, product];
const machine = createMachine({
initial: "home",
on: {
GO_HOME: "home",
GO_PRODUCTS: "products",
},
states: {
home: {},
products: {
on: {
GO_PRODUCTS: ".product"
},
initial: "index",
states: {
index: {},
product: {}
}
}
}
});
const App = buildXstateTreeMachine(machine, {
selectors: ...,
actions: ...,
view: ...,
});
const history = createBrowserHistory();
export const Root = buildRootComponent(App, {
routes,
history,
basePath: "/"
});