<Router/>

Next-generation router for React tailored towards complex SPA use-cases, makes your application feel like it's been rendered on the server-side.

Source code is hosted on GitHub

A demo can be found on CodeSandbox

This project was heavily inspired by react-router and a lot of attention has been paid to keep the API very similar, therefore the transition will be quick and hassle-free. This router implementation offers many convenient features that are usually too hard to implement in regular single-page applications. All of these features are on-demand, if you don't need them, don't use them. Out of the box, this router behaves exactly like the react-router. By adding some additional flags, your app starts to feel totally different. The UX increases drastically, your routes feel like they are being rendered on the server-side.

  • Declarative routing inside React

  • Nested routing

  • Async loading of components

  • Supports route loaders

  • Supports route unloaders

  • Routes feel like they've been rendered on the server

  • Controlled mode for premature rendering

  • Hooks for various use cases

  • Easy URL query manipulation

  • Switch, Redirect, Link, and other goodies

  • Uses history (v5) for maximal compatibility with other libraries

yarn add @corets/router

Philosophy

This router emerged out of necessity to solve common SPA needs that are incredibly hard / almost impossible to solve without granular control over the router and its transitions. This is why this is not an addon on top of react-router, nor is it a fork. A totally new approach was needed to solve many problems in a developer-friendly way. To fully understand the reasons, let's have a look at the problems every single page application has to live with.

Code splitting

Imagine you take advantage of code splitting and lazy load your route implementation upon first navigation.

To achieve that, you have to wrap your route component into another component to display some sort of a loading overlay. As soon as the implementation has been loaded, you render the actual page.

For a brief moment, you will have to show a loading overlay to indicate the progress. Users will no longer see the previous screen and just stare and the loading overlay until the new route is ready to render. You can immediately tell that this is a SPA based on the bad UX.

Router solves this problem by fetching your route implementation behind the scenes, without unmounting the current route. You can show a loading bar on the top of the page in the meantime. Users will see the previous route plus the loading bar until the new route is ready for the transition. This feels more natural and is also what Google and Facebook do in their applications.

Route loaders

Imagine a route that renders a table with data, where data is fetched from the server through an API call.

To achieve that, you need to display a global loading overlay until the data has been loaded. You need to manually orchestrate the loading overlay if there are multiple components that you have to wait for. Another trick that many SPAs fall back to is the use of skeletons - you render some placeholders that indicate that there is more to come, but the view is not fully ready yet.

For a brief moment, you have no data to show and your users have either to stare at the loading overlay or at some placeholders without any meaningful content until the page is fully ready to render.

Router solves this problem by allowing every component to register a loader. It will wait for all loaders to fully resolve prior to doing the transition. You can show a progress bar at the top of the page, your users never have to stare on an empty screen or at placeholder components. Preloaders can be freely registered from any component that is being rendered inside the new route. You don't need to orchestrate anything manually. Adopting this pattern is a matter of minutes.

Route loaders can also return an unloader that is triggered when the route is being navigated away from.

Route unloaders

Imagine your current route triggers an action that opens a modal. Inside that modal, you have a link to another route. By clicking that link, the modal disappears immediately as the transition begins.

Your components don't have the means to do anything prior to transitioning away. The transition starts immediately, the modal simply disappears and new content is shown. It would feel more natural if the modal would gracefully close, maybe with a nice animation, just before the new route is rendered.

Router solves this problem by allowing every component to register an unloader. It will wait for all unloaders to fully resolve prior to doing the transition. You can do whatever is necessary, like sending some data to the server, running animations, etc. Unloading of the current route is part of the route transition life cycle, therefore you can show a progress bar at the top of the page indicating users that the next route is still being loaded.

Controlled mode

Imagine you try to load another route that needs to fetch some data. Wouldn't it be cool, if you could show a small overlay, covering the currently rendered screen, and providing some additional information to the users, something like "Preparing the rocket to launch", "Sending out the droids", etc.?

