How to Use Vanilla JS Routers with React

17 Sep 2021

13 minute read

When you’re working with React, before too long, the question will inevitably arise:

“Uh … how do I change pages with React?”

It’s a silly question, isn’t it? This is what the web was built to do, man! Add pages, link to pages, change pages when you click on aforementioned links. The URL is the foundation on which everything else is built: the axis about which the entire insane machine of internet turns. Strange to think that the framework doesn’t include anything for implementing something so fundamental, isn’t it?

To find out why this is so, let’s have a tiny history lesson so we can get some context.

First, some background context

If we cast our minds back to 2010 or thereabouts, the question of how to “change pages”, or link between different parts of your site was a pretty simple one:

And voila! The magic of the internet in concert with HTML and the browser itself would fetch the new page, render it on the screen, and you’d be on your merry way. No JavaScript or further effort necessary.

With React and other single-page-application frameworks, things work a little differently. Your “pages”, in most instances, are not individual HTML pages on a backend server: they are dynamically constructed chunks of HTML which are build on the client, or in the browser itself.

Dynamic HTML generation isn’t necessarily new in and of itself. Unless your webpage is nothing but static server-generated content, you’ll probably need some kind of dynamic content on your website. React’s innovation was to turn your entire app into dynamically generated HTML, bundled with an integrated event and state-management system to declaratively coordinate updates to that HTML.

If you’ve built your entire site with nothing but React, all of the content on your site therefore consists of these dynamic chunks of JavaScript-generated HTML. This doesn’t stop you from creating something that resembles a traditional “page”, per se. You can add as many component “pages” as you like, each one representing a distinct area in your app. You can also add in buttons to swap these chunks of dynamic HTML in and out of the browser window.

If you don’t look too closely, this can create the appearance of different “pages” being loaded to the end user …

… but that pesky URL bar won’t change at all!

… but that’s the whole point of React!

This behavior is, ironically, exactly what React promises right on the label: the library’s whole purpose is to enable you to build one single application, loaded in the browser by one single HTML file, which can dynamically generate your “pages” right then and there. This mitigates the long round-trip retrieval and content-loading times associated with fetching individual pages from a server, and gives the visitors to your site a much snappier, faster, and smoother web experience.

And yet, if you’re going to build your entire site using only React, you’re going to be missing out on a lot of important functionality:

The history of the web has established these UX expectations in our minds, and so we’re going to have to add this functionality back into our apps if we’re going to use React to power the whole thing.

Enter React Router

Fortunately, there are libraries out there which can mimic this routing behavior, and one of the most popular is React Router. It enables you to add this functionality back into your app. React Router and similar React-based libraries work by wrapping up the various bits of your app into special routing components:

import { React, ReactDOM } from 'https://unpkg.com/es-react'
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link
} from "https://unpkg.com/react-router/umd/react-router.min.js"
import htm from 'https://unpkg.com/htm?module'

const html = htm.bind(React.createElement)

const Example = () => {
  return html`
    <div>
      <nav>
        <Link to="/page-one">Go To Page One</Link>
        <Link to="/page-two">Go To Page Two</Link>
      </nav>

      <Router>
        <Switch>
          <Route path="/page-one">
            <PageOne />
          </Route>
          <Route path="/page-two">
            <PageOne />
          </Route>
        </Switch>
      </Router>
    </div>
  `
}

