Implementing Routing in React, a Step by Step Approach

Implementing Routing in React, a Step by Step Approach

This article follows my thought process of building a routing system for React applications.

To be able to follow along one should have some exposure to building React applications and custom React hooks.

These are our goals:

  • Have a simple routing system where one can simply read the current route as a string, and also be able to navigate to some other given route.
  • Make it UX friendly and make it feel natural for usage in a React application.

The first goal alone is actually very simple to accomplish. There already exists a browser native solution: location.href.

console.log(location.href);      // prints out the current URL
location.href = ‘example.com’;   // navigates to `example.com`

The biggest problem with this is when we navigate to a new URL, the browser gets refreshed. It is as if we simply typed the URL in the address bar and pressed enter. Definitely not a good user experience. There is no need to refresh the app if we already have that app loaded and running.

There is a way to make it change only the URL and not refresh the browser. Like this:

history.pushState({}, '', ‘example.com’);

But then we will only change what is written in the address bar, and not really refresh our app to correspond to the URL change. Let’s park this issue for now, we will return to it later.

First, we will define the ideal way somebody would use the routing solution. In today’s React applications it feels natural to implement this with a custom hook. Imagine we have a hook useRouter, and we would like to use it like this:

let [route, setRoute] = useRouter();

console.log(route);     // prints out the current route
setRoute(‘/home’);      // navigates to `/home` on the same origin

Looks very much like the useState hook, doesn’t it? Let’s implement this hook. We will do it step by step.

// router.jsx

import {useState} from 'react'

export function useRouter() {
    let [route, setRoute] = useState(location.pathname);
    return [
        route,
        newRoute => {
            history.pushState({}, '', newRoute);
            setRoute(newRoute);
        }
    ];
}

This is just our first try. It makes sense to try it now with a simple application that consists of two React components: the main one <App> and the inner one <Pancake>. <Pancake> should be rendered only if we are accessing route /pancake.

// App.jsx

import {useRouter} from './router.js'
import Pancake from './Pancake.jsx'

export default function App() {
    let [route, setRoute] = useRouter();

    return <>
        current route: {route}
        <br/>
        {route == '/pancake' && <Pancake/>}
    </>
}
// Pancake.jsx

import {useRouter} from './router.js'

export default function Pancake() {
    let [route, setRoute] = useRouter();

    return <>
        <button onClick={_ => setRoute('/waffle')}>
            go to /waffle
        </button>
    </>
}

If we try the app now and we type directly in the address bar the route /pancake we will see exactly what is expected: the button "go to /waffle" will be rendered. Now, if we click on the button, what happens is the route changes to /waffle as expected, but nothing changes on the page. No surprise here, as there is nothing in the state of the <App> component that has changed, so React doesn’t know that it needs to refresh its hierarchy. Even though we are using the same hook useRoute in both <App> and <Pancake>, there are actually just two separate instances of the individual states of those components. But we want them to use the same state. The way to do this in React is with React Context. We will provide route and setRoute through the context. That means we can change our <App> and <Pancake> components to this:

// App.jsx

import {useContext} from 'react'
import {routerContext} from './router.jsx'
import Pancake from './Pancake.jsx'

export default function App() {
    let [route, setRoute] = useContext(routerContext);

    return <>
        current route: {route}
        <br/>
        {route == '/pancake' && <Pancake/>}
    </>
}
// Pancake.jsx

import {useContext} from 'react'
import {routerContext} from './router.jsx'

export default function Pancake() {
    let [route, setRoute] = useContext(routerContext);

    return <>
        <button onClick={_ => setRoute('/waffle')}>
            go to /waffle
        </button>
    </>
}

Obviously, we have to create and expose the routerContext in our router.jsx. Also we need to wrap our app in the <routerContext.Provider> component. First, let’s make a separate component, <Router>, that will contain this provider:

// router.jsx

import {useState, createContext} from 'react'

function useRouter() {
    let [route, setRoute] = useState(location.pathname);
    return [
        route,
        newRoute => {
            history.pushState({}, '', newRoute);
            setRoute(newRoute);
        }
    ];
}

export const routerContext = createContext();

export default function Router(props) {
    let [route, setRoute] = useRouter();

    return <>
        <routerContext.Provider value={[route, setRoute]}>
            {props.children}
        </routerContext.Provider>
    </>
}

And now simply wrap <App> inside <Router>.

let root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <Router>
        <App/>
    </Router>
);

If we try the app now, everything works as expected. When we click on the button “go to /waffle”, both the URL in the address bar changes, and also the app refreshes its view but without refreshing the browser page. Great!

Now is the time to make some clean ups in the code. First, one can notice we are not using the useRouter hook outside the router.jsx, hence, there is no need for the export. Also, there is actually no need for this hook to exist anymore, as it is used only once and in the same file. Let us rewrite the file to clean this up.

// router.jsx

import {useState, createContext} from 'react'

