Matchers

Matchers are objects that map a Request and Context to their associated content, including views, data, and other information.

Functions for creating matchers

While it’s possible to create your own custom matchers, usually you’ll use Navi’s helper functions to create them. There are five basic functions that return matchers:

Navi also provides a number of child-accepting functions that can be composed together to match multiple pieces of information with a single matcher. They can also be composed together with the compose(). It may help to think of them as middleware.

Finally, if the above functions don’t cover your needs, it is possible to write your own custom matchers. Matchers are functions that take a child and return a generator function, so you can write them with pure JavaScript.

lazy()

lazy(() => Promise<{ default: Matcher }>)

The lazy() function allows you to split out part of your matcher tree to a separate file, making it possible to declare routing trees for large projects which can’t all be loaded at once.

Navi’s lazy() function acts like React’s lazy() function, accepting a function that returns a promise. Once the promise resolves, Navi will defer to the matcher on the resolved value’s default property.

Examples

import { lazy, mount } from 'navi'

export default mount({
  '/': lazy(() => import('./landingRoute')),
  '/about': lazy(() => import('./aboutRoute')),
})

map()

map((request, context) =>
  Matcher |
  Promise<Matcher | { default: Matcher }>
)

The map() function allows you to decide on a matcher at runtime, based on the value of request and context. This is useful when you want to use a different child matcher depending on the circumstances.

Examples

You can use map() when implementing authentication to match a route() when the user is logged in, and a redirect() when the user is logged out.

import { lazy, map, mount, redirect } from 'navi'

export default mount({
  '/members': map((request, context) =>
    context.isAuthenticated
      ? lazy(() => import('./membersRoute'))
      : redirect(request =>
          '/login?redirectTo='+encodeURIComponent(request.originalUrl)
        )
  ),
})

mount()

mount(paths: {
  [path: string]: Matcher
})

The mount() function returns a matcher that allows you to “mount” multiple child matchers at specified paths. The active matcher will then depend on the current URL.

This matcher transforms the Request object passed to child matchers by:

  • Removing the matched part of the URL from the path and url properties
  • Appending the matched path to the mountpath property

Examples

Basic usage

This example specifies two URLs — on '/about’, a route() matcher is used that dynamically loads its view form another file, and on /, a matcher is mounted that redirects users to /about.

routes.js
index.js
styles.css
about.mdx
import { mount, redirect, route } from 'navi'

export default mount({
  '/about': route({
    title: 'The createSwitch() function',
    getView: () => import('./about.mdx'),
  }),

  '/': redirect('./about'),
})
Build In Progress

Nested mounts

You can compose multiple mount() matchers together — just as you’d compose React components or Redux reducers.

export default mount({
  '/plans': mount({
    '/pro': route({
      title: 'Pro',
      getView: () => import('./pro-plan.mdx'),
    }),
    '/team': route({
      title: 'Team',
      getView: () => import('./team-plan.mdx'),
    }),
  }),

  '/': redirect('./plans/pro'),
})

The / path

Within the paths object, the / path is special; it is used to represent the content of the URL at which the mount() matcher itself is mounted at.

If you don’t provide a / path for a mount(), then accessing it directly will result in a 404. For example, accessing /plans in this example will result in a 404.

export default mount({
  '/plans': mount({
    '/pro': route({
      title: 'Pro',
      getView: () => import('./pro-plan.mdx'),
    }),
    '/team': route({
      title: 'Team',
      getView: () => import('./team-plan.mdx'),
    }),
  }),

  '/': redirect('./plans/pro'),
})

Because of this, it often makes sense to mount a redirect at the / path.

URL parameters

It’s possible to specify wildcard segments in a mount() matcher’s paths by starting segments with the : character. The values of these wildcard segments will be made available via your NaviRequest objects’ params property.

For example, the following paths object specifies that any URL of the form /resource/:id, where :id can be anything, maps to a route whose view and title depends on the result of fetchResource(:id).

export default mount({
  '/resource/:id': route(request => {
    let resource = await fetchResource(request.params.id)

    return {
      title: resource.title,
      content: resource.content,
    }
  })
})

Wildcard paths

The * pattern is a special pattern that will always be matched if no other pattern is matched. By using the * pattern, you’re can mount multiple mount() matchers at the same path, which is useful for:

  • Splitting multiple routes out into another file using lazy()
  • Adding a layout to a number of routes using withView()
  • Guarding a number of routes against unauthenticated users

Here’s a basic example:

export default mount({
  '/': lazy(() => import('./landing')),
  '/articles': : lazy(() => import('./articles')),

  '*': withView(
    <AuthenticationLayout>
      <View />
    </AuthenticationLayout>,
    mount({
      '/login': lazy(() => import('./login')),
      '/register': lazy(() => import('./register')),
    })
  )
})

