A <Router /> with Hooks and Suspense

Navi is a new kind of router for React. It lets you declaratively map URLs to content, even when that content is asynchronous.

So the React team just released a new API called Hooks. It’s amazing. It lets you declaratively model state and side effects. You’ve probably read about it elsewhere on the internet, so I’m not going to tell you about hooks themselves, but…

With a new API comes new possibilities. And to cut to the chase, Navi’s new <Router> component uses Hooks and Suspense to make routing simpler than ever before. It makes all sorts of things possible — you can even add animated loading transitions in just 3 lines of code.

So how do you use these new superpowered hooks? We’ll get to that in a moment. But before we do, what the hell is a <Router>?

#How many routes could a
<Router routes /> route…

It can route as many as you’d like, because Navi lets you dynamically import() entire routing trees on demand. But how?

The trick is in Navi’s method for declaring routes. For simple routes, you can just use Navi’s mount() and route() functions. But for heavier content, you can declare dependencies on asynchronous data and views using async/await — or you can even split out entire routing trees using lazy().

<Router routes={
  mount({    '/': route({      title: 'My Shop',
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/products': lazy(() => import('./productsRoutes')),  })
} />

If you take a look at this example, you’ll see that you’ve got yourself a <Router> with a couple routes, including a shop’s landing page and a lazily loadable /products URL.

Let’s build the rest of the shop.

For your next step, you’ll need to decide where to render the current route’s view element. And to do that, you just plonk down a <View /> element somewhere inside your <Router>.

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <View />    </Layout>
  </Router>,
  document.getElementById('root')
)

Simple, huh? But waaait a minute… what if you view the lazily loadable /products URL? Then the route will be loaded via an import(), which returns a Promise, and so at first there’ll be nothing to render. Luckily, React’s new <Suspense> feature lets you declaratively wait for promises to resolve. So just wrap your <View> in a <Suspense> tag, and you’re off and racing!

index.js
product.js
Landing.js
Layout.js
api.js
styles.css
import { mount, route, lazy } from 'navi'
import React, { Suspense } from 'react'
import ReactDOM from 'react-dom'
import { Router, View } from 'react-navi'
import api from './api'
import Landing from './Landing'
import Layout from './Layout'

const routes =
  mount({
    '/': route({
      title: "Hats 'n' Flamethrowers 'r' Us",
      getData: () => api.fetchProducts(),
      view: <Landing />,
    }),
    '/product': lazy(() => import('./product')),
  })

ReactDOM.render(
  <Router routes={routes}>
    <Layout>
      <Suspense fallback={null}>
        <View />
      </Suspense>
    </Layout>
  </Router>,
  document.getElementById('root')
)
Build In Progress

#Bro, just give me the hooks?

Ok, so you’ve seen how to render a route’s view. But did you notice that your route also defines a getData() function?

route({
  title: 'My Shop',
  getData: () => api.fetch('/products'),
  view: <Landing />,
})

How do you access the data? With React hooks!

Navi’s useCurrentRoute() hook can be called from any function component that is rendered within the <Router> tag. It returns a Route object that contains everything that Navi knows about the current URL.

index.js
product.js
Landing.js
Layout.js
api.js
styles.css
import React from 'react'
import { Link, useCurrentRoute } from 'react-navi'

export default function Landing() {
  // useCurrentRoute returns the lastest loaded Route object
  let route = useCurrentRoute()
  let data = route.data
  let productIds = Object.keys(data)

  console.log('views', route.views)
  console.log('url', route.url)
  console.log('data', route.data)
  console.log('status', route.status)
  
  return (
    <ul>
      {productIds.map(id => 
        <li key={id}>
          <Link href={`/product/${id}`}>{data[id].title}</Link>
        </li>
      )}
    </ul>
  )
}
Build In Progress

    Ok. So far, so good. But imagine that you’ve just clicked a link to /products — which is dynamically imported. It’s going to take some time to fetch the route, so what are you going to display in the meantime?

    #Visualizing loading routes

    When routes take a long time to load, you’ll want to display some sort of loading indicator to the user — and there a number of approaches that you could take. One option would be to show a fallback with <Suspense>, just as with the initial load. But this looks a bit shit.

    terrible looking loading

    What you’d really like to do is to display a loading bar over the current page while the next route loads… well, unless the transition only takes 100ms. Then you probably just want to keep displaying the current page until the next one is ready, because showing a loading bar for only 100ms also looks a bit shit.

    loading indicator with no delay

    There’s just one problem. Doing this with currently available tools is ridiculously hard, right? Well actually… You can add it to the above demo in just 3 lines of code, using the useLoadingRoute() hook and the react-busy-indicator package.

    index.js
    product.js
    Landing.js
    Layout.js
    api.js
    styles.css
    import BusyIndicator from 'react-busy-indicator@1.0.0'
    import React from 'react'
    import { Link, useLoadingRoute } from 'react-navi'
    
    export default function Layout({ children }) {
      // If there is a route that hasn't finished loading, it can be
      // retrieved with `useLoadingRoute()`.
      let loadingRoute = useLoadingRoute()
    
      return (
        <div className="Layout">
          {/* This component shows a loading indicator after a delay */}
          <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
          <header className="Layout-header">
            <h1 className="Layout-title">
            <Link href='/' prefetch={null}>
              Hats 'n' Flamethrowers 'r' Us
            </Link>
            </h1>
          </header>
          <main>
            {children}
          </main>
        </div>
      )
    }
    Build In Progress

    Go ahead and try clicking between these pages a few times. Did you notice how smooth the transition back to the index page is? No? It was so smooth that you didn’t notice that there’s actually a 100ms delay? Great! That’s exactly the experience that your users want.

    Here’s how it works: useCurrentRoute() returns the most recent completely loaded route. And useLoadingRoute() returns any requested-but-not-yet-completely-loaded route. Or if the user hasn’t just clicked a link, it returns undefined.

    Want to display a loading bar while pages load? Then just call useLoadingRoute(), check if there’s a value, and render a loading bar if there is! CSS transitions let you do the rest.

    #More neat tricks

    I’m not going to drop the entire set of guides, API reference, and docs on integrating with other tools on you right now. You’re reading a blog post, so you might not have time for all that juicy information. But let me ask you a question:

    What happens if the route doesn’t load?

    One of the things about asynchronous data and views is that sometimes they don’t bloody work. Luckily, React has a great tool for dealing with things that don’t bloody work: Error Boundaries.

    Let’s rewind for a moment to the <Suspense> tag that wraps your <View />. When <View /> encounters a not-yet-loaded route, it throws a promise, which effectively asks React to please show the fallback for a moment. You can imagine that <Suspense> catches that promise, and then re-renders its children once the promise resolves.

    Similarly, if <View /> finds that getView() or getData() have thrown an error, then it re-throws that error. In fact, if the router encounters a 404-page-gone-for-a-long-stroll error, then <View /> will throw that, too. These errors can be caught by Error Boundary components. For the most part, you’ll need to make your own error boundaries, but Navi includes a <NotFoundBoundary> to show you how its done:

    index.js
    product.js
    Landing.js
    Layout.js
    api.js
    styles.css
    import BusyIndicator from 'react-busy-indicator@1.0.0'
    import React from 'react'
    import { Link, NotFoundBoundary, useLoadingRoute } from 'react-navi'
    
    export default function Layout({ children }) {
      // If there is a route that hasn't finished loading, it can be
      // retrieved with `useLoadingRoute()`.
      let loadingRoute = useLoadingRoute()
    
      return (
        <div className="Layout">
          {/* This component shows a loading indicator after a delay */}
          <BusyIndicator isBusy={!!loadingRoute} delayMs={200} />
          <header className="Layout-header">
            <h1 className="Layout-title">
            <Link href='/'>
              Hats 'n' Flamethrowers 'r' Us
            </Link>
            </h1>
          </header>
          <main>
            <NotFoundBoundary render={renderNotFound}>
              {children}
            </NotFoundBoundary>
          </main>
        </div>
      )
    }
    
    function renderNotFound() {
      return (
        <div className='Layout-error'>
          <h1>404 - Not Found</h1>
        </div>
      )
    }
    Build In Progress

    #But that’s not all!

    Ok, so I think we’re about out of time for this blog post. But there’s a bunch more details in the docs:

    You can also check out the examples directory of the Navi repository for full code examples, including:

    Oh, and did I mention that Navi is build with TypeScript, so the typings are first-class?

    #Help me, Obi-Wan Kenobi. You’re my only hope.

    Ok, so even if you’re not Obi-Wan Kenobi, I’d really appreciate your help.

    Actually, a lot of little things are way more helpful than they might seem. Can you try Navi out and file an issue for any missing features? Awesome. Can you make tiny improvements to the docs as you learn? Radical. Can you put something small online and add it to the README's list of sites using Navi? Phenomenal.

    With that said, there are a few big ticket items that I’d love some help with:

    • Navi needs some dev tools. Internally, all data is represented as simple objects called Chunks — which are then reduced into Route objects with a Redux-like reducer. As a result of this design, it should be possible for dev tools to provide a useful window into what’s going on, along with time-travel capability — but I want to leave this task for the community. So if you want to be the person who made Navi’s dev tools, let’s discuss the details 🤓

    • Matcher functions like mount(), lazy() and route() are just Generator Functions. In fact, it’s entirely possible to create custom matchers. For instance, you could create a withTimeout() matcher that switches routes based on how long they take to load. And if you do create useful custom matchers, send a tweet or DM to @james_k_nelson so I can spread the word 🤩

    • If you can provide a translation for even a single page from the docs, I’ll be forever grateful ❤️

    • If you’d like to see this project grow, please give it a 🌟Star on Github!

    #One more thing…

    Navi now has experimental server rendering support. Matchers like route() and mount() have access to an entire Request object, including method, headers, and body. Your routes aren’t limited to matching just a view and data — they can also match a HTTP status and headers.

    You can now handle routing on the client, the server and in serverless functions with exactly the same code.

    Of course, you could already do some of this with Next.js — but if all you need is a router, then Navi is a lot smaller (and more flexible). If you’d like to know more about the difference, take a read through Navi vs. Next.js. Or if you just want the server rendering demo:

    index.js
    product.js
    Landing.js
    Layout.js
    api.js
    styles.css
    import { createMemoryNavigation, mount, route, lazy } from 'navi'
    import React, { Suspense } from 'react'
    import ReactDOMServer from 'react-dom/server'
    import { NaviProvider, View } from 'react-navi'
    import api from './api'
    import Landing from './Landing'
    import Layout from './Layout'
    
    async function main() {
      const routes =
        mount({
          '/': route({
            title: "Hats 'n' Flamethrowers 'r' Us",
            getData: (request) => {
              console.log('headers', request.headers)
              return api.fetchProducts()
            },
            view: <Landing />,
            status: 200,
          }),
          '/product': lazy(() => import('./product')),
        })
    
      // Creates a "navigation" object, which contains the same functionality
      // as the browser `<Router>` object, but handled in-memory.
      const navigation = createMemoryNavigation({
        routes,
        request: {
          headers: {
            'authorization-token': '123',
          },
          url: window.location.pathname
        }
      })
    
      // Wait for the navigation object to stabilise, so it can be rendered
      // immediately.
      await navigation.steady()
    
      console.log('status', navigation.getCurrentValue().status)
    
      // As `renderToString()` doesn't support `<Suspense>`, instead of using
      // a `<Router>` with a `<Suspense>`, you'll need to manually pass your
      // navigation object to a `<NaviProvider>` component.
      let html = ReactDOMServer.renderToString(
        <NaviProvider navigation={navigation}>
          <Layout>
            <View />
          </Layout>
        </NaviProvider>
      )
    
      document.body.innerHTML = html
    }
    
    main()
    Build In Progress

      As of right now, there’s no official package for integrating Navi with Express. There should be one. Please make one. I’ll tell the world about it.

      But seriously, I will tell the world about anything you make with Navi. Here’s how I’ll do it: the Frontend Armory weekly newsletter. Want to hear about awesome React shit that other readers are making? Join it. You know you want to.

      Join Frontend Armory for Free »

      But that’s it from me today. Thanks so much for reading. I’ve poured my heart into this project because I believe that it’ll make your life as a React developer so much easier. Now go build something amazing!

      P.S. I haven’t forgotten about the React in Practice course — I’m working on it right now, and it’ll include hooks. I’ll have more more details real soon.

      About James

      Hi! I've been playing with JavaScript for over half my life, and am building Frontend Armory to help share what I've learned along the way!

      Tokyo, Japan