export const routerContext = createContext();

export default function Router(props) {
    let [route, setR] = useState(location.pathname);

    let setRoute = newRoute => {
        history.pushState({}, '', newRoute);
        setR(newRoute);
    };

    return <>
        <routerContext.Provider value={[route, setRoute]}>
            {props.children}
        </routerContext.Provider>
    </>
}

Neat! Now, as suggested in the beginning, we want to use the router throughout the app with the useRouter hook. But, with the current solution, we have to use it with useContext(routerContext). This is a little bit cumbersome. We are building the router solution that can be used as is. A developer who would use it doesn’t need to know that we are implementing all this magic with React Context. Also, we already wrapped the routerContext.Provider in the Router component, so this part is hidden. Let's reintroduce the useRouter hook idea and implement this hook again.

This time, the hook will look very differently than before. It only needs to hide the useContext(routerContext) part in itself, so it should be very simple. We will put that in the router.jsx. Also, we can remove exposing of the routerContext, as we will not need that outside the file anymore.

// router.jsx

import {useState, useContext, createContext} from 'react'

const routerContext = createContext();

export function useRouter() {
    return useContext(routerContext);
}

export default function Router(props) {
    let [route, setR] = useState(location.pathname);

    let setRoute = newRoute => {
        history.pushState({}, '', newRoute);
        setR(newRoute);
    };

    return <>
        <routerContext.Provider value={[route, setRoute]}>
            {props.children}
        </routerContext.Provider>
    </>
}
// App.jsx

import {useRouter} from './router.jsx'
import Pancake from './Pancake.jsx'

export default function App() {
    let [route, setRoute] = useRouter();

    return <>
        current route: {route}
        <br/>
        {route == '/pancake' && <Pancake/>}
    </>
}
// Pancake.jsx

import {useRouter} from './router.jsx'

export default function Pancake() {
    let [route, setRoute] = useRouter();

    return <>
        <button onClick={_ => setRoute('/waffle')}>
            go to /waffle
        </button>
    </>
}

We are almost there. There is one more important thing to fix. What happens if somebody uses the back and forward browser buttons? It doesn’t work! The URL changes expectedly, but the page doesn’t refresh. To fix this, we need to add an event listener to this event that will make a navigation using our router. The event is called popstate and fires on the window object. Also, as we will add this event listener, we have to make sure to clean it up if the component dismounts. This is done by creating a return function inside the useEffect hook. Finally, our routing solution becomes this:

// router.jsx

import {useState, useEffect, useContext, createContext} from 'react'

const routerContext = createContext();

export function useRouter() {
    return useContext(routerContext);
}

export default function Router(props) {
    let onPopState = event => {
        setRoute(location.pathname);
    };

    useEffect(_ => {
        addEventListener('popstate', onPopState);
        return _ => {
            removeEventListener('popstate', onPopState);
        }
    }, []);

    let [route, setR] = useState(location.pathname);

    let setRoute = newRoute => {
        history.pushState({}, '', newRoute);
        setR(newRoute);
    };

    return <>
        <routerContext.Provider value={[route, setRoute]}>
            {props.children}
        </routerContext.Provider>
    </>
}

This is more or less the whole simple routing solution. It is very simple, and there is definitely space for improvements. We can mention few things that could be added if there is a need for it.

First, if somebody is to use a hyperlink with a standard <a> tag, it would obviously do navigation without our router. To overcome this, one would need to provide an onClick listener to the element that would prevent default browser navigation and use our router instead. For example, if we were to use the link instead of the button in our <Pancake>, it would look something like this:

// Pancake.jsx

import {useRouter} from './router.jsx'

export default function Pancake() {
    let [route, setRoute] = useRouter();

    function onClick(evt) {
        evt.preventDefault();
        setRoute(new URL(evt.currentTarget.href).pathname);
    }

    return <>
        <a href="/waffle" onClick={onClick}>
            go to /waffle
        </a>
    </>
}

If there is a need to use something similar very often, it would be reasonable to create a separate component that wraps the <a> tag with this logic, and provide it as an additional component of our routing system.

Second, sometimes there is a need for a redirect that we don’t want to save in the navigation history. For example, we may want to render the same page whether the user goes to "/home" or to "/". And usually the page would simply navigate from the first route to the second. So, if this redirection was saved in the navigation history, then pressing the browser’s back button wouldn’t do much, it would just try to go to the previous page which would right away redirect back. In this case, we would prefer to call history.replaceState instead of our history.pushState. This option can simply be added as an additional parameter to our setRoute function.

Lastly, one small optimization can be implemented with our solution. Functions onPopState and setRoute don’t need to be redefined every time when a state updates in the <Router> component. If this is really a concern, one can always wrap those functions using the useCallback hook provided by React for this kind of optimizations.

While there are many other possible improvements, I will stop here, as my intention was to share my step-by-step thought process of a very simple routing implementation, instead of providing a full fledged routing system.