redirect()

redirect(
  to:
    | string
    | Partial<URLDescriptor>
    | ((request, context) =>
        | string
        | Partial<URLDescriptor>
        | Promise<string | Partial<URLDescriptor>
      ),
  options?: {
    exact?: boolean
  }
)

Redirects can be mapped to one of a map’s paths to declare that any visits to that path will automatically navigate to the specified path.

The value of to can be an absolute path, a partial URL descriptor, or a getter function that returns either of these.

By default, a redirect() will only match the exact URL. However, you can pass { exact: false } to allow a redirect to match any URL underneath it.

Examples

Redirect to /browse, relative to the application root:

redirect('/browse')

Redirect to ./browse, relative to the path at which the redirect is mounted:

redirect('./browse')

Redirect to /login?redirectTo=..., appending the current URL as parameter, so that the login screen can redirect back to it when complete:

redirect(request =>
  '/login?redirectTo='+
  encodeURIComponent(request.mountpath+request.search)
)

route()

route(options: 
  {
    // Arbitrary information about this route, that will be merged with
    // `data` objects from any parent matchers.
    data?: object,
    getData?: (request, context) => object | Promise<object>,

    // If specified, the produced route will have a type of `error`,
    // and will contain the specified error object.
    error?: object,
    getError?: (request, context) => any | Promise<any>,

    // <head> or <meta> tags that should be rendered when this route is
    // active.
    head?: any,
    getHead?: (request, context) => any | Promise<any>,

    // HTTP headers that should be sent to the client. This is only useful
    // when performing server side rendering.
    headers?: any,
    getHeaders?: (request, context) => object | Promise<object>,

    // State that should be stored on `window.history.state`, and remembered
    // between server/client or when the user navigates forward/back.
    // Previously stored state is available at `request.state`.
    state?: any,
    getState?: (request, context) => any | Promise<any>,

    // The matched HTTP status code. This is only useful when performing
    // server side rendering.
    status?: any,
    getStatus?: (request, context) => number | Promise<number>,

    // The title that should be set within the `<title>` tag, and shown in
    // the browser's title bar.
    title?: string,
    getTitle?: (request, context) => string | Promise<string>,

    // Code to render this route. While you can pass anything in here,
    // typically you'll pass a React element or component.
    view?: any,
    getView?: (request, context) => any | Promise<any>,
  } |
  (request, context) => {
    data?: object,
    head?: any,
    headers?: object,
    state?: any,
    status?: any,
    title?: string,
    view?: any,
  } |
  (request, context) => Promise<{
    data?: object,
    head?: any,
    headers?: object,
    state?: any,
    status?: any,
    title?: string,
    view?: any,
  }>
)

Used to map multiple pieces of content to a URL.

There are two ways to use route():

  1. You can pass a getter function that maps (request, context) to an object containing your content.
  2. You can pass an object that contains constant values, or getter functions for individual values.

In the second case, if request.method is equal to "HEAD", then any getters for view won’t be called. This is particularly useful when your app uses crawl(), as it uses the "HEAD" method by default — ensuring that the act of creating a site map doesn’t fetch all of your app’s views.

Examples

Passing an object of props

route({
  title: 'Frontend Armory',
  data: {
    language: "en",
  },
  head: <>
    Level up your JavaScript through interactive exercises and examples.
  </>,
  getView: () => import('./landing.mdx'),
})

Passing a getter function

route(async (request, context) => {
  let view

  if (!context.currentUser || !context.currentUser.isPro) {
    view = <SubscribePage />
  }
  else {
    let { default: MDXDocument } = await import('./document.mdx')
    view = <MDXDocument currentUser={context.currentUser} />
  }

  return {
    data: {
      exclusiveTo: 'pro',
    },
    title: 'Mastering Asynchronous JavaScript'
    view,
  }
})

withContext()

withContext(
  getChildContext: (request, parentContext) => any | Promise<any>,
  child?: Matcher
)

The withContext() matcher sets the Routing Context within its children. It won’t make any effort to merge in the parent context, but since the parent context is available via the getter function’s second argument, you can merge it in yourself.

This matcher is useful for providing information that is shared by multiple children — similar to React Context.

Examples

Imagine that you’re building a site with many online courses — like Frontend Armory. Each course is composed of a number of nested mount() and route() matchers — with one route() for each lesson. Context could be used to provide the course details to each of the nested pages.

let context = withContext(
  (route, parentContext) => ({
    course,
    courseRoot: rount.mountpath,

    // Merge in the parent context
    ...parentContext,
  }),
  mount({
    // ...
  })
)

withData()

withData(
  dataOrGetter:
    object |
    (request, context) =>
      object |
      Promise<object | { default: object }>,
  child?: Matcher
)

