Building a client side router in React with event hooks (pt1: events)

Mike Talbot ⭐ - Sep 8 '21 - - Dev Community

TLDR;

I'm making a pluggable widget component with front end and serverless back end parts. This article is the first in the series and covers the usage of custom events in React to build a router.

  • Handling events in React
  • Raising custom events

Overview

I'm embarking on a collaborative project to build a serverless widget for the 4C content creator community that I've recently joined.

The first thing that this project needs is a router on the client side and as I thought I'd use React, the first thing I thought of was React Router. But then I thought, it's just a client side router and that might make an interesting opportunity to get into the heart of that problem and allow me to understand Routers more.

There's also a thing about React Router I don't like so much. I always end up writing a wrapper around it so I can dynamically register routes in a declarative fashion rather than imperatively writing them inside the JSX.

// What I want

import "./something-that-declares-routes.js"

register("/some/route/:id", <SomeComponent color="blue"/>)

export default function App() {
    return <Router />
}

Enter fullscreen mode Exit fullscreen mode
// Rather than

import "./something-that-declares-routes.js"
import {declaredRoutes} from "./declared-routes.js"

export default function App() {
     return <Router>
         <SomeComponent color="blue" path="/some/route/:id" />
         {declaredRoutes.map((route) => (<route.Component 
            key={route.path} path={route.path}/>)}
    </Router>
}
Enter fullscreen mode Exit fullscreen mode

What is a router?

So ok, what do we want from a router? We want to be able to specify a pattern of URLs supplied to our app in order to convert them into some function to be called. The function should also be able to take parameters from a route so:

   /some/:id/route?search&sort
Enter fullscreen mode Exit fullscreen mode

Calls some registered function or component with the id, search and sort parameters from a url like this /some/abc123/route?search=something&sort=name,desc

register("/some/:id/route?search&sort", <ShowInfo color="blue"/>)

function ShowInfo({id, search, sort, color}) {
   return /* something */
}
Enter fullscreen mode Exit fullscreen mode

The URL

So for routes to work we have to deal with the window.location object and know when it changes... either because we've navigated ourselves or the user has pressed the Back or Forward buttons.

From the location we will need to match routes based on the pathname and extract variables from the pathname and search properties to pass to our component.

The browser gives us an onpopstate event when the user navigates using the buttons, but there is no event for the navigation to a new URL so we are going to have to deal with that ourselves.

Events

Let's keep our code simple by faking onpopstate events when the user navigates around our app using links.

I like events, I use events everywhere in my code to loosely couple components. We've seen above that we will need to raise and handle events quite frequently so the first step on the journey is to build some tools to aid with that process.

In this first part of the article we will create some useful functions to raise and handle events both inside and outside React components.

The Plan

Because we are working with browser standard events I decided to just press the existing methods on window into service. However, I want to be able to pass custom properties to a handler function as additional parameters, rather than creating dozens of custom events, so we will decorate up standard Event instances with the parameters passed along with the event, we'll do this so we don't accidentally conflict with any standard properties.

 Handling events

Our first function is then: one to attach a handler and deal with these extra properties, returning a method to detach the handler later.

export function handle(eventName, handler) {
  const innerHandler = (e) => handler(e, ...(e._parameters || []))
  window.addEventListener(eventName, innerHandler)
  return () => window.removeEventListener(eventName, innerHandler)
}
Enter fullscreen mode Exit fullscreen mode

Here we create an inner handler that uses a _parameters property on the event object to pass additional parameters to the handler.

Turning this into a hook for React is then child's play:

export function useEvent(eventName, handler) {
  useLayoutEffect(() => {
    return handle(eventName, handler)
  }, [eventName, handler])
}
Enter fullscreen mode Exit fullscreen mode

Raising events

Writing a function to raise these events with custom parameters is also pretty easy:

export function raise(eventName, ...params) {
  const event = new Event(eventName)
  event._parameters = params
  window.dispatchEvent(event)
  return params[0]
}
Enter fullscreen mode Exit fullscreen mode

Note how we return the first parameter - that's an Inversion of Control helper, we might be raising events looking for return values, and this gives us an easy way of doing that.

handle("get-stuff", (list)=>list.push("I'm here"))
// ...
handle("get-stuff", (list)=>list.push("Another choice"))
// ...
for(let stuff of raise("get-stuff", [])) {
   console.log(stuff)
}
Enter fullscreen mode Exit fullscreen mode

By returning the first parameter we write a lot less boilerplate.

When we are working with events like onPopState we also want to decorate the event object with parameters (like the state for the location) so we do need another function to deal with this circumstance, that we will use every now and again:

export function raiseWithOptions(eventName, options, ...params) {
  const event = new Event(eventName)
  Object.assign(event, options)
  event._parameters = params
  window.dispatchEvent(event)
  return params[0]
}
Enter fullscreen mode Exit fullscreen mode

This one is very similar, just it decorates the custom event with the options object passed in.

Bonus: Redrawing things when events happen

We may well want to get our React components to redraw based on events that have changed some global state. There's an easy way to do that with a useRefresh hook that can either cause a refresh or register a function that will refresh after a sub function is called.

import { useEffect, useMemo, useRef, useState } from "react"

export function useRefresh(...functions) {
    const [, refresh] = useState(0)
    const mounted = useRef(true)
    useEffect(() => {
        mounted.current = true
        return () => (mounted.current = false)
    }, [])
    const refreshFunction = useMemo(
        () =>
            (...params) => {
                if (params.length === 1 && typeof params[0] === "function") {
                    return async (...subParams) => {
                        await params[0](...subParams)
                        refreshFunction()
                    }
                }
                for (let fn of functions) {
                    if (fn) {
                        fn(...params)
                    }
                }
                if (mounted.current) {
                    refresh((i) => i + 1)
                }
            },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [...functions]
    )
    return refreshFunction
}

Enter fullscreen mode Exit fullscreen mode

This creates us a utility function that causes React to redraw the component. It's handy for lots of things but here we can just use it to do a refresh on an event:

function Component() {
   const refresh = useRefresh()
   useEvent("onPopState", refresh)
   return null
}
Enter fullscreen mode Exit fullscreen mode

The useRefresh function takes a list of other functions to call. This is sometimes useful, especially for debugging

    const refresh = useRefresh(()=>console.log("Redrawing X"))
Enter fullscreen mode Exit fullscreen mode

And the returned function can be made to wrap a refresh around something:

function Component() {
     const refresh = useRefresh()
     // do something with global state on window.location.search
     return <button onClick={refresh(()=>window.location.search = "?x"}>Set X</button>
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this first part we've seen how to easily raise and handle events in React. Below is the running widget that uses these techniques.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player