TLDR;
I'm building a client side router as part of a project to create some useful Widgets for my community's blogs. In this article we cover parsing routes and parameters.
Motivation
I need a client side router so I can embed different widgets that are configured by an admin interface into my posts to get more information from my audience so I can make better content.
For example:
You can vote interactively in the widget below for the language you love... Click on a language and see the results for everyone who has voted so far (it updates in real time too).
And here you can click the one that you hate!!!
Cool huh?
Routing
In the first part of this article series we developed some basic event handling and raising so we could fake popstate
events.
In this part we are going to do the following:
- Create a method to declare routes
- Create a component to declare routes that uses the method above
- Create a component to render the right route, with any parameters
Declaring routes
First off we need to make an array to store our routes:
const routes = []
Next we need to export a method to actually declare one. We want to pass a path like /some/route/:with/:params?search&sort
, a React component to render with the route and then we'll have some options so we can order our declarative routes in case they would conflict. I'd also like to have Routers with different purposes (like a sidebar, main content, nav etc).
Example call (it's the one for the widgets above!):
register("/:id/embed", RenderMeEmbed)
The register
function:
export function register(path, call, { priority = 100, purpose = "general" }) {
if (!path || typeof path !== "string") {
throw new Error("Path must be a string")
}
Ok so now we have some parameters, it's time to split the path on the search string:
const [route, query] = path.split("?")
Next up, I want to be able to pass the register
function a Component function or an instantiated component with default props. So register("/", Root)
or register("/admin", <Admin color="red"/>)
.
if (typeof call === "function" || call._init) {
return add({
path: route.split("/"),
call,
priority,
purpose,
query: query ? query.split("&") : undefined
})
} else if (typeof call === "object" && call) {
return add({
path: route.split("/"),
priority,
purpose,
query: query ? query.split("&") : undefined,
call: (props) => <call.type {...call.props} {...props} />
})
}
So just in case there are some funny functions out there that look like objects (there are, but it's rare - I'm looking at you React.lazy()
!), I check whether the call
parameter is a function or has a special property. You can see we then call add
splitting up the route on the /
character and the query string on the &
.
The case of the instantiated React component makes a wrapper component that wraps the type
and the props
of the default and decorates on any additional props from the route.
add
itself is pretty straightforward:
function add(item) {
routes.push(item)
routes.sort(inPriorityOrder)
raise("routesChanged")
return () => {
let idx = routes.indexOf(item)
if (idx >= 0) routes.splice(idx, 1)
raise("routesChanged")
}
}
We add the route to the array, then sort the array in priority order. We raise a "routesChanged" event so that this can happen at any time - more on that coming up. We return a function to deregister the route so we are fully plug and play ready.
function inPriorityOrder(a, b) {
return +(a?.priority ?? 100) - +(b?.priority ?? 100)
}
Route Component
So we can declare routes in the JSX we just wrap the above function:
export function Route({ path, children, priority = 100, purpose = "general" }) {
const context = useContext(RouteContext)
useEffect(() => {
return register(`${context.path}${path}`, children, { priority, purpose })
}, [path, children, context, priority, purpose])
return null
}
We have added one complexity here, to enable <Route/>
within <Route/>
definitions, we create a RouteContext
that will be rendered by the <Router/>
component we write in a moment. That means we can easily re-use components for sub routes or whatever.
The <Route/>
renders it's child decorated with the route parameters extracted from the location
.
Code Splitting
To enable code splitting we can just provide a lazy()
based implementation for our component:
register(
"/admin/comment/:id",
lazy(() => import("./routes/admin-comment"))
)
Making sure to render a <Suspense/>
around any <Router/>
we use.
The Router
Ok so to the main event!
window.location
First off we need to react to the location changes. For that we will make a useLocation
hook.
export function useLocation() {
const [location, setLocation] = useState({ ...window.location })
useDebouncedEvent(
"popstate",
async () => {
const { message } = raise("can-navigate", {})
if (message) {
// Perhaps show the message here
window.history.pushState(location.state, "", location.href)
return
}
setLocation({ ...window.location })
},
30
)
return location
}
This uses useDebouncedEvent
which I didn't cover last time, but it's pretty much a wrapper of a debounce function around useEvent
's handler. It's in the repo if you need it.
You'll notice the cool thing here is that we raise a "can-navigate" event which allows us to not change screens if some function returns a message
parameter. I use this to show a confirm box if navigating away from a screen with changes. Note we have to push the state back on the stack, it's already gone by the time we get popstate
.
navigate
You may remember from last time that we need to fake popstate
messages for navigation. So we add a navigate
function like this:
export function navigate(url, state = {}) {
window.history.pushState(state, "", url)
raiseWithOptions("popstate", { state })
}
Router
const headings = ["h1", "h2", "h3", "h4", "h5", "h6", "h7"]
export function Router({
path: initialPath,
purpose = "general",
fallback = <Fallback />,
component = <section />
}) {
Ok so firstly that headings
is so when the routes change we can go hunting for the most significant header - this is for accessibility - we need to focus it.
We also take a parameter to override the current location (useful in debugging and if I ever make the SSR), we also have a fallback component and a component to render the routes inside.
const { pathname } = useLocation()
const [path, query] = (initialPath || pathname).split("?")
const parts = path.split("/")
The parsing of the location looks similar to the register function. We use the split up path
in parts
to filter the routes, along with the purpose
.
const route = routes
.filter((r) => r.purpose === purpose)
.find(
(route) =>
route.path.length === parts.length && parts.every(partMatches(route))
)
if (!route) return <fallback.type {...fallback.props}
path={path} />
We'll come to partMatches
in a moment - imagine it's saying either these strings are the same, or the route wants a parameter. This router does not handle wildcards.
If we don't have a route, render a fallback.
const params = route.path.reduce(mergeParams, { path })
const queryParams = query.split("&").reduce((c, a) => {
const parts = a.split("=")
c[parts[0]] = parts[1]
return c
}, {})
if (route.query) {
route.query.forEach((p) => (params[p] = queryParams[p]))
}
Next up we deal with the parameters, we'll examine mergeParams
momentarily. You can see we convert the query parameters to a lookup object, and then we look them up from the route :)
return (
<RouteContext.Provider path={path}>
<component.type {...component.props} ref={setFocus}>
<route.call {...params} />
</component.type>
</RouteContext.Provider>
)
Rendering the component is a matter of laying down the context provider and rendering the holder component, we need this component so we can search it for a heading in a moment. Then whichever route we got gets rendered with the parameters.
partMatches
This function is all about working out whether the indexed part of the path in the route is a parameter (it starts with a ":") or it is an exact match for the part of the current location. So it's a Higher Order Function that takes a route and then returns a function that can be sent to .filter()
on an array of route parts.
function partMatches(route) {
return function (part, index) {
return route.path[index].startsWith(":") || route.path[index] === part
}
}
mergeParams
Merge params just takes the index of the current part of the path and if the route wants a parameter it decorates the current value onto and object with a key derived from the string after the ":").
function mergeParams(params, part, index) {
if (part.startsWith(":")) {
params[part.slice(1)] = parts[index]
}
return params
}
setFocus - a little accessibility
So the final thing is to handle the accessibility. When we mount a new route, we will find the first most significant header within it, and focus that.
function setFocus(target) {
if (!target) return
let found
headings.find((heading) => (found = target.querySelector(heading)))
if (found) {
found.focus()
}
}
}
Conclusion
That's it, a declarative client side router with path and query parameters. You can check out the whole widget code here: