Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
September 9, 2021 05:47 pm GMT

Client Side React Router (pt2:Routes)

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.

For example:

Which language do you love?

And which do 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:


Original Link: https://dev.to/miketalbot/client-side-react-router-pt2-routes-1g3g

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To