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 />
}
// 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>
}
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
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 */
}
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)
}
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])
}
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]
}
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)
}
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]
}
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
}
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
}
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"))
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>
}
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.
Top comments (0)