A ReScript web router for RescriptRelay.
npm install rescript-relay-routerA 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:
- rescript@>=12.0.0
- vite for your project setup, >2.8.0.
- build: { target: "esnext" } in your vite.config.js.
- "type": "module" in your package.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:
``bashInstall the package
yarn add rescript-relay-router
This will create all necessary assets to get started. Now, add the router to your
bsconfig.json:`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:`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:
`rescript
// Router.reslet preparedAssetsMap = Dict.make()
//
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:`rescript
React.string("Error!")}>
`Finally, we'll need to render
. 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.`rescript
// App.res or similar
// This renders all the time, and when there's a pending navigation (pending via React concurrent mode), pending will be true
renderPending={pending => {pending ? React.string("Loading...") : React.null}}
/>
`There, we're all set! Let's go into how routes are defined and rendered.
Defining and rendering routes
$3
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:`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:
1. Start with a capitalized letter
2. 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.To create a "catch all" route, use the
*, character as the route path. Typically used for the "not found" route. Example:`json
[
{
"name": "NotFound",
"path": "*"
}
]
`react-router under the hood.$3
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
. is the fully joined names 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:
`rescript
// This creates a lazy loaded version of the 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 => {
{props.childRoutes}
},
)
`And, just for clarity,
OrganizationDashboard being rendered looks something like:`rescript
// OrganizationDashboard.res
module Query = %relay()@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, prepare would receive someParam: option. 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 that prepare returns above.$3
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
RelayRouter.Utils.childRouteHasContent. Here's an example of using it:`rescript
{childRoutes}
`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 and query params
$3
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:`rescript
{React.string("See active members")}
`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
day to day instead of . This helps if you need to add your own things to the Link component at a later stage.$3
The router lets you navigate and preload/prepare routes programatically if needed. It works like this:
`rescript
let {push, replace, preload, preloadCode} = RelayRouter.Utils.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,
can help you preload both code and data. It's configured via these props:-
preloadCode - controls when code will be preloaded. Default is OnInView, 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 is OnIntent.
- 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.$3
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
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:
`rescript
let mainContainerRef = React.useRef(Nullable.null) targetElementRef=mainContainerRef
id="main-scroll-area"
>
{children}
`This lets the router know that
is the element that will be scrolling. If you also want the router to do scroll restoration, you can render at the bottom inside of , like so:`rescript
let mainContainerRef = React.useRef(Nullable.null) targetElementRef=mainContainerRef
id="main-scroll-area"
>
{children}
`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.
Query parameters
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:
$3
Query parameters are defined inline inside of your route definitions JSON:
`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 to displayMode and expandDetails, which it defines itself. But, its nested routes Dashboard and Members (and any routes nested under them) will have access to those parameters too, and in addition to that, their own parameters. So Members has access to displayMode, expandDetails, first _and_ after. 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:`rescript
type t
let parse: string => option
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. So, for example: someParam=array.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.
$3
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:
`json
[
{
"name": "Organization",
"path": "/organization/:slug?displayMode=DisplayMode.t!&expandDetails=bool",
"children": [
{ "name": "Dashboard", "path": "" },
{ "name": "Members", "path": "members?first=int&after=string" }
]
}
]
`- Notice the
! after displayMode=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 a defaultValue value inside of DisplayMode, so it can point to DisplayMode.defaultValue as needed. Here's an example:`rescript
// DisplayMode.res
type t = Full | Partiallet parse = (str): option => {
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.$3
You can access the current value of a route's query parameter like this:
`rescript
let {queryParams} = Routes.Organization.Members.Route.useQueryParams()
`You can set query params by using
setParams:`rescript
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 the setter 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 to false will preserve any query parameters in the URL not controlled by this route. Defaults to true.> 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 by setParams, 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 to setParams.Path params
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:
`json
[
{
"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 or deleted).
- The type of memberStatus will be a polyvariant [#active | #inactive | #deleted].$3
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.`rescript
switch Routes.Organization.Members.Route.usePathParams() {
| Some({slug}) => Console.log("Organization slug: " ++ slug)
| None => Console.log("Woops, not on the expected route.")
}
`Advanced
Here's a few more advanced things you can utilize the router for.
$3
#### With
makeLinkFromQueryParamsIn 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:`rescript
// 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)})
`#### With
useMakeLinkWithPreservedPathIn case you don't already have the current value of the path and query parameters and only want to update the query params, you can use
useMakeLinkWithPreservedPath to generate a new link:`rescript
let makeNewLink = Routes.Organization.Members.Route.useMakeLinkWithPreservedPath()// Changing a single query parameter without caring about the rest
let changingASingleQueryParam = makeNewLink(queryParams => {...queryParams, showDetails: Some(true)})
`$3
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:
#### Checking whether a specific route is active
`rescript
// 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)
`#### Checking whether a route is active in a generic way (
)`rescript
// There's a generic way to check if a route is active or not, RelayRouter.Utils.useIsRouteActive.
// Useful for making your own 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 = RelayRouter.Utils.useIsRouteActive(
// 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
) to_=href
className={className ++ " " ++ isRouteActive ? "css-classes-for-active-styling" : "css-classes-for-not-active-styling"}
>
....
}
}
// Use like this:
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 = RelayRouter.Utils.isRouteActive(
~pathname="/some/thing/123",
~routePattern="/some/thing/:id",
~exact=true,
)
`#### Extracting the parameters of a route is active in a generic way (
parseRoute)If you want to check if a link matches a given route (that is not the active one) and want to extract its parameters, you can use
parseRoute:`rescript
switch Routes.Organization.Members.Route.parseRoute(link){
| Some((_pathParams, {showDetails: true})) => // do something here
| Some(_) => // do something else
| None => // the link is not matched by the given route
}
`#### Checking whether a direct sub route of a route is active (for tabs, etc)
`rescript
// 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