Client Side React Router: routes & parameters
Tags: react, javascript, tutorial, webdev
Published: 2021-09-09 17:47:12

## 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).

{% codesandbox n4dpo initialpath=/WWn-tszlY4dvj3e-4R5Yw/embed runonclick=0 %}

And here you can click the one that you hate!!!

{% codesandbox n4dpo initialpath=/AH0gIMzPZJSiXcElIcwBr/embed runonclick=0 %}

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:

```jsx
    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!):

```jsx
register("/:id/embed", RenderMeEmbed)
```

The `register` function:

```jsx

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:

```jsx
  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"/>)`.

```jsx
  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:

```jsx

  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.

```js
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:

```jsx
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:

```jsx
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.

```jsx

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:

```jsx
export function navigate(url, state = {}) {
  window.history.pushState(state, "", url)
  raiseWithOptions("popstate", { state })
}
```

### Router

```jsx
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.

```jsx
  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`.

```jsx

  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.

```jsx
  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 :)

```jsx
  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.

```jsx
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 ":").

```jsx
  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.

```jsx
  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:

{% github miketalbot/cccc-widget no-readme %}


{% codesandbox n4dpo initialpath=/19yEFb6jzzFBomM-9lZTZ/embed runonclick=0 %}