This can be achieved thanks to the controlled mode. This mode can be applied to a single route or to all routes at once. This mode allows a route to render content prior to it being fully ready to render / fully loaded.

Router achieves this behaviour by having two routes mounted at the same time, while contrary to the un-controlled mode, the route that is still being in transition will not be hidden, it will be visible side by side with the previous screen. Now it's up to you to decide what you want to show and when. Thankfully there are many convenient hooks that make dealing with the life cycle a piece of cake.

Life cycle

Let's have a look at what the typical router/route life cycle looks like:

  • Mount router

  • Mount routes

  • Register routes with router

  • Figure out active routes

  • Load route implementations if necessary

  • Mount new routes in hidden mode, render normally in controlled mode

  • Wait for route loaders to complete

  • Wait for route unloaders to complete

  • Unmount the previous routes

  • Show new routes

If any new routes are registered during the life cycle execution, the router will incorporate them in the transition. While performing transitions, users can be presented with a progress indicator, but they will never lose the currently rendered screen. You don't need to show any skeletons while fetching relevant data. Each route feels like it has been rendered on the server.

Summary

As you can see, this router implementation is incredibly powerful and flexible. You can have regular routes side by side with routes that preload content, unload gracefully, allow children components to delay transitions while async operations are being executed, render multiple routes side by side in the controlled mode, etc. You can freely mix and match different behaviors based on your requirements.

Quick start

Basic router setup with some routes:

import { render } from "react-dom"
import { Router, Route } from "@corets/router"

render(
  <Router>
    <Route path="/some/path">Content</Route>
  </Router>,
  document.getElementById("root")
)

Patch matching

Paths can be matched in a regular or an exact mode. Path parameters can be matched in different ways using the additional ?, + and * modifiers.

// matches /some/path/*
<Route path="/some/path" />

Exact mode

Match path in the exact mode:

// matches /some/path only
<Route path="/some/path" exact />

Path parameters

Match path parameters that can be retrieved later:

// matches /some/123/*, :param is "123"
<Route path="/some/:param" />

Zero or one

Match zero or exactly one path segment:

// matches /some/*, :param is "null"
// matches /some/123/*, :param is "123"
<Route path="/some/:param?" />

One or many

Match one or multiple path segments:

// matches /some/123/*, :param is "123"
// matches /some/123/456/*, :param is "123/456"
<Route path="/some/:param+" />

Zero or many

Match zero or multiple path segments:

// matches /some/*, :param is "null"
// matches /some/123/*, :param is "123"
// matches /some/123/456/*, :param is "123/456"
<Route path="/some/:param*" />

Path parameters

Parameters can be accessed inside a route using the useParams() and useRoute() hooks:

import { useParams, useRoute } from "@corets/router"

const params = useParams()
// or
const { params } = useRoute()

Conditional routes

Due to the nature of how this router implementation works, it is considered a bad practice to mount and unmount routes conditionally:

  • Manually unmounting a route bypasses it's unloaders

  • Conditionally mounting routes inside a <Switch /> component might lead to unexpected behaviour

Don't panic, we've got you covered! You can use the <Group />, <Switch /> or <Route /> components whenever you plan to toggle available routes at runtime:

import { render } from "react-dom"
import { Route, Router, Group } from "@corets/router"

render(
  <Router>
    <Group disabled={isAuthenticated}>
      <Switch>
        <Route ... />
        <Route ... />
      </Switch>
    </Group>
  
    <Switch enabled={isAuthenticated}>
      <Route ... />
      <Route ... disabled={isAuthenticated} />
    </Switch>
  </Router>
) 

<Router />

All routes must be wrapped inside this component:

import { render } from "react-dom"
import { Router, Route } from "@corets/router"

render(
  <Router>
    <Route>This route is always rendered</Route>
    <Route path="/some/path">Renders only if path matches</Route>
  </Router>,
  document.getElementById("root")
)

Base path

Define a base path for the router:

<Router base="/custom/base/path" />

Loaders and unloaders

Enable loaders and unloaders for all routes:

<Router loadable unloadable />