ReactDOM.render(html`<${Example} />`, document.getElementById('app-root')

And voila! You’ve got an app that can link between pages, reload, and access pages via a URL entered straight into the URL bar:

… so why not just use React Router?

Frontend routing isn’t a React-only problem, there are a lot of other “vanilla-JS” or platform-agnostic routers out there which can implement routing functionality without being dependent on a specific view library like React:

So why would you use one of these over React Router?

Reasons to use React Router

  1. It’s very ubiquitous: you’ll find a lot of code examples, blog posts, and tutorials that feature React Router.
  2. It offers accessible routing out of the box. You can be assured that your app won’t suffer from the major problems associated with multi-route SPA accessibility (although these features are not all that difficult to add back in).
  3. It is 100% React, which makes it easier to use if you don’t have a firm grasp on how to mix vanilla JS techniques with React techniques.

Reasons to use a platform-agnostic router

  1. React Router conceals most of its logic within the library components themselves. These kind of “magical” libraries can be very useful in many cases. When functionality is wrapped up in a React component, it’s more liable to be declarative, which makes working with components easier. But the magic comes at a cost: if you have to implement an unconventional or custom feature, then you’re gonna have a hard time.
  2. If you ever want to switch libraries (like from React to Vue), then you can take most of your existing routing logic with you without having to refactor to a new library or framework.
  3. Libraries like Page.js have some features that React Router doesn’t have. Lots of routing libraries have a concept of “enter” and “exit” callbacks, which give you the opportunity to run setup and teardown code when you visit and leave a particular route. Page.js has these callbacks built in, but to achieve the same effect in React Router, you have to build it into your components themselves, which (arguably) blurs the separation of concerns between the router and the routes themselves, and makes your components more complicated and tricky to refactor.
  4. Many of these libraries are smaller than React Router. Minified and gzipped, Page.js comes out to 3.9 KB of code, whereas react-router-dom comes out to 9.5 KB. A pretty negligible amount, but if you don’t have much wiggle room with the size or your app, then an alternative like Page.js is the clear winner.

So! If these pros sound appealing to you, then let’s learn how to write a React app which uses Page.js.

Required background knowledge

Before we dive in, here are some pre-reqs that you’ll want to familiarize yourself with in order to understand this tutorial. We won’t be diving too deep into any of this – a basic orientation is all that is required:

Initial setup

Let’s do some initial setup. It’s 2021, and I’m sick of Babel and Webpack, aren’t you? Let’s dump some complexity and use some nice modern JS features to keep our code simple. We’ll use:

import { React, ReactDOM } from 'https://unpkg.com/es-react'
import htm from 'https://unpkg.com/htm?module'

const html = htm.bind(React.createElement)

const App = () => {
    return html`
        <div>
          <p>Look ma! No node_modules!</p>
        </div>
    `
}

ReactDOM.render(App(), document.getElementById('app-root'))

And would you look at that, frontend code produced with nothing but browser-based tools:

Import page.js, initialize the router, set a base URL

Now that we’ve got a bare-bones app setup, let’s import Page.js and get it running. useEffect comes into play here: we only want to initialize the router once, so we’ll make our initial call to the page function there.

I’ll also be setting the base URL to /# here. This is to prevent our backend server from returning 404s when we link between different pages or perform reloads. If you have easy access to your backend server and can define appropriate handlers for every frontend route, then you can omit this step. This Stack Overflow answer has more info if you’d like to dive deeper into why we need to do this.

import { React, useEffect, ReactDOM } from 'https://unpkg.com/es-react'
import htm from 'https://unpkg.com/htm?module'
import page from 'https://unpkg.com/page@1.11.6/page.mjs'

const html = htm.bind(React.createElement)

const App = () => {
  useEffect(() => {
    page.base("/#")
    page()
  }, [])

  return html`
    <div>
      <p>Look ma! No node_modules!</p>
    </div>
  `
}

ReactDOM.render(App(), document.getElementById('app-root')

Defining our routes

So, first up: let’s define our routes. According to the docs, it’s a matter of calling the page function with two things:

  1. The name of our desired route
  2. A callback to fire when we hit that route

Let’s add some route definitions, and use some silly stub functions to show that they actually do something when you hit each route:

import { React, useEffect, ReactDOM } from 'https://unpkg.com/es-react'
import htm from 'https://unpkg.com/htm?module'
import page from 'https://unpkg.com/page@1.11.6/page.mjs'

const html = htm.bind(React.createElement)

const App = () => {
  const [pokedexVideoId, setPokedexVideoId] = useState("oyhQQIeU-JY")

  page("/charmander", () => setPokedexVideoId("oyhQQIeU-JY"))
  page("/snorlax", () => setPokedexVideoId("GXNc8QDH-Dc"))
  page("/bulbasaur", () => setPokedexVideoId("F_-x2ErAtsA"))

  useEffect(() => {
    page.base("/#")
    page()
  }, [])

  return html`
    <div>
      <nav>
        <h1>Pokedex Browser</h1>
        <a href="/charmander">Charmander</a>
        <a href="/snorlax">Snorlax</a>
        <a href="/bulbasaur">Bulbasaur</a>
      </nav>
      
      <iframe src="https://www.youtube.com/embed/${pokedexVideoId}"></iframe>
    </div>
  `
}

ReactDOM.render(html`<${App} />`, document.getElementById('app-root')

Interlude: freedom of movement

That last example was kind of interesting, right? One of the things that distinguishes Page.js from React Router is that you’re able to directly define the callback that’s fired when a route changes.

Compared to React Router, this gives you a much greater degree of control over what happens when a route changes. React Router is deeply hooked into React API and ethos: the API practically demands that you render a component and nothing but a component on a route change. From the React Router docs:

import { React, useEffect, ReactDOM } from 'https://unpkg.com/es-react'
import { Router, Route, Switch } from "https://unpkg.com/react-router/umd/react-router.min.js"
import htm from 'https://unpkg.com/htm?module'

const html = htm.bind(React.createElement)

const Example = () => {
  return html`
    <Router>
      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/dashboard">
          <Dashboard />
        </Route>
      </Switch>
    </Router>
  `
}

ReactDOM.render(html`<${Example} />`, document.getElementById('app-root')

When Page.js detects a route change, however, you can do … anything. You could do something as big as swapping out the topmost component being rendered by your app, or something as small as changing the video ID on an embedded YouTube iframe. The choice is yours, depending on the size of the problem you’re trying to solve.

Speaking of swapping out components on route changes, let’s get back to the task at hand:

Rendering our views

It’s just a small leap from here if we want to change pages (or components) instead of switching YouTube video IDs around. We can use the exact same concept – tracking a video ID in state, and switching the video ID on route change – and apply it instead to components.

This time, we’ll make a set of page components, wrap them up in an object for ease of organization, and render them conditionally based on state:

import { React, useEffect, ReactDOM } from 'https://unpkg.com/es-react'
import htm from 'https://unpkg.com/htm?module'
import page from 'https://unpkg.com/page@1.11.6/page.mjs'

const html = htm.bind(React.createElement)

const Charmander = () => {
  return html`
    <div>
      <h1>Charmander's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/oyhQQIeU-JY"></iframe>
    </div>
  `
}

const Bulbasaur = () => {
  return html`
    <div>
      <h1>Bulbasaur's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/GXNc8QDH-Dc"></iframe>
    </div>
  `
}

const Snorlax = () => {
  return html`
    <div>
      <h1>Snorlax's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/F_-x2ErAtsA"></iframe>
    </div>
  `
}

const routes = {
  charmander: Charmander,
  snorlax: Snorlax,
  bulbasaur: Bulbasaur 
}

const App = () => {
  const [route, setRoute] = useState("charmander")

  page("/charmander", () => setRoute("charmander"))
  page("/snorlax", () => setRoute("snorlax"))
  page("/bulbasaur", () => setRoute("bulbasaur"))

  useEffect(() => {
    page.base("/#")
    page()
  }, [])

  return html`
      <div>
        <nav>
          <h1>Pokedex Browser</h1>
          <a href="/charmander">Charmander</a>
          <a href="/snorlax">Snorlax</a>
          <a href="/bulbasaur">Bulbasaur</a>
        </nav>

        <${routes[route]} />
      </div>
  `
}

ReactDOM.render(html`<${App} />`, document.getElementById('app-root')

Accessibility

The final piece of this is accessibility.

The great thing about backend-driven websites is that they are pretty much accessible out of the box. Assistive technology knows how to detect page changes and announce them properly to users of this technology.

With 100% frontend-driven sites and apps, we need to give assistive technology a few additional hints in order for everything to function properly. In short, this consists of three things:

  1. Scrolling to the top of the new page on route change.
  2. Changing the document’s title so the screen reader will announce the new page.
  3. Programmatically resetting focus to something on the new page.

Those first two are pretty straightforward, although the last one is a tricky issue which doesn’t have a universally agreed upon best practice just yet. In this example, I’m going to go with focusing on the first heading of the new page. I’ve written a whole blog post on the topic if you’d like to dive deeper!

Here is the vanilla JS code for each of those tasks:

// 1. Scroll to the top of the page
const scrollTop = () => window.scrollTo({x: 0})

// 2. Change the title of the page
const changeTitle = title => document.title = title

// 3. Set focus on an element with a `focus-target` ID, with a little bit of extra code to ensure cross-browser compatibility 
const setFocus = () => {
    setTimeout(() => {
        const focusTarget = document.getElementById("focus-target")
        focusTarget.setAttribute('tabindex', '-1')
        focusTarget.focus()
        focusTarget.removeAttribute('tabindex')
    }, 0)
}

And here’s everything integrated into our Pokedex example:

import { React, useEffect, ReactDOM } from 'https://unpkg.com/es-react'
import htm from 'https://unpkg.com/htm?module'
import page from 'https://unpkg.com/page@1.11.6/page.mjs'

const html = htm.bind(React.createElement)

const Charmander = () => {
  return html`
    <div>
      <h1 id="focus-target">Charmander's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/oyhQQIeU-JY"></iframe>
    </div>
  `
}

const Bulbasaur = () => {
  return html`
    <div>
      <h1 id="focus-target">Bulbasaur's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/GXNc8QDH-Dc"></iframe>
    </div>
  `
}

const Snorlax = () => {
  return html`
    <div>
      <h1 id="focus-target">Snorlax's Pokedex Entry</h1>
      <iframe src="https://www.youtube.com/embed/F_-x2ErAtsA"></iframe>
    </div>
  `
}

const routes = {
  charmander: Charmander,
  snorlax: Snorlax,
  bulbasaur: Bulbasaur
}

const scrollTop = () => window.scrollTo({x: 0})
const changeTitle = title => document.title = title
const setFocus = () => {
    setTimeout(() => {
        const focusTarget = document.getElementById("focus-target")
        focusTarget.setAttribute('tabindex', '-1')
        focusTarget.focus()
        focusTarget.removeAttribute('tabindex')
    }, 0)
}

const App = () => {
  const [route, setRoute] = useState("charmander")

  const changeRoute = routeName => {
    setRoute(routeName)
    scrollTop()
    changeTitle(`Pokedex entry for ${routeName}`)
    setFocus()
  }

  page("/charmander", () => setRoute("charmander"))
  page("/snorlax", () => setRoute("snorlax"))
  page("/bulbasaur", () => setRoute("bulbasaur"))

  useEffect(() => {
    page.base("/#")
    page()
  }, [])

  return html`
      <div>
        <nav>
          <h1>Pokedex Browser</h1>
          <a href="/charmander">Charmander</a>
          <a href="/snorlax">Snorlax</a>
          <a href="/bulbasaur">Bulbasaur</a>
        </nav>

        <${routes[route]} />
      </div>
  `
}

ReactDOM.render(html`<${App} />`, document.getElementById('app-root')

I’ve added some focus ring styles so the focus change is nice and obvious to see:

And that’s it!

With accessibility taken care of, we’ve got a fully functioning, accessible, routable, linkable React application. Go forth and build what you will!

If you’ve got a comment, see an error in any of the demo code, or have a question, post a comment down below, or get in touch. I’d love to hear from you :)

- Andy

Leave a comment

Related Posts