React: Navigation Without React-Router

Intro

The React-Router library is by far the most popular way to navigate around a React application. However there are times when you may not want to use this library. For example if you don’t want to have to update your application when the React-Router library makes a breaking change, which is about once a year.

For our sample we are going to take our Widgets application that we have been working on and create a manual navigation between our different widgets.

window.location

One of the things that is fundamental to understand is the window.location read only property of the window interface.

In short, in a browser, each tab represents a Window, and each window contains a DOM document (a web page).

There is a global variable exposed to JavaScript code called window, which has various properties, one of which is window.location.

window.location console log

and then within that property we can see another very important property called pathname

Showing Content Based On Pathname

Now that we know we can check the property window.location.pathname we can use that knowledge to hide or show content based on the value of this property. For example let us re-work our App.js file to try this out.

We create a new function that returns our Accordion component if our location condition is met, and then call that function in the return section of the app.

"App.js"
import React, { useState } from "react"

import Accordion from "./components/Accordion"

const showAccordion = () => {
  if (window.location.pathname === "/") {
    return <Accordion />
  }
}

export default () => {
  return <div className="ui container">{showAccordion()}</div>
}

And as expected we can only see our dropdown on the homepage now.

dropdown on homepage

We can easily follow this pattern to build a navigation for the rest of the widgets.

App.js
import React from "react"

import ColorSelect from "./components/ColorSelect"
import Translate from "./components/Translate"
import Accordion from "./components/Accordion"
import Search from "./components/Search"

const showAccordion = () => {
  if (window.location.pathname === "/") {
    return <Accordion />
  }
}

const showColorSelect = () => {
  if (window.location.pathname === "/color-select") {
    return <ColorSelect />
  }
}

const showTranslate = () => {
  if (window.location.pathname === "/translate") {
    return <Translate />
  }
}

const showSearch = () => {
  if (window.location.pathname === "/search") {
    return <Search />
  }
}

export default () => {
  return (
    <div className="ui container">
      {showAccordion()}
      {showColorSelect()}
      {showTranslate()}
      {showSearch()}
    </div>
  )
}

And this all works as expected. If you go to /search you see the search widget, etc etc.

There are some downsides to this however. For example, this code is not DRY. We have lots of repetitive checks going on here.

Building a Re-Usable Route Component

We could do something like this.

const showComponent = (route, component) => {
  return window.location.pathname === route ? component : null
}

But in React the preferred way to do things is with components. So let us build a component for navigation. First we make a new component called Route.

components/Route.js
const Route = ({ path, children }) => {
  return window.location.pathname === path ? children : null
}

export default Route

Note that instead of passing in the component as a prop directly we passed in children which will pass in any components that are children of an instance of the Route component. In this way we can show multiple different components on each route. Also note that we did not need to import React into this component because it does not contain any JSX. Exports/Imports (AKA modules) are actually a function of Node, as we learned when we were working with Express.

Then we would re-write our App to be the following.

App.js
import React from "react"

import Route from "./components/Route"
import ColorSelect from "./components/ColorSelect"
import Translate from "./components/Translate"
import Accordion from "./components/Accordion"
import Search from "./components/Search"

export default () => {
  return (
    <div className="ui container">
      <Route path="/">
        <Accordion />
      </Route>
      <Route path="/color-select">
        <ColorSelect />
      </Route>
      <Route path="/translate">
        <Translate />
      </Route>
      <Route path="/search">
        <Search />
      </Route>
      <Route path="/all">
        <Accordion />
        <ColorSelect />
        <Translate />
        <Search />
      </Route>
    </div>
  )
}

And we can see that our /all route component displays all of the children

all children of component on screen

Implementing a Header For Navigation

This is simple enough. We just create a new component with some links and some Semantic UI styles.

components/Header.js
import React from "react";

const Header = () => {
  return (
    <div className="ui secondary pointing menu">
      <a href="/" className="item">
        Accordion
      </a>
      <a href="/color-select" className="item">
        Color Select
      </a>
      <a href="/translate" className="item">
        Translate
      </a>
      <a href="/search" className="item">
        Wiki Search
      </a>
      <a href="/all" className="item">
        All Widgets
      </a>
    </div>
  );
};

export default Header;

and then import that component into our application.

App.js
import React from "react";

import Header from "./components/Header";
import Route from "./components/Route";
import ColorSelect from "./components/ColorSelect";
import Translate from "./components/Translate";
import Accordion from "./components/Accordion";
import Search from "./components/Search";

export default () => {
  return (
    <div className="ui container">
      <Header />      <Route path="/">
        <Accordion />
      </Route>
      <Route path="/color-select">
        <ColorSelect />
      </Route>
      <Route path="/translate">
        <Translate />
      </Route>
      <Route path="/search">
        <Search />
      </Route>
      <Route path="/all">
        <Accordion />
        <ColorSelect />
        <Translate />
        <Search />
      </Route>
    </div>
  );
};

Header navigation working

This is great but currently the browser is completely reloading the whole page every time we click on one of these nav items. That means we are reloading all our JavaScript and CSS every time we click on a nav link. Let’s improve this.

Preventing Full Page Reload

To start let us create a new component called Link that will allow us to add event handlers to links. As props for this new component we will feed it everything that a normal link requires, including CSS classes, a destination href, and we will use the children prop to feed it the link text.

/components/Link
import React from "react";

const Link = ({ className, href, children }) => {
  return (
    <a className={className} href={href}>
      {children}
    </a>
  );
};

export default Link;

We then replace all our links in our Header with our shiny new Links component.

components/Header.js
import React from "react";

import Link from "./Link";