Enable loaders and unloaders for a specific route:

<Route loadable unloadable />

Loader and unloader threshold

Define how long the router waits for loaders and unloaders to register, default is 5ms:

<Router wait={10} />

Debugging

Enable detailed life cycle logs:

<Router debug />

Custom history

Pass a custom history instance, this is useful for testing or when working with other libraries:

<Router history={history} />

Testing

During the tests you might want to provide a history instance pointing to a specific location:

import { render } from "@testing-library/react"
import { Router, createTestHistory } from "@corets/router"

const testHistory = createTestHistory("/test/path")

render(<Router history={testHistory}>...</Router>)

<Route />

The Route component is used to connect your components to a specific location path:

import { render } from "react-dom"
import { Router, Route } from "@corets/router"

render(
  <Router>
    <Route path="/some/path">Content</Route>
  </Router>,
  document.getElementById("root")
)

Render function

Provide a custom render function:

<Route render={() => <div>Content</div>} />

Render component

Provide a component to render:

<Route render={SomeComponent} />

Async component

Load component implementation asynchronously:

<Route load={() => import("./some/path/MyComponent").then(m => m.MyComponent)} />

Async render function

Provide a render function with some async logic:

<Route load={async () => {
  const something = await doSomething()
  
  return <div>{something}</div>
}} />

Route path

Specify a path for the route:

<Route path="/some/path" />

Route without a path will always match:

<Route>404</Route>

Exact path

Match route path in the exact mode:

<Route path="/some/path" exact />

Absolute path

When nesting routes, you might want to provide an absolute route path instead of the relative one:

// matches /foo/*
<Route path"/foo">
  
  // matches /foo/bar/*
  <Route path="/foo/bar" absolute />
  
  // matches /foo/foo/bar*
  <Route path="/foo/bar" />
</Route>

Nested routes

Routes can be freely nested inside each other, parent route's path is automatically used as the path prefix:

<Route>
  Main content
  
  <Route path="/foo">Other content</Route>
  <Route path="/bar">Other content</Route>
</Route>

Loaders

You can add a loader by using the useRouteLoader() hook:

const MyComponent = () => {
  useRouteLoader(async () => {
    // load relevant data...
    
    // optionally return an unloader
    return async () => {
      // unloading logic...
    }
  })
  
  return <div>Content</div>
}

<Route loadable render={ MyComponent } />

You have to set the loadable property on the route or the router to enable this feature. Make sure to also set the unloadable property on the route or the router if you want to return an unloader from the useRouteLoader hook.

Unloaders

You can add an unloader by using the useRouteUnloader() hook.

const MyComponent = () => {
  useRouteUnloader(async () => {
    // do your thing
  })

  return <div>Content</div>
}

<Route unloadable render={ MyComponent } />

You have to set the unloadable property on the route or the router to enable this feature.

Navigate to another route by clicking a link.

import { render } from "react-dom"
import { Link } from "@corets/router"

render(
  <Link to="/route/path" />, 
  document.getElementById("root")
)

Links that match the current path, have the property data-active set to true. You can change the styling of active links using this simple CSS rule:

a[data-active] {
  // ....
}

You can narrow down what paths will be considered as matching, by setting the exact property. Check the path matching section for more details.

render(
  <Link to="/route/path" exact />, 
  document.getElementById("root")
)

Modifier keys

By default, links respect the ctrl+click, alt+click and cmd+click events as well as the target property. Links clicked using a modifier key will use the default browser behavior instead of triggering the normal navigation. This can be disabled by setting the intercept property to false:

<Link to="/route/path" intercept={false} />

<Switch />

This component will render the first route that matches the current path, it accepts the same props as the <Group/> component:

import { render } from "react-dom"
import { Switch, Route } from "@corets/router"

render(
  <Switch>
    <Route path="/foo">Some content</Route>
    <Route path="/bar">Other content</Route>
    <Route>404</Route>
  </Switch>,
  document.getElementById("root")
)

<Group />

