Development in React: Navigation

René Techniek

How to add routing to your React application, using react-router and enhance your development experience.

In our previous blog we explained how to setup a basic React app. Using this as a starting point we can start developing a webapp to our needs.

Most applications will require the need for users to navigate between different pages. You might want users to sign up using some kind of form. Since we will be building a single page application using React, these different pages should not become separate react applications, otherwise we would be losing the strength and purposes of creating a web app using React. Instead we will need to switch between displaying the correct content, depending on the page we want to display. This can be done by keeping track of the page in some form of state ourselves. However, this will become tedious very quickly (if you feel up for the task, take a look at this blogpost, explaining how to start implementing routing from scratch by yourself).

So how should we proceed? Luckily, there are many options out there, that will help you in managing navigation and routing in your application. The most commonly known and used library being react-router. Let’s setup all the things we need in order to add this library to our previously built basic webapp.

In order to integrate this library with our application, we will need to install a couple of packages. Namely react-router (which contains the core components and functions of the library) and react-router-dom (which will help us with a number of useful components that can be used in our webapp). Since we are using TypeScript in our examples, we will need to install two more packages, containing the typings for these routing packages, namely @types/react-router and @types/react-router-dom.

In order to add these packages we can run the following command:

npm install --save react-router react-router-dom @types/react-router @types/react-router-dom

Or you could add the following lines for these packages manually to your package.json file and run npm install afterwards:

"@types/react-router" : "^5.1.7",
"@types/react-router-dom" : "^5.1.5",
"react-router" : "^5.1.2",
"react-router-dom" : "^5.1.2",