Use withData() to add data to a Route’s data property. If a child matcher adds data on the same properties as this matcher, then the child matcher’s data will take precedence.

withHead()

withHead(
  headOrGetter:
    any |
    (request, context) =>
      any |
      Promise<any | { default: any }>,
  child?: Matcher
)

Use withHead() to add a value to a Route’s heads array. The value will be prepended to any values added by any child matcher.

withHeaders()

withHeaders(
  headersOrGetter:
    object |
    (request, context) =>
      object |
      Promise<object | { default: object }>,
  child?: Matcher
)

Use withHeaders() to add a HTTP headers to a Route’s headers object. Any HTTP headers that a child matcher adds will take precedence of this matcher’s headers.

withState()

withState(
  stateOrGetter:
    any |
    (request, context) =>
      any |
      Promise<any | { default: any }>,
  child?: Matcher
)

Use withState() to specify data that should be merged into window.history.state, and thus remembered as the user navigates with the forward/back buttons. Previously stored state is available at request.state.

As state is stored on window.history.state, it must be serializable. Unfortunately, this means that Error objects are not able to be stored on request.state — instead, you’ll need to extract the message and store that.

The most recent state stored with withState() can be extracted from your navigation object using navigation.extractState(), and can be fed into your navigation object on load by passing a state property to createBrowserNavigation(). This allows navigation state to be passed from server to client.

withStatus()

withStatus(
  statusOrGetter:
    number |
    (request, context) =>
      number |
      Promise<number | { default: number }>,
  child?: Matcher
)

Use withStatus() to add a HTTP status to a Route’s status object. Any status added by a child matcher will take precedence.

Example

The route() function automatically adds a 200 status if you don’t specify one, but internally, it just uses withStatus(). Here’s how you could do the same thing yourself:

import { compose, map, mount, redirect, withStatus, withView } from 'navi'

mount({
  '/contact': map(async (request, context) => {
    if (request.method === 'POST') {
      try {
        await context.api.postContact(request.body)
        return redirect('/thankyou')
      }
      catch (error) {
        return compose(
          withView(<ContactForm error={error} />),
          withStatus(400)
        )
      }
    }
    else {
      return compose(
        withView(<ContactForm />),
        withStatus(200)
      )
    }
  })
})

withTitle()

withTitle(
  titleOrGetter:
    string |
    (request, context) =>
      string |
      Promise<string | { default: string }>,
  child?: Matcher
)

Use withTitle() to specify a value for the matched Route’s title string — which will be used to set the page’s <title> tag. Any title added by a child matcher will take precedence.

withView()

with(
  viewOrGetter:
    any |
    (request, context) =>
      any |
      Promise<any | { default: any }>,
  child?: Matcher
)

Use withView() to add a view to a Route’s views array. The value will be prepended to any values added by any child matcher.

Views getters will not be called when request.method is 'HEAD', as is the case by default when calling crawl().

compose()

There are times when you’ll want to associate multiple pieces of information with a single URL. For example, you may want to associate both a view, some data, and a title.

Matchers that accept a child argument can be composed in one of two ways:

  • You can pass a child matcher as a second argument. E.g.

    withView(
      <About />,
      withData({
        language: 'en',
      })
    )
    
  • You can pass multiple matchers to the compose() function, which allows you to specify nested matchers like the above example using a flat list of argument:

    compose(
      withView(<About />),
      withData({ language: 'en' }),
    )
    

Custom Matchers

Matchers are functions that accept a child generator, and return a Generator Function that outputs arrays of Segment objects.

type MatcherGenerator = (request: NaviRequest) => Iterator<Segment[]>
type Matcher = (child: MatcherGenerator) => MatcherGenerator

Why do matchers follow this signature?

Firstly, accepting a child generator as an argument allows for composition via a standard compose() function — just like the one that comes with Redux. While some matcher-creating functions allow for a child to be passed in as a second argument, this is actually just sugar for specifying a default value for the matcher’s child argument.

As for the generator function, it typically looks something like this:

function* matcherGenerator(request) {
  while (busy) {
    yield computeSegments(env.request)
  }
}

The generator function is called once for each new request. Given that the matcher’s result may be asynchronous, it may not be possible to immediately return a value. However, given that there may be multiple matchers composed together, each emitting their own Segment objects, it may not be desirable to wait until all matchers have completed to see each result.

Generators are a useful way to solve this problem. A generator can immediately yield a “busy” segment that indicates that more data is to come, and then yield a final segment once it’s available. In the meantime, it can call child generators and yield their results as appropriate.

Because matchers are just generators, it’s possible to create your own matchers. As an example, it would be possible to create a withTimeout() function that returns a matcher that emits an error is its child matchers take too much time to resolve.