This component can be used to apply specific settings to all of its child routes. The disabled flag is especially useful whenever you need to disable some routes without having to unmount them:

import { render } from "react-dom"
import { Switch, Route } from "@corets/router"

render(
  <Group
    disabled
    loadable
    unloadable
    controlled
  >
    <Route ... />
    <Route ... />
    <Route ... />
  </Group>
)

<Redirect />

This component will trigger a redirect to another path upon render:

import { render } from "react-dom"
import { Redirect } from "@corets/router"

render(
  <Redirect to="/foo" />,
  document.getElementById("root")
)

useRouter()

Retrieves router handle from the context, it exposes some useful methods and properties:

import { useRouter } from "@corets/router"

const router = useRouter()

Detect loading

Check if the router is loading anything anywhere on the page, same as the useRouterIsLoading() hook:

router.isLoading()

Detect unloading

Check if the router is unloading anything anywhere on the page, same as the useRouterIsUnloading() hook:

router.isUnloading()

Detect visibility

Check if the router is showing anything anywhere on the page, same as the useRouterIsVisible() hook:

router.isVisible()

Redirects

You can use the router instance to redirect to another path, same as the useRedirect() hook:

router.redirect("/some/path")

useRoute()

Retrieves a route handle from the context, it exposes some useful methods and properties:

import { useRoute } from "@corets/router"

const route = useRoute()

Detect loading

Check if the route is loading, same as the useRouteIsLoading() hook:

route.isLoading()

Detect unloading

Check if the route is unloading, same as the useRouteIsUnloading() hook:

route.isUnloading()

Detect visibility

Check if the route is visible same as the useRouteIsVisible() hook:

route.isVisible()

Redirects

Route handle can be used to redirect to another path, same as useRedirect() hook:

route.redirect("/some/path")

Parameters

Route parameters can be accessed directly through the route handle, same as the useParams() hook:

route.params

Query

Route query can be accessed directly through the route handle, there is also the useQuery() hook that can also be used to modify the query:

route.query

Status

Route status can be accessed directly through the route handle, same as the useRouteStatus() hook:

route.status

useRoutes()

Returns an object with all the routes that were detected by the router:

import { useRouteLoader } from "@corets/router"

const routes = useRoutes()

useRouteLoader()

Route loaders can be used to preload data prior to triggering the route transition:

import { useRouteLoader } from "@corets/router"

useRouteLoader(async () => {
  // do your thang
  
  // optionally return an unloader...
  return async () => {
    // unloading logic
  }
})

Make sure to also set the unloadable property on the route or the router if you want to return an unloader from the useRouteLoader hook.

You can also create a route loader without the callback and resolve it manually:

import { useEffect } from "react"
import { useRouteLoader, useRouteIsLoading, RouteStatus } from "@corets/router"

const isRouteLoading = useRouteIsLoading()
const routeLoader = useRouteLoader()

useEffect(() => {
  if (isRouteLoading) {
    // do your thang
    routeLoader.done()
  }
}, [isRouteLoading])

Check if this particular loader is running:

routeLoader.isRunning()

useRouteUnloader()

Route unloaders can be used to run some logic prior to the route is being unmounted:

import { useRouteUnloader } from "@corets/router"

useRouteUnloader(async () => {
  // do your thang
})

You can also create a route unloader without the callback and resolve it manually:

import { useEffect } from "react"
import { useRouteUnloader, useRouteIsUnloading, RouteStatus } from "@corets/router"

const isRouteUnloading = useRouteIsUnloading()
const routeUnloader = useRouteUnloader()

useEffect(() => {
  if (isRouteUnloading) {
    // do your thang
    routeUnloader.done()
  }
}, [isRouteUnloading])

Check if this particular unloader is running:

routeUnloader.isRunning()

useRouteStatus()

Get status of the current route:

import { useRouteStatus } from "@corets/router"

const routeStatus = useRouteStatus()

useRouterIsLoading()

Check if the router is loading anything anywhere on the page:

import { useRouterIsLoading } from "@corets/router"

const isLoading = useRouterIsLoading()

