A router designed for scale, performance and ergonomics. Tailored for usage with rescript-relay
. A modern router, targeting modern browsers and developer workflows using vite
.
- Nested layouts
- Render-as-you-fetch
- Preload data and code with full, zero effort type safety
- Granular preloading strategies and priorities - when in view, on intent, etc
- Fine grained control over rendering, suspense and error boundaries
- Full type safety
- First class query param support
- Scroll restoration
- Automatic code splitting
- Automatic release/cleanup of Relay queries no longer in use
The router requires the following:
-
vite
for your project setup,>2.8.0
. -
build: { target: "esnext" }
in yourvite.config.js
. -
"type": "module"
in yourpackage.json
, meaning you need to run in es modules mode. - Your Relay config named
relay.config.cjs
. - Preferably
yarn
for everything to work smoothly. rescript-relay@>=1.1.0
Install the router and initialize it:
# Install the package
yarn add rescript-relay-router
# Initiate the router itself
yarn rescript-relay-router init
# Run the first generate command manually. This will run automatically when everything is setup
yarn rescript-relay-router generate -scaffold-renderers
This will create all necessary assets to get started. Now, add the router to your bsconfig.json
:
"bs-dependencies": [
"@rescript/react",
"rescript-relay",
"rescript-relay-router"
]
Worth noting:
- The router relies on having a single folder where router assets are defined. This is
./src/routes
by default, but can be customized. Route JSON files and route renderes must be placed inside of this folder. - The router will generate code as you define your routes. By default, this code ends up in
./src/routes/__generated__
, but the location can be customized. This generated code is safe to check in to source control.
Now, add the router Vite plugin to your vite.config.js
:
import { rescriptRelayVitePlugin } from "rescript-relay-router";
export default defineConfig({
plugins: [rescriptRelayVitePlugin()],
});
Restart Vite. Vite will now watch and autogenerate the router from your route definitions (more on that below).
Let's set up the actual ReScript code. First, let's initiate our router:
// Router.res
let preparedAssetsMap = Js.Dict.empty()
// `cleanup` does not need to run on the client, but would clean up the router after you're done using it, like when doing SSR.
let (_cleanup, routerContext) = RelayRouter.Router.make(
// RouteDeclarations.res is autogenerated by the router
~routes=RouteDeclarations.make(
// prepareDisposeTimeout - How long is prepared data allowed to live without being used before it's
// potentially cleaned up? Default is 5 minutes.
~prepareDisposeTimeout=5 * 60 * 1000
),
// This is your Relay environment
~environment=RelayEnv.environment,
// SSR coming soon. For now, initiate a browser environment for the router
~routerEnvironment=RelayRouter.RouterEnvironment.makeBrowserEnvironment(),
~preloadAsset=RelayRouter.AssetPreloader.makeClientAssetPreloader(preparedAssetsMap),
)
Now we can take routerContext
and wrap our application with the router context provider:
<RelayRouter.Provider value={Router.routerContext}>
<React.Suspense fallback={React.string("Loading...")}>
<RescriptReactErrorBoundary fallback={_ => React.string("Error!")}>
<App />
</RescriptReactErrorBoundary>
</React.Suspense>
</RelayRouter.Provider>
Finally, we'll need to render <RelayRouter.RouteRenderer />
. You can render that wherever you want to render your routes. It's typically somewhere around the top level, although you might have shared things unaffected by the router that you want to wrap the route renderer with.
// App.res or similar
<RelayRouter.RouteRenderer
// This renders all the time, and when there's a pending navigation (pending via React concurrent mode), pending will be `true`
renderPending={pending => <div>{pending ? React.string("Loading...") : React.null}</div>}
/>
There, we're all set! Let's go into how routes are defined and rendered.
Routes are defined in JSON files. Route JSON files can include other route JSON files. This makes it easy to organize route definitions. Each route has a name, a path (including path params), query parameters if wanted, and child routes that are to be rendered inside of the route.
Route files are interpreted JSONC, which means you can add comments in them. Check the example below.
routes.json
is the entry file for all routes. Example routes.json
:
[
{
"name": "Organization",
"path": "/organization/:slug",
"children": [
// Look, a comment! This works fine because the underlying format is jsonc rather than plain JSON.
// Good to provide contextual information about the routes.
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?showActive=bool" }
]
},
{ "include": "adminRoutes.json" }
]
Route names must:
- Start with a capitalized letter
- Contain only letters, digits or underscores
Any routes put in another route's children
is nested. In the example above, this means that the Organization
route controls rendering of all of its child routes. This enables nested layouts, where layouts can stay rendered and untouched as URL changes in ways that does not affect them.
The router uses the matching logic from
react-router
under the hood.
Each defined route expects to have a route renderer defined, that instructs the router how to render the route. The router will automatically generate route renderer files for any route, that you can then just "fill in".
The route renderers needs to live inside of the defined routes folder, and the naming of them follow the pattern of <uniqueRouteName>_route_renderer.res
. <uniqueRouteName>
is the fully joined name
s from the route definition files that leads to the route. Each route renderer is also automatically code split without you needing to do anything in particular.
In the example above, the route renderer for Organization
would be Organization_route_renderer.res
. And for the Dashboard
route, it'd be Organization__Dashboard_route_renderer.res
.
A route renderer looks like this:
// This creates a lazy loaded version of the <OrganizationDashboard /> component, that we can code split and preload. Very handy!
// You're encouraged to always code split like this for performance, even if the route renderer itself is automatically code split.
// The router will intelligently load the route code as it's likely to be needed.
module OrganizationDashboard = %relay.deferredComponent(OrganizationDashboard.make)
// Don't worry about the names/paths here, it will be autogenerated for you
let renderer = Routes.Organization.Dashboard.Route.makeRenderer(
// prepareCode lets you preload any _assets_ you want to preload. Here we preload the code of our codesplit component.
// It receives the same props as `prepare` below.
~prepareCode=_ => [OrganizationDashboard.preload()],
// prepare let's your preload your data. It's fed a bunch of things (more on that later). In the example below, we're using the Relay environment, as well as the slug, that's a path parameter defined in the route definition, like `/campaigns/:slug`.
~prepare=({environment, slug}) => {
// HINT: This returns a single query ref, but remember you can return _anything_ from here - objects, arrays, tuples, whatever. A hot tip is to return an object that doesn't require a type definition, to leverage type inference.
OrganizationDashboardQuery_graphql.load(
~environment,
~variables={slug: slug},
~fetchPolicy=StoreOrNetwork,
(),
)
},
// Render receives all the config `prepare` receives, and whatever `prepare` returns itself. It also receives `childRoutes`, which is any rendered route nested inside of it. So, if the route definition of this route has `children` and they match, the rendered output is in `childRoutes`. Each route with children is responsible for rendering its children. This makes layouting easy.
~render=props => {
<OrganizationDashboard queryRef=props.prepared> {props.childRoutes} </OrganizationDashboard>
},
)
And, just for clarity, OrganizationDashboard
being rendered looks something like:
// OrganizationDashboard.res
module Query = %relay(`
query OrganizationDashboardQuery($slug: ID!) {
organizationBySlug(slug: $slug) {
...SomeFragment_organization
}
}
`)
@react.component
let make = (~queryRef) => {
let data = Query.usePreloaded(~queryRef)
....
}
Now, let's look at what props each part of the route renderer receives:
prepare
will receive:
-
environment
- The Relay environment in use. -
location
- the full location object, including pathname/search/hash etc. - Any path params. For a path like
/some/route/:thing/:otherThing
,prepare
would receive:thing: string, otherThing: string
. - Any query params. For a path like
/some/route/:thing?someParam=bool&anotherParam=array<string>
,prepare
would receivesomeParam: option<bool>, anotherParam: option<array<string>>
. More on query params later. -
childParams?
- If the route's child routes has path params, they'll be available here.
prepareCode
will receive the same props as prepare
above.
render
will receive the same things as prepare
, and in addition to that it'll also receive:
-
childRoutes: React.element
- if there are rendered child routes, its rendered content will be here. -
prepared
- whatever it is thatprepare
returns above.
As you can see, both child route params and content is passed along to your parent route.
The child route content (that you render to show the actual route contents) is passed along as a prop childRoutes
. The child route params (any path params for child routes) are passed along as childParams
, if there are any child params. This means that childParams
will only exist if there are actual child params.
Sometimes it's useful to know whether that child route content is actually rendered or not. For example, maybe you want to control whether a slideover or modal shows based on whether there's actual content to show in it. For that purpose, there's a helper called RelayRouterUtils.childRouteHasContent
. Here's an example of using it:
<SlideOver open_={RelayRouterUtils.childRouteHasContent(childRoutes)} title=None>
{childRoutes}
</SlideOver>
There, excellent! We've now covered how we define and render routes. Let's move on to how we use the router itself - link to routes, interact with query params, prepare route data and code in advance, and so on.
Linking to routes is fully type safe, and also quite ergonomic. A type safe makeLink
function is generated for every defined route. Using it looks like this:
<RelayRouterLink to_={Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)}>
{React.string("See active members")}
</RelayRouterLink>
makeLink
will take any parameters defined for the route as non-optional (slug
here), and any query param defined for the route (or any parent route that renders it) as an optional. makeLink
will then produce the correct URL for you.
This is really nice because it means you don't have to actively think about your route structure when doing day-to-day work. Just about what the route is called and what parameters it takes.
Routes
is the main file you'll be interacting with. It lets you find all route assets, regardless of how they're organized. It's designed to be autocomplete friendly, making it easy to autocomplete your way to whatever route you're after.
In Routes.res
, any route will have all its generated assets at the route name itself + Route
, like Routes.Organization.Members.Route.makeLink
. Any children of that route would be located inside of that same module, like Routes.Organization.Members.SomeChildRoute.Route
.
Tip: Create a helper module and alias the link component, so you use something link
<U.Link />
day to day instead of<RelayRouterLink />
. This helps if you need to add your own things to the Link component at a later stage.
The router lets you navigate and preload/prepare routes programatically if needed. It works like this:
let {push, replace, preload, preloadCode} = RelayRouterUtils.useRouter()
// This will push a new route
push(Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true))
// This will replace
replace(Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true))
// This will prepare the *code* for a specific route, as in download the code for the route. Notice `priority` - it goes from Low | Default | High, and lets you control how fast you need this to be prepared.
preloadCode(
~priority=High,
Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)
)
// This will preload the code _and_ data for a route.
preload(
~priority=High,
Routes.Organization.Members.Route.makeLink(~slug=organization.slug, ~showActive=true)
)
Just as with the imperative functions above, <RelayRouterLink />
can help you preload both code and data. It's configured via these props:
-
preloadCode
- controls when code will be preloaded. Default isOnInView
, and variants are:-
OnRender
as soon as the link renders. Suitable for things like important menu entries etc. -
OnInView
as soon as the link is in the viewport. -
OnIntent
as soon as the link is hovered, or focused. -
NoPreloading
do not preload.
-
-
preloadData
- controls when code and data is preloaded. Same variants as above, default isOnIntent
. -
preloadPriority
- At what priority to preload code and data. Same priority variant as described above.
A few notes on preloading:
- The router will automatically release any Relay data fetched in
prepare
after 5 minutes if that route hasn't also been rendered. - If the route is rendered, the router will release the Relay data when the route is unmounted.
- Releasing the Relay data means that that data could be evicted from the store, if the Relay store needs the storage for newer data.
- It's good practice to always release data so the Relay store does not grow indefinitively with data not necessarily in use anymore. The router solves that for you.
If your only scrolling area is the document itself, you can enable scroll restoration via the router (if you don't prefer the browser's built in scroll restoration) by simply rendering <RelayRouterScroll.ScrollRestoration />
inside of your app, close to the router provider.
Remember to turn off the built in browser scroll restoration if you do this:
%%raw(`window.history.scrollRestoration = "manual"`)
If you have scrolling content areas that isn't scrolling on the main document itself, you'll need to tell the router about it so it can correctly help you with scroll restoration, and look at the intersection of the correct elements when detecting if links are in view yet. You tell the router about your scrolling areas this way:
let mainContainerRef = React.useRef(Js.Nullable.null)
<RelayRouterScroll.TargetScrollElement.Provider
targetElementRef=mainContainerRef
id="main-scroll-area"
>
<main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
{children}
</main>
</RelayRouterScroll.TargetScrollElement.Provider>
This lets the router know that <main />
is the element that will be scrolling. If you also want the router to do scroll restoration, you can render <RelayRouterScroll.ScrollRestoration />
at the bottom inside of <RelayRouterScroll.TargetScrollElement.Provider />
, like so:
let mainContainerRef = React.useRef(Js.Nullable.null)
<RelayRouterScroll.TargetScrollElement.Provider
targetElementRef=mainContainerRef
id="main-scroll-area"
>
<main ref={ReactDOM.Ref.domRef(mainContainerRef)}>
{children}
<RelayRouterScroll.ScrollRestoration />
</main>
</RelayRouterScroll.TargetScrollElement.Provider>
This will have the router restore scroll as you navigate back and forth through your app. Repeat this for as many content areas as you'd like.
Scroll restoration is currently only on the y-axis, but implementing it also for the x-axis (so things like carousels etc can easily restore scroll) is on the roadmap.
Full, first class support for query parameters is one of the main features of the router. Working with query parameters is designed to be as ergonomic as possible. This is how it works:
Query parameters are defined inline inside of your route definitions JSON:
[
{
"name": "Organization",
"path": "/organization/:slug?displayMode=DisplayMode.t&expandDetails=bool",
"children": [
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?first=int&after=string" }
]
}
]
A few things to distill here:
- Notice how we're defining query parameters inline, just like you'd write them in a URL.
- Query parameters are inherited down. In the example above, that means that
Organization
has access todisplayMode
andexpandDetails
, which it defines itself. But, its nested routesDashboard
andMembers
(and any routes nested under them) will have access to those parameters too, and in addition to that, their own parameters. SoMembers
has access todisplayMode
,expandDetails
,first
andafter
. So, all routes have access to the query parameters they define, and whatever parameters their parents have defined.
Query parameters can be defined as string
, int
, float
, boolean
or the type of a custom module. A custom module query parameter is defined by pointing at the module's type t
: someParam=SomeModule.t
. Doing this, the router expects SomeModule
to at least look like this:
type t
let parse: string => option<t>
let serialize: t => string
The router will automatically convert back and forth between the custom module value as needed. You will only even need to interact with t
, not the raw string.
All query parameters can also be defined as array<param>
. So, for example: someParam=array<string>
.
More notes:
- The router will automatically take care of encoding and decoding values so it can be put in the URL.
- A change in query params only will not trigger a full route re-render (meaning the route renderers gets updated query params). With Relay, you're encouraged to refetch only the data you need to refetch as query params change, not the full route query if you can avoid it.
While there's no way to guarantee that a query param always has a value in the URL, you can set default values for query params so that the value you interact with in the code will always exist. This can be quite convenient at times.
Currently, this is only possible to do with custom modules. Here's a full example to illustrate how it's done:
[
{
"name": "Organization",
"path": "/organization/:slug?displayMode=DisplayMode.t!&expandDetails=bool",
"children": [
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?first=int&after=string" }
]
}
]
- Notice the
!
afterdisplayMode=DisplayMode.t
. This means that this particular query parameter will always have a value. But, where's the default value defined? The router expects you to have adefaultValue
value inside ofDisplayMode
, so it can point toDisplayMode.defaultValue
as needed. Here's an example:
// DisplayMode.res
type t = Full | Partial
let parse = (str): option<t> => {
switch str {
| "full" => Some(Full)
| "partial" => Some(Partial)
| _ => None
}
}
let serialize = (t: t) => {
switch t {
| Full => "full"
| Partial => "partial"
}
}
let defaultValue = Full
There, anytime displayMode
is not set in the URL, you'll get the defaultValue
of Full
instead.
You can access the current value of a route's query parameter like this:
let {queryParams} = Routes.Organization.Members.Route.useQueryParams()
You can set query params by using setParams
:
let {setParams} = Routes.Organization.Members.Route.useQueryParams()
setParams(
~setter=currentParameters => {...currentParameters, expandDetails: Some(true)},
~onAfterParamsSet=newParams => {
// ...do whatever refetching based on the new params you'd like here.
}
)
Let's have a look at what config setParams
take, and how it works:
-
setter: oldParams => newParams
- A function that returns the new parameters you want to set. For convenience, it receives the currently set query parameters, so it's easy to just set a single or a few new values without keeping track of the currently set ones. -
onAfterParamsSet: newParams => unit
- This runs as soon as the new params has been returned, and receives whatever new params thesetter
returns. Here's where you'll trigger any refetching or similar using the new parameters. -
navigationMode_: Push | Replace
- Push or replace the current route? Defaults to replace. -
removeNotControlledParams: bool
- Setting this tofalse
will preserve any query parameters in the URL not controlled by this route. Defaults totrue
.
Please note that
setParams
will do a shallow navigation by default. A shallow navigation means that no route data loaders will trigger. This lets you run your own specialized query, like a refetch or pagination query, driven bysetParams
, without trigger additional potentially redundant data fetching. If you for some reason don't want that behavior, there's a "hidden"shallow: bool
prop you can pass tosetParams
.
Path params are typically modelled as strings. But, if you only want a route to match if a path param is in a known set of values, you can encode that into the path param definition. It looks like this:
[
{
"name": "Organization",
"path": "/organization/:slug/members/:memberStatus(active|inactive|deleted)"
}
]
This would do 2 things:
- This route will only match if
memberStatus
is one of the values in the provided list (active
,inactive
ordeleted
). - The type of
memberStatus
will be a polyvariant[#active | #inactive | #deleted]
.
You can access the path params for a route via the usePathParams
hook. It'll return the path params if you're currently on that route.
switch Routes.Organization.Members.Route.usePathParams() {
| Some({slug}) => Console.log("Organization slug: " ++ slug)
| None => Console.log("Woops, not on the expected route.")
}
Here's a few more advanced things you can utilize the router for.
In addition to makeLink
, there's also a makeLinkFromQueryParams
function generated to simplify the use case of changing just one or a few of a large set of query params. makeLinkFromQueryParams
lets you create a link by supplying your new query params as a record rather than each individual query param as a distinct labelled argument. It enables a few neat things:
// Imagine this is quite a large object of various query params related to the current view.
let {queryParams} = Routes.Organization.Members.Route.useQueryParams()
// Scenario 1: Linking to the same view, with the same filters, but for another organization
let otherOrgSameViewLink = Routes.Organization.Members.Route.makeLinkFromQueryParams(~orgSlug=someOtherOrgSlug, queryParams)
// Scenario 2: Changing a single query parameter without caring about the rest
let changingASingleQueryParam = Routes.Organization.Members.Route.makeLinkFromQueryParams(~orgSlug=currentOrgSlug, {...queryParams, showDetails: Some(true)})
The router emits helpers to both check whether a route is active or not, as well as check whether what, if any, of a route's immediate children is active. The latter is particularly useful for tabs where each tab is a separate path in the URL. Examples:
// Tells us whether this specific route is active or not. Every route exports one of this.
// ~exact: Whether to check whether _exactly_ this route is active. `false` means subroutes of the route will also say it's active.
let routeActive = Routes.Organization.Members.Route.useIsRouteActive(~exact=false)
// There's a generic way to check if a route is active or not, `RelayRouterUtils.useIsRouteActive`.
// Useful for making your own <NavLink /> component that highlights itself in some way when it's active.
// A very crude example below:
module NavLink = {
@react.component
let make = (~href, ~routePattern, ~exact=false) => {
let isRouteActive = RelayRouterUtils.useIsRouteActive(
~href=linkHref,
// Every route has a `routePattern` you can use
~routePattern=Routes.Organization.Members.Route.routePattern,
// Whether to check whether _exactly_ this route is active. `false` means subroutes of the route will also say it's active.
~exact
)
<RelayRouterLink
to_=href
className={className ++ " " ++ isRouteActive ? "css-classes-for-active-styling" : "css-classes-for-not-active-styling"}
>
....
}
}
// Use like this:
<NavLink
to_={Routes.Organization.Members.Route.makeLink()}
routePattern={Routes.Organization.Members.Route.routePattern}
>
// You can also check a pathname directly, without using the hook:
let routeActive = RelayRouterUtils.isRouteActive(
~pathname="/some/thing/123",
~routePattern="/some/thing/:id",
~exact=true,
)
// This will be option<[#Dashboard | #Members]>, meaning it will return if and what immediate sub route is active for the Organization route. You can use this information to for example highlight tabs.
let activeSubRoute = Routes.Organization.Route.useActiveSubRoute()
- Check in or don't check in generated assets?
- Cleaning up?
- CI