const Header = () => {
  return (
    <div className="ui secondary pointing menu">
      <Link href="/" className="item">
        Accordion
      </Link>
      <Link href="/color-select" className="item">
        Color Select
      </Link>
      <Link href="/translate" className="item">
        Translate
      </Link>
      <Link href="/search" className="item">
        Wiki Search
      </Link>
      <Link href="/all" className="item">
        All Widgets
      </Link>
    </div>
  );
};

export default Header;

and we can see that all the information we needed gets passed in as props, just like if these were plain HTML links.

Prevent Page Reload

Now we want to go about adding our new functionality, which is to prevent the page reload. To start we will add a click event handler to our Link components.

./components/Link.js
import React from "react";

const Link = ({ className, href, children }) => {
  // prevent full page reload
  const onClick = (event) => {    event.preventDefault();  };
  return (
    <a className={className} href={href} onClick={onClick}>      {children}
    </a>
  );
};

export default Link;

Using the preventDefault() method we have successfully stopped the page from reloading, as well as doing anything else. Now we need change the visibility of the URL and the content without actually reloading the page.

Change The URL

So what is the point of even changing the URL if we can just change the content dynamically without reloading the page? Simply because users expect to be able to bookmark certain portions of your site so that they can return to them. It is not strictly necessary for functionality, but is absolutely best practice.

There is a method built into the browser to manually update the URL. window.history.pushState() MDN: history.pushState

Knowing that we can add that to the click listener on our Link component

components/Link.js
import React from "react";

const Link = ({ className, href, children }) => {
  // prevent full page reload
  const onClick = (event) => {
    event.preventDefault();
    window.history.pushState({}, "", href)  };

  return (
    <a className={className} href={href} onClick={onClick}>
      {children}
    </a>
  );
};

export default Link;

and note that we passed in the href prop as the url to update to.

Communicate URL change to Route

We are successfully updating the URL when we click the Link components now. And now we need to alert our routes that the URL has changed, so that they can update the content in the window. We can do this with another native window method called window.dispatchEvent() and a React method called PopStateEvent()

components/Link.js
import React from "react";

const Link = ({ className, href, children }) => {
  
  const onClick = (event) => {
    // prevent full page reload
    event.preventDefault();
    // update url
    window.history.pushState({}, "", href);

    // communicate to Routes that URL has changed
    const navEvent = new PopStateEvent('popstate');    window.dispatchEvent(navEvent);  };

  return (
    <a className={className} href={href} onClick={onClick}>
      {children}
    </a>
  );
};

export default Link;

which then gets received in our Route component like so.

components/Route.js
import { useEffect } from 'react';
const Route = ({ path, children }) => {
    useEffect(() => {        // define callback as separate function so it can be removed later with cleanup function        const onLocationChange = () => {            console.log('Location Change');        }        window.addEventListener('popstate', onLocationChange);        // clean up event listener        return () => {            window.removeEventListener('popstate', onLocationChange)        };    }, [])
    return window.location.pathname === path
    ? children
    : null;
}

export default Route;

What we did there was create a custom event, like “click”, and we called it popstate, and told that event to get triggered every time the URL changes. Then we added an event listener to our Route component, and said that every time the event popstate occurs we fire the function onLocationChange(), which currently just creates a console log. Then we added a cleaner function to clean up the event listener.

If any part of this is confusing you will want to reference the post where we learned about the useEffect hook here Ncoughlin: React Hooks useEffect and then we learned about the CleanUp Function here Ncoughlin: React Throttling API Requests #cleanup-function

And this is currently working, every time we click on a link we are getting FIVE console logs (one for each route).

Get Route Component to Re-render Itself

Now that the Route is able to detect when the URL has changed, we are going to introduce a piece of state that is going to cause the Route to re-render itself. Remember that a component always re-renders when a piece of it’s state changes, so that is what we are going to do, introduce a state and update it every time the URL changes.

components/Route.js
import { useEffect, useState } from 'react';

const Route = ({ path, children }) => {
    // state to track URL and force component to re-render on change
    const [currentPath, setCurrentPath] = useState(window.location.pathname);
    useEffect(() => {
        // define callback as separate function so it can be removed later with cleanup function
        const onLocationChange = () => {
            // update path state to current window URL
            setCurrentPath(window.location.pathname);        }

        // listen for popstate event
        window.addEventListener('popstate', onLocationChange);

        // clean up event listener
        return () => {
            window.removeEventListener('popstate', onLocationChange)
        };
    }, [])

    return currentPath === path    ? children
    : null;
}

export default Route;

In the third highlight we changed the boolean to compare the state currentPath instead of the current URL window.location.pathname, this is just for clarity as the purpose of the state to re-render the component has already been achieved, the component would still work with the window pathname as we have set currentPath to be equal to it anyways.

Some users expect that you will open a link in a new tab if you hold the command key, so we are going to implement that functionality.

components/Link.js
import React from "react";

const Link = ({ className, href, children }) => {
  
  const onClick = (event) => {
    // if ctrl or meta key are held on click, allow default behavior of opening link in new tab
    if (event.metaKey || event.ctrlKey) {      return;    }
    // prevent full page reload
    event.preventDefault();
    // update url
    window.history.pushState({}, "", href);

    // communicate to Routes that URL has changed
    const navEvent = new PopStateEvent('popstate');
    window.dispatchEvent(navEvent);
  };

  return (
    <a className={className} href={href} onClick={onClick}>
      {children}
    </a>
  );
};

export default Link;

Inside of our check above you can see event.metaKey and event.ctrlKey, these are boolean properties of the mouse event. You can view a full list of available mouse event properties here MDN: MouseEvent

GitHub Repo

Ncoughlin: React Widgets