useRouterIsUnloading()

Check if the router is unloading anything anywhere on the page:

import { useRouterIsUnloading } from "@corets/router"

const isUnloading = useRouterIsUnloading()

useRouterIsVisible()

Check if the router is showing anything anywhere on the page:

import { useRouterIsShowing } from "@corets/router"

const isShowing = useRouterIsShowing()

useRouteIsLoading()

Check if the route is loading:

import { useRouteIsLoading } from "@corets/router"

const isLoading = useRouteIsLoading()

useRouteIsUnloading()

Check if the route is unloading:

import { useRouteIsUnloading } from "@corets/router"

const isUnloading = useRouteIsUnloading()

useRouteIsVisible()

Check if the route is visible:

import { useRouteIsShowing } from "@corets/router"

const isLoading = useRouteIsShowing()

useHistory()

Returns history instance from the context:

import { useHistory } from "@corets/router"

const history = useHistory()

useLocation()

Returns current location object:

import { useLocation } from "@corets/router"

const location = useLocation()

useMatch()

Matches current location against the given pattern:

import { useMatch } from "@corets/router"

const [matches, params] = useMatch("/some/path/:id")

useParams()

Retrieve route params:

import { useParams } from "@corets/router"

const params = useParams()

Retrieve with default parameters:

const params = useParams({ some: "value" })

useQuery()

Retrieve a query handle that can be used to read and write data into the query part of the URL. By providing default values for each query part, you define a list of query fields that you want to control. You won't be able to read or modify query parts that are not part of this list. This allows different components to work with different parts of the query without having to worry about each other's reads and writes:

import { useQuery } from "@corets/router"

// given this query: foo=value1 & baz=value2
const query = useQuery({ 
  foo: "fallback value", 
  bar: "fallback value"
})

// returns { foo: "value1", bar: "fallback value" }
query.get()

You can update a part of the query, and preserve the previous values, this operation will never modify query parts that are not part of the list with the default values:

// given this query: foo=value1 & baz=value2
const query = useQuery({
  foo: "fallback value",
  bar: "fallback value"
})

// query becomes: foo=value1 & bar=value3 & baz=value2
query.put({ bar: "value3" })

// returns { foo: "value1", bar: "value3" }
query.get()

You can override the whole query, query parts that were omitted will be stripped from the final query, this operation will never modify query parts that are not part of the list with the default values. Omitted query pieces will be replaced with the default value:

// given this query: foo=value1 & baz=value2
const query = useQuery({
  foo: "fallback value",
  bar: "fallback value"
})

// query becomes: bar=value1 & baz=value2
query.set({ bar: "value1" })

// returns { foo: "fallback value", bar: "value1" }
query.get()

useRedirect()

Retrieve a redirect function:

import { useRedirect } from "@corets/router"

const redirect = useRedirect()

redirect("/some/path")

Redirect with query:

redirect("/some/path", { query: { some: "value"} })

Redirect with hash:

redirect("/some/path", { hash: "#hash" })

Redirect and preserve query from the current URL:

redirect("/some/path", { preserveQuery: true })

Redirect and preserve hash from the current URL:

redirect("/some/path", { preserveHash: true })

usePathWithBase()

Create a path according to the base path that has been configured on the router:

import { usePathWithBase } from "@corets/router"

// given router base is /foo
// returns /foo/bar
usePathWithBase("/bar")

You can specify a custom base path:

usePathWithBase("/bar", "/base")

useQueryStringifier()

Retrieve a helper to turn any object into a URL query string:

import { useQueryStringifier } from "@corets/router"

const strigifier = useQueryStringifier()

// returns foo=bar&bar=baz
strigifier({ foo: "bar", bar: "baz" })

The ? character is not part of the final query, you have to add it manually.

useQueryParser()

Retrieve a helper to parse a URL query string:

import { useQueryParser } from "@corets/router"

const parser = useQueryParser()

// returns { foo: "bar", bar: "baz" }
parser("foo=bar&bar=baz")

Last updated