Once these new libraries are installed, we will have to create actual content to display for our routes. We will first create two simple pages. In order to keep a clear overview, we will create a subfolder called Pages that will contain the files (or additional subfolders) containing our pages and their page specific components. First we will add a file for our homepage in the following location Pages/Homepage/Homepage.tsx. The file will contain the following contents (which were previously present in our App.tsx file:

import React from 'react';

const HomePage = () => {
  return (
    <div>
      <p>
        This is our example home page. It could contain information about our website, provided services, or anything we'd like to tell our visitors.
      </p>
    </div>
  );
}

export default HomePage;

Now that we have our HomePage, let us add a second page. This page will contain a very basic form. We should add this file in its own subfolder as follows Pages/FormPage/FormPage.tsx. It will contain the following contents:

import React from 'react';
import { ExampleForm } from './ExampleForm';

const FormPage = () => {

  return (
    <div className="center-content">
      <ExampleForm/>
    </div>
  );
}

export default FormPage;

Since this file should only represent the page, any logic related to the form will not be put inside this page component. Instead, as you can see, we also need to add a component called ExampleForm.tsx which will contain our form specific logic. The component will be added to a separate file, located in Pages/FormPage/ExampleForm.tsx, which will look as follows:

import React from 'react';

interface ExampleFormState{
  isFormPartiallyAnswered: boolean;
  nameValue: string;
}

export class ExampleForm extends React.Component<{}, ExampleFormState> {
  constructor(props : {}) {
    super(props);
    this.state = {
      isFormPartiallyAnswered: false,
      nameValue: "",
    };
  }

  render(){
    return (
      <React.Fragment>
        <form>
          <label>
            Name:&nbsp;
            <input type="text" value={this.state.nameValue} onChange={(event) => {
              this.setState({
                nameValue: event.target.value,
                isFormPartiallyAnswered: true,
              })
            }} />
          </label>

          <div className="full-width">
            <button type="submit">Submit</button>
          </div>
        </form>
      </React.Fragment>
    );
  }
}

As you can see, we add an input field where the user can insert their name. The inserted value will be kept in the components state and is updated when the value in the input field changes. When the user is done, the value can be submitted using the submit button.

These pages however, are not yet reachable from our webapplication. In order to make them accessible using react-router, we will need to add all the different routes that are required to reach these specific pages. We can do this by adding a component called BrowserRouter to an already accessibly part of our application, in our case App.tsx (since this is our defined entry point). Note that react-router offers other routing implementations besides the BrowserRouter component. But since the most commonly used method of routing makes use of BrowserRouter, we will use this for our example and leave the other options as an exercise for the reader.

Inside the BrowserRouter component, we have to add a component called Switch, which will be responsible for switching between our different pages. In order to actually switch between these pages, we have to add one or more Route components that define our actual routes (and their associated content) inside the Switch component. The Route component should contain a path property, which will tell react-router how to change the URL for the given form of routing (in the case of BrowserRouter, the provided path will be appended to the base URL).

Inside each Route component we can add any child component we want. Alternatively, we can provide the required page as the component property instead. If we choose to do this however, we can only add a single component instead of multiple child components. This is not an issue in the case of our example, since we created separate page components which will house our content.

In order to actually be able to navigate to these routes through our app, we will also need to add one or more Link components. It is mandatory to place Link components inside of the BrowserRouter component (either directly or in any child component). This component requires a property to which should be the same value as defined in the path property of the corresponding Route component we want to link towards. Inside the Link component, we can add text or child components that, when clicked, will link to the location. When we do this, App.tsx should look like this:

import React from 'react';
import './App.css';
import { 
  Link,
  BrowserRouter,
  Switch,
  Route } from 'react-router-dom';
import HomePage from './Pages/HomePage/HomePage';
import FormPage from './Pages/FormPage/FormPage';

const App = () => {
  return (
    <div className="App">
      <header className="App-header">
        <BrowserRouter>
          <div>
            <nav>
              <Link to="/">Home</Link><br/>
              <Link to="/contact">Contact information</Link>
            </nav>
            <Switch>
              <Route path="/" component={HomePage}/>
              <Route path="/form" component={FormPage}/>
            </Switch>
          </div>
        </BrowserRouter>
      </header>
    </div>
  );
}

export default App;

When we start our application, we will now see two links on our screen. However, clicking on the Form link, will not display the correct component but only alter our URL. When using react-router for the first time this might be confusing. This happens due to the fact that the defined routes will look at the first match containing the provided path in our list of routes. In our case the path /form is defined after we defined the path /. Note that /form also contains the substring /. Therefore the path /form will match with the substring / and its corresponding Route component, therefore displaying the HomePage component.

We can counter this issue in two ways. The first option would be to change the order of our defined routes. By placing the Route with our home path / below our /contact path, we will first hit the route towards the contact page. However, this could cause issues when adding new routes or when similar names for pages are used. If you do opt for this method, make sure to always place the path to / last.

The second option however, overcomes this issue. By adding the property exact to our routes, the matching of the path will have to match exactly with the provided path in our Route component. When we do this, we can keep any order of our list of routes and can name them anything we’d like (as long as the paths provided are unique). After we apply this second option (by adding exact to all our Route components), we should be able to follow the provided links to their respective paths and pages. We could also directly navigate to an existing path by directly typing it in our address bar.

But what happens when we try to open a path that is not defined in our list of existing route paths? When we try to do so, the Router will not find an exact match and will not display any components. Depending on your use case, this could be a desired outcome. However, most applications will display either an error or 404 page for these situations. We can create another page called NotFoundPage.tsx, containing an error message we want to display to our users. This file could look something like this:

import React from 'react';
import logo from '../logo.svg';
import { Redirect } from 'react-router';

const NotFoundPage = () => {
  return (
    <div>
      <img src= {logo} className="App-logo" alt="logo" />
      <p>
        Oops, this page does not exist.
        <Redirect from="/blabla" to="/" exact/>
      </p>
    </div>
  );
}

export default NotFoundPage;

Once we made our error page, we can add the following Route to the end of our list of Routes in the Switch component:

<Route component={NotFoundPage}/>

Also notice that we do not provide a path to this component. Since we added this Route as the final component in our list of Routes, this Route will be considered a fallback, for all the possible paths that have not been exactly matched to any of the other Routes. The fallback will then display the NotFoundPage component that we defined in NotFoundPage.tsx. Depending on what the desired behaviour for this page is, you can either add a Redirect component to the page or display an explanation message to your user. In any case, make sure to always leave this fallback page as your last entry of routes.

With all the Link components in place, the Router is already getting quite crowded. We can fix this by creating a header component file Components/Global/Header.tsx, and placing the Link components inside of it. This file will look as follows:

import React from 'react';
import { Link } from 'react-router-dom';
import logo from '../../logo.svg';

const Header = () => {
  return (
    <header>
      <div>
        <img src= {logo} className="App-logo" alt="logo" />
      </div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/form">Example form</Link>
      </nav>
    </header>
  );
}

export default Header;

This should be fine for some cases. However, it could be the case that we want to do more with the layout of each individual page. We can do this by creating different Layout components, for example a component for a Layout with a menu bar at the top and one for a menu bar to the left-hand side of our screen. Before we will add these files, let us update our styling. We remove the current App.css file and replace it with App.scss (note that we already setup webpack to handle scss files in our previous blog, so this should not be an issue). Lets change the contents of this new file to the following:

.App {
  background-color: #bad1ff;
  text-align: center;
  height: 100vh;
  color: black;
}

.App-logo {
  height: 96px;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.App-body {
  width: 100%;
}

a, a:visited {
  color: white;
}

a:hover, a:focus, a:active {
  color: #ffffffb9;
}

.menu-left {
  display: flex;
  flex-direction: row;
}

header {
  background-color: #98bcff;
  min-height: 96px;
  font-size: calc(10px + 2vmin);
}

header > nav {
  display: flex;
  align-items: center;
  justify-content: center;
  a {
    margin-right: 10px;
    margin-bottom: 5px;
    margin-left: 10px;
  }
}

.menu-left > header > nav {
  width: 256px;
  flex-direction: column;
}

/* based on https://www.w3schools.com/howto/howto_css_overlay.asp */
.overlay {
  position: fixed; /* Sit on top of the page content */
  width: 100%; /* Full width (cover the whole page) */
  height: 100%; /* Full height (cover the whole page) */
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0,0,0,0.5); /* Black background with opacity */
  z-index: 2; /* Specify a stack order in case you're using a different order for other elements */
}

.center-content {
  display: flex;
  flex-direction: column;
  height: 100%;
  align-items: center;
  justify-content: center;
}

button {
  margin-top: 10px;
  margin-left: 10px;
  margin-right: 10px;
  cursor: pointer;
}

form {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
}

.error{
  color: red;
}

.modal {
  background-color: #bad1ff;
  border-radius: 4px;
  padding: 20px;
}

.full-width {
  width: 100%;
}

Now that our styling is prepared, we will create the two different layouts as follows: Pages/PageLayouts/LayoutWithMenuTop.tsx:

import React from 'react';
import Header from '../../Components/Global/Header';

export const LayoutWithMenuTop = ({...props}) => {
  return (
    <div className="App">
      <Header/>
      <div className="App-body">
        {props.children}
      </div>
    </div>
  );
}

Pages/PageLayouts/LayoutWithMenuLeft.tsx:

import React from 'react';
import Header from '../../Components/Global/Header';

export const LayoutWithMenuLeft = ({...props}) => {
  return (
    <div className="App menu-left">
      <Header/>
      <div className="App-body">
        {props.children}
      </div>
    </div>
  );
}

Note that in our example these files look almost identical (except for the additional class in case of LayoutWithMenuLeft). In reality these layouts will obviously change to the needs of the application and could differ accordingly.

In our Router we now have to specify which layout we want to use for each Page. We will do so by creating a new wrapper component LayoutRoute. This component will contain a property called layout which is of the type React.ComponentType. It will contain the layout component to be associated with each page. Additionally, the properties will extend the RouteProps from React-router (so we do not lose any functionality that react-router provides). Pages/PageLayouts/LayoutRoute.tsx will look as follows:

import React from 'react';
import { 
  Route,
  RouteProps} from 'react-router-dom';

interface LayoutRouteProps extends RouteProps{
  layout: React.ComponentType,
}

const LayoutRoute = ({layout: Layout, path, component, ...rest} : LayoutRouteProps) => (
  <Layout>
    <Route path={path} component={component} {...rest} />
  </Layout>
);


export default LayoutRoute;

Now that we have our wrapper component, we can replace our Route components by LayoutRoute components. The only thing we have to add is the layout property for each route/page. After changing this App.tsx should look a lot more tidy. It’s contents should look similar to this:

import React from 'react';
import { 
  BrowserRouter,
  Switch} from 'react-router-dom';
import LayoutRoute from './Pages/PageLayouts/LayoutRoute';
import { LayoutWithMenuTop } from './Pages/PageLayouts/LayoutWithMenuTop';
import { LayoutWithMenuLeft } from './Pages/PageLayouts/LayoutWithMenuLeft';
import HomePage from './Pages/HomePage/HomePage';
import FormPage from './Pages/FormPage/FormPage';
import NotFoundPage from './Pages/NotFoundPage';

const App = () => {
  return (
    <BrowserRouter >
      <Switch>
        <LayoutRoute path={"/"} layout={LayoutWithMenuTop} component={HomePage} exact/>
        <LayoutRoute path={"/form"} layout={LayoutWithMenuLeft} component={FormPage} exact/>
        <LayoutRoute layout={LayoutWithMenuTop} component={NotFoundPage}/>
      </Switch>
    </BrowserRouter>
  );
}

export default App;

Since we have a form in our application, we probably want to make sure that, once we answered some of the form’s questions, we cannot simply navigate away by accident (by missclicking a link for example).

In order to do so React-router provides the Prompt component, which will fire an alert message to the user, once they try to navigate away from the page. This component will require a message property, which can be used in two ways. Either this property will just contain a string, the message to be prompted to the user after navigating away. Or the property could contain a function. This function will receive a location as input and will return a string as a message or a boolean which indicates whether or not to continue with the navigation.

We can also provide an optional property called when, containing a boolean on when to fire the prompt. For example, to only fire once we already provided some answers (compared to always alerting us when navigating away). When we add this prompt component to our ExampleForm component (for example below our button component) and we try to navigate to a different page in our application using the link to our home page, the prompt will fire.

One major downside of the prompt component however, is that it only displays plain alert messages. Alert messages cannot be styled and might not be very nice looking compared to the style of the webapp. Preferably we would like to display a custom Modal, with custom styling, that acts in the same way as the regular react-router prompt.

We can do this by creating our own prompt component that will alter the way the regular prompt works. We will add a file Components/Gloabl/EnhancedPrompt.tsx, to which we will need to provide two properties. An optional property called when, which will work exactly the same as with the regular react-router Prompt component. And secondly a property which we shall call shouldBlockNavigation. This property will (partially) replace the message property from the react-router Prompt. This property however will be a function with a location as input and will always return a boolean value, indicating if we allow the navigation to proceed or halt the nagivation entirely, based on our desired logic.

The EnhancedPrompt component will contain three state properties, and to make things even more stylish we will be making use of hooks (note that we did not use hooks in our form component, it might be a nice exercise to update that component yourself). Firstly we will need a state property modalVisible which will contain a boolean to indicate if the prompt should be displayed or not. Secondly, a state property locationToNavigateTo. This state property will initially be empty, but will be filled with the location we want to navigate our app towards (once we allow it to do so). The third and final state property isNavigationConfirmed will contain a boolean value which will be set to true once we tell navigation to proceed.

We will also make use of the useHistory hook that is provided by the react-router-dom library. This will allow us to hook into place the correct values in our history, once we get the go ahead for our navigation.

Our EnhancedPrompt.tsx component will look as follows:

import { Location } from "history";
import React, { useEffect, useState } from "react";
import { Prompt, useHistory } from "react-router-dom";
import Modal from "./Modal"

interface Props {
  when?: boolean | undefined;
  shouldBlockNavigation: (location: Location) => boolean;
}

const EnhancedPrompt = ({
  when,
  shouldBlockNavigation,
}: Props) => {
  const [modalVisible, setModalVisible] = useState(false);
  const [locationToNavigateTo, setLocationToNagivateTo] = useState<Location | null>(null);
  const [isNavigationConfirmed, setConfirmedNavigation] = useState(false);
  const history = useHistory();

  const closeModal = () => {
    setModalVisible(false);
  };

  const handleBlockedNavigation = (nextLocation: Location): boolean => {
    if (!isNavigationConfirmed && shouldBlockNavigation(nextLocation)) {
      setModalVisible(true);
      setLocationToNagivateTo(nextLocation);
      return false;
    }
    return true;
  };

  const handleConfirmNavigationClick = () => {
    setModalVisible(false);
    setConfirmedNavigation(true);
  }; 

  useEffect(() => {
    if (isNavigationConfirmed && locationToNavigateTo) {
      history.push(locationToNavigateTo.pathname)
    }
  }, [isNavigationConfirmed, locationToNavigateTo, history]);

  return (
    <React.Fragment>
      <Prompt when={when} message={handleBlockedNavigation} />
      <Modal
        isShown={modalVisible}
        titleText={"Do you want to close without saving?"}
        explanatoryText={"You have unsaved changes. Are you sure you want leave this page without saving?"}
        cancelButtonText={"Cancel"}
        confirmButtonText={"Confirm"}
        onCancel={closeModal}
        onConfirm={handleConfirmNavigationClick}
      />
    </React.Fragment>
  );
};

export default EnhancedPrompt;

When we want to display our prompt, the Prompt component will fire the handleBlockedNavigation method with the current location as its parameter. In this method we check whether or not we should block the navigation. When we do, we set the modal to be visible and set the provided location in our state. Finally, as per the documentation of the react-router Prompt component, we return false in order to not perform navigation.

In the modal, we can now decide whether or not to navigate. Once we confirm navigation, the main navigation logic will take place in the useEffect hook (which is provided by React). If you are unfamiliar with hooks, the useEffect hook acts similarly to the componentDidMount and componentDidUpdate methods you would normally have used. The difference being that you have to provide the hooked state properties that you are using inside the useEffect hook.

Simply put, once a state update has occurred, the useEffect hook will trigger. It will check if we have confirmed our navigation and we have an actual location to navigate towards. Once these checks pass, we will add the location to our history, and navigation will occur.

The Modal component will be a simple component that will notify the user about the navigation and provided options to either accept or decline the navigation. The Modal.tsx component should look something like this:

import React from 'react';

interface ModalProps {
  isShown : boolean
  titleText : string
  explanatoryText : string
  cancelButtonText : string
  confirmButtonText : string
  onCancel : (() => void)
  onConfirm : (() => void)
}

const Modal = ({
  isShown,
  titleText,
  explanatoryText,
  cancelButtonText,
  confirmButtonText,
  onCancel,
  onConfirm
} : ModalProps) => {
  if(isShown){
    return (
      <div className="overlay">
        <div className="center-content">
          <div className="modal">
            <div>{titleText}</div>
            <div>{explanatoryText}</div>
            <div>
              <button onClick={onCancel}>{cancelButtonText}</button>
              <button onClick={onConfirm}>{confirmButtonText}</button>
            </div>
          </div>
        </div>
      </div>
    );
  }
  return <React.Fragment/>
}

export default Modal;

Now that we have created our EnhancedPrompt, all we have to do is place it into our form component as follows:

...
  </form>
  <EnhancedPrompt
      when={this.state.isFormPartiallyAnswered}
      shouldBlockNavigation={location => (
        location.pathname !== window.location.pathname && this.state.isFormPartiallyAnswered)}
  />
</React.Fragment>
...

We have now added some useful and reusable ways for our routing to the web application. The code can be found in our Git repository here. In the following blog we will discuss how to enhance the styling experience for our application.

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of bel met Jolanda.