Handle the navigation in a ReScript React app

lundi 22 mars 2021

Generaly, in a modern web application, the navigation is handled by the front-end side. In a React project, we will tend to favorise a library like React Router.

ReScript React has his own navigation system that is pretty simple and performant. In that way, we can write our own "framework" to make our navigation type-safe.

Take a look to the API

At the time this article is written, the ReScript documentation doesn't yet be converted, so I will use the Reason one which is identical.

First, let's see what the API gave us. There are only 6 functions whose 1 hook :

  • push(string) take a path and update the URL
  • replace(string) replace the current URL without being added to the history
  • watchUrl(f) take a callback which is an event listener, when the URL change, will be raised by adding some informations.
  • unwatchUrl(watcherID) delete the event listener set
  • dangerouslyGetInitialUrl() will get the URL informations like the path, hash or search params
  • useUrl is a React hook which give us the current URL

Here is a basic usage example :

module PageHome = {
  @react.component
  let make = () => <h1> {"Home"->React.string} </h1>
}

module PageProducts = {
  @react.component
  let make = () => <h1> {"Products"->React.string} </h1>
}

module PageProductDetails = {
  @react.component
  let make = (~productId) => <h1> {("Product " ++ productId)->React.string} </h1>
}

@react.component
let make = () => {
  let route = ReasonReactRouter.useUrl()

  switch route.path {
  | list{} => <PageHome />
  | list{"products"} => <PageProducts />
  | list{"products", productId} => <PageProductDetails productId />
  | _ => <strong> {"This page does not exist !"->React.string} </strong>
  }
}

Everything is handled by the ReScript pattern matching which allow us to spread the path list.

A type-safe navigation

This way to do is fine for a small application of few pages, but after some times, you will have to handle different levels of navigation with authentication.

Why did I put type safe in the title you may ask. If you look closely our example above, our code can easly break if someone rename a route or made a typo. This can be solved by creating a specific type for our routes and avoid the string type which is too permissive !

Developing our type

I think the better way to represent a route is an enumeration, in that way, we can use a variant type.

We can represent our routes list like this :

type route =
  | Home
  | Products
  | NotFound;

You may ask, how do we handle URL with dynamic informations like IDs ? Variants in ReScript can own a constructor argument which can be any type.

type route =
  | Home
  | Products
  | ProductDetails(string)
  | NotFound;

I don't like to use the string type for this kind of information but I will make an other article to keep focus on the navigation subject.

Our routes enumeration is done, we have to write a function which convert a string to a route and vice versa.

Convert our type

As we said above, we have 2 needs :

  • transform the URL we get from the ReScript React API to a route type
  • transform a route type into an URL

There's no magic ! We are gonna make a function for each case and will use the pattern matching.

Transform the URL from ReScript React to a route type :

let routeFromUrl = (url: ReasonReact.Router.url) =>
  switch url.path {
  | list{} => Home
  | list{"products"} => Products
  | list{"products", productId} => ProductDetails(productId)
  | _ => NotFound
  }

Note : The root index is an empty list.

Transform a route type in URL

let routeToUrl = switch (route) {
    | Home => ""
    | Products => "/products"
    | ProductDetails(productId) => "products/"++productId
    | NotFound => "/404"
  };

Commonly, I regroup everything in a file namedNavigation.re :

/* Navigation.re */

type route =
  | Home
  | Products
  | ProductDetails(string)
  | NotFound;

let routeFromUrl = (url: ReasonReact.Router.url) => switch (url.path) {
  | [] => Home
  | ["products"] => Products
  | ["products", productId] => ProductDetails(productId)
  | _ => NotFound
};
let routeToUrl = route =>
  switch (route) {
    | Home => ""
    | Products => "/products"
    | ProductDetails(productId) => "/products/"++productId
    | NotFound => "/404"
  };

Use our type

Now, we can use our type in our first example :

open Navigation;

/* ... */

[@react.component]
let make = () => {
  let route = ReasonReactRouter.useUrl();

  switch (route->routeFromUrl) {
    | Home => <PageHome />
    | Products => <Products />
    | ProductDetails(productId) => <PageProductDetails productId />
    | NotFound => <strong>"This page does not exist !"->React.string</strong>
  };
};

Now, if we need to add a new route, you will have to add it to our enumeration and converters if not the compiler will raise a warning or an error saying that we don't handle a case.

We miss an example, the usage of links in our application. For this use case, we can use our function routeToUrl to convert a route into a string and use a HTML <a> tag.

open Navigation;

module PageHome = {
  [@react.component]
  let make = () => {
    <>
      <h1>"Home"->React.string</h1>
      <a href=Products->routeToUrl onClick={event => {
        event->ReactEvent.Synthetic.preventDefault;
        ReasonReact.Router.push(Products->routeToUrl);
      }}>
        "Products"->React.string
      </a>
    </>
  }
};

We must set an onClick method to avoid a browser navigation and have a full reload and set the href tag to allow the user to copy / see the URL.

We are not going to lie, this is very fastidious for a simple link... That's why we will going to make it a component and append this to our Navigation.re file.

/* Navigation.re */

...

module Link = {
  [@react.component]
  let make = (~route, ~children) => {
    <a href=route->routeToUrl onClick={event => {
        event->ReactEvent.Synthetic.preventDefault;
        ReasonReact.Router.push(route->routeToUrl);
      }}>
      children
    </a>
  };
};

This became much more simpler to use ! And mostly, type-safe ! It's impossible to put a route that doesn't exist or make a typo without breaking the compilation !

open Navigation;

module PageHome = {
  [@react.component]
  let make = () => {
    <>
      <h1>"Home"->React.string</h1>
      <Link route=Products>
        "Products"->React.string
      </Link>
    </>
  }
};

Handle nested routes

After some times, you will have to handle several levels of navigaiton. With our code, we don't need to change anything because we can use variants as their own arguments. Look a this example below :

type routeAdmin =
  | Dashboard
  | DashboardProducts
  | DashboardProductDetails(string)

type route =
  | Home
  | Products
  | ProductDetails(string)
  | Admin(route);


let routeFromUrl = (url: ReasonReact.Router.url) =>
  switch url.path {
  | list{} => Home
  | list{"products"} => Products
  | list{"products", productId} => ProductDetails(productId)
  | list{"admin", ...rest} =>
    switch rest {
    | list{} => Dashboard
    | list{"products"} => DashboardProducts
    | list{"products", productId} => DashboardProductDetails(productId)
    }
  | _ => NotFound
  }

let routeToUrl = switch (route) {
  | Home => ""
  | Products => "/products"
  | ProductDetails(productId) => "/products/"++productId
  | Admin(Home) => "/admin"
  | Admin(DashboardProducts) => "/admin/products"
  | Admin(DashboardProductDetails(productId)) => "/admin/products/" ++ productId
  | NotFound => "/404"
};

Like in JavaScript, we can use the spread operator to decompose our list.

Conclusion

Now we have a simplistic but strong navigation solution ! In the next article, we will make the navigation even more type-safe by replacing our string type for our IDs to a dedicated one !

Thomas Deconinck

Tech Leader @colisweb. J’aime particulièrement l’informatique et la programmation. ReScript est mon langage favori. J'aime beaucoup React et React Native avec expo.
🇯🇵 暇な時に日本語を勉強します