DEV Community

Cover image for Client Side React Router: routes & parameters
Mike Talbot ⭐
Mike Talbot ⭐

Posted on

Client Side React Router: routes & parameters

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 = []
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
  }
Enter fullscreen mode Exit fullscreen mode

Ok so now we have some parameters, it's time to split the path on the search string:

  const [route, query] = path.split("?")
Enter fullscreen mode Exit fullscreen mode

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} />
    })
  }
Enter fullscreen mode Exit fullscreen mode

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")
    }
  }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"))
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

Router

const headings = ["h1", "h2", "h3", "h4", "h5", "h6", "h7"]

export function Router({
  path: initialPath,
  purpose = "general",
  fallback = <Fallback />,
  component = <section />
}) {
Enter fullscreen mode Exit fullscreen mode

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("/")
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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]))
  }
Enter fullscreen mode Exit fullscreen mode

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>
  )
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

That's it, a declarative client side router with path and query parameters. You can check out the whole widget code here:

Top comments (0)