Minimal pushstate routing for JS apps
Switch branches/tags
Nothing to show
Clone or download
Latest commit 712a4b3 Jul 22, 2018
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
preact init Jul 22, 2018
src init Jul 22, 2018
.eslintrc init Jul 22, 2018
.gitignore init Jul 22, 2018
.npmignore init Jul 22, 2018
build.js init Jul 22, 2018
package.json init Jul 22, 2018
readme.md init Jul 22, 2018

readme.md

Overview

Minimal pushstate routing for JS applications.

  • replaces react-router and similar alternatives
  • works with any UI library
  • gives you imperative control
  • works well with server-side rendering
  • plain regexps, not a custom string-based dialect

Meant to be used with history or an API-compatible alternative. Usable with any UI library. Comes with a Preact adapter that implements pushstate links.

Size: ≈3.6 KiB minified, including the querystring dependency and the optional Preact adapter.

TOC

Why

Most routing libraries are overwrought.

Consider react-router:

  • ridiculous internal and API complexity
  • insanely large size, around 40 KiB last time I checked
  • custom string-based dialect for path matching
  • hierarchical routing that makes top-level control impossible
  • routing through rendering:
    • makes it impossible to implement asynchronous top-level transitions, where the next page doesn't render until the data is ready
    • makes it impossible to pre-render the next page and slide it into view
    • hostile to isomorphic server-side rendering
    • redirects as a side effect of rendering, which again is hostile to isomorphic apps, which want to handle routing before rendering, and return 301/302/303 for redirects
  • missing support for URL queries; they don't even provide that as common-sense functions
  • bad rendering performance

Why regexps?

  • can see exactly what it will match
  • don't have to learn the fine semantics of yet another string-based dialect
  • imperouter returns the regexp match -> no new concepts to understand
  • can use ES2018 named capture groups, which obsolete other ways of capturing named parameters such as '/path/:id' in string-based dialects or Imperouter own {params: ['id']}

Installation

yarn add -E imperouter
# or
npm i -E imperouter

Usage

  • Write route config
  • Wire up a history listener
  • Wire up UI

First, a route config:

const routes = [
  {path: /^[/]$},
  {path: /^[/]posts[/]([^/]+)$/, params: ['id']},
  {path: /./},
]

Imperouter just finds the matching route. It doesn't trigger rendering or any other side effects. Interpreting the route is up to you. Usually, routes refer to a view component. These examples use Preact.

const routes = [
  {path: /^[/]$,                                 component: LandingPage},
  {path: /^[/]posts[/]([^/]+)$/, params: ['id'], component: PostPage},
  {path: /./,                                    component: Page404},
]
function LandingPage ({location})               {/* ... */}
function PostPage    ({location, params: {id}}) {/* ... */}
function Page404     ({location})               {/* ... */}

We'll also need history. It lets us subscribe to history push events, not just pop events. When Imperouter links push new locations, the application can react to that.

import createBrowserHistory from 'history/es/createBrowserHistory'

const history = createBrowserHistory()

// Defined below
history.listen(onLocationChange)

onLocationChange(history.location)

On location changes, we probably want to render the UI. Subscribe to the history and imperatively render from from the top. Assuming you're using Imperouter with Preact, make sure to include a Context with the history; this is required for pushstate links.

import * as React from 'preact'
import * as ir from 'imperouter'
import {Context} from 'imperouter/preact'

const rootNode = document.getElementById('root')

function onLocationChange(location) {
  // Routes are defined above
  const match = ir.findRouteMatch(routes, location.pathname)

  // Note: `component` in the route is an application-level convention, not
  // dictated by the router. However, `params` are provided by the router.
  const {route: {component: Component}, params} = match

  const element = (
    <Context history={history}>
      <div id='root'>
        <Component location={location} params={params} />
      </div>
    </Context>
  )
  React.render(element, rootNode, rootNode.parent)
}

Note that you're free to include other side effects in the location handler. You can trivially implement asynchronous transitions by asking components to fetch data before rendering them. This doesn't need any special library support.

Finally, for pushstate navigation, use the Link component:

import {Link} from 'imperouter/preact'

function LandingPage() {
  return (
    <div>
      <Link to='/'>Home</Link>
      <Link to='/posts/100'>First Post</Link>
    </div>
  )
}

If you pass a location, the link will detect if it's "current" and set the [aria-current=true] attribute. Use it for styling.

import {Link} from 'imperouter/preact'

function LandingPage({location}) {
  return (
    <div>
      <Link to='/' exact location={location}>Home</Link>
      <Link to='/posts/100' location={location}>First Post</Link>
    </div>
  )
}

API

All examples in this section imply an import:

import * as ir from 'imperouter'
// or
const ir = require('imperouter')

Location

Rather than plain URLs, both History and Imperouter use "locations", which are plain dicts that look like window.location:

interface Location {
  protocol: ?string
  host:     ?string
  pathname: ?string
  search:   ?string
  query:    ?{[string]: string | [string]}
  hash:     ?string
}

The "query" is an Imperouter extension. When interfacing with History and window.location, use these functions for query support:

findRouteMatch(routes, pathname) -> match

Finds the first match via matchRoute. Returns undefined if nothing matches.

const routes = [
  {path: /^[/]$/},
  {path: /^[/]posts$/},
  {path: /^[/]posts[/]([^/]+)$/},
  {path: /./},
]

ir.findRouteMatch(routes, '/')
// ['/', route: {path: /^[/]$/}, params: {}]

ir.findRouteMatch(routes, '/posts/100')
// ['/posts/100', '100', route: {path: /^[/]posts/([^/]+)$/}, params: {}]

matchRoute(route, pathname) -> match

Tests the route. The route must look like this:

const route = {path: /someRegexp/, params: ['someParamName']}
  • path is mandatory and must be a regexp
  • params are optional; if provided, must be a list of strings
  • other properties are ignored

Returns the result of calling route.path.exec(pathname), additionally assigning the original route and named params. See the match structure in the RegExp.prototype.exec docs.

Pass params to give names to the positional capture groups in your regexp:

const route = {path: /^[/]posts[/]([^/]+)$/, params: ['id']}
//                                ↑ capture group      ↑ group name

const match = ir.matchRoute(route, '/posts/100')

// ['/posts/100', '100', route: {...}, params: {id: '100'}]

decodeLocation(url) -> location

Parses a URL string into a location (see above) with a decoded query.

const location = ir.decodeLocation('/one?two=three#four')
/*
{
  pathname: '/one',
  search: '?two=three',
  query: {two: 'three'},
  hash: '#four',
}
*/

encodeLocation(location) -> url

Reverse of decodeLocation. Converts a location into a URL. Automatically encodes location.query, which takes priority over location.search.

const url0 = ir.encodeLocation({
  pathname: '/one',
  search: '?two=three',
  hash: '#four',
})
// '/one?two=three#four'

const url1 = ir.encodeLocation({
  pathname: '/one',
  query: {two: 'three'},
  hash: '#four',
})
// '/one?two=three#four'

decodeQuery(search) -> query

Converts a search string into a query dict. Same as querystring.decode, but also accepts null and undefined and ignores the starting ?, if any.

ir.decodeQuery()
// {}

ir.decodeQuery('?one=two&three=four')
// {one: 'two', three: 'four'}

// ir.decodeQuery('one=two&one=three')
// {one: ['two', 'three']}

encodeQuery(query) -> search

Converts a query dict into a search string. Same as querystring.encode, but also accepts null and undefined, omits null or undefined properties, and prepends ? when the encoded search is not empty.

ir.encodeQuery()
// ''

ir.encodeQuery({one: 'two', three: 'four'})
// '?one=two&three=four'

ir.encodeQuery({one: ['two', 'three']})
// '?one=two&one=three'

ir.encodeQuery({one: 'two', three: null, four: undefined})
// '?one=two'

withQuery(location) -> location

Returns a version of location where location.query is updated to match location.search.

ir.withQuery({pathname: '/one', search: '?two=three'})
// {pathname: '/one', search: '?two=three', query: {two: 'three'}}

withSearch(location) -> location

Returns a version of location where location.search is updated to match location.query. Use this before passing the location to History methods which support structured locations, but not queries.

ir.withSearch({pathname: '/one', query: {two: 'three'}})
// {pathname: '/one', search: '?two=three', query: {two: 'three'}}

Context

Part of the Preact adapter. Passes history to descendant links. Use this somewhere at the root of your view hierarchy.

See the usage examples above. In short:

import createBrowserHistory from 'history/es/createBrowserHistory'
import {Context} from 'imperouter/preact'

const history = createBrowserHistory()

<Context history={history}> ... site content ... </Context>

Link

Part of the Preact adapter. Pushstate-enabled HTML link. Requires a Context with a history. See the usage examples above.

Accepted props:

import {Link} from 'imperouter/preact'

<Link
  // Location as string
  to='/one?two=three#four'

  // Structured location
  to={{pathname: '/one', query: {two: 'three'}, hash: '#four', state: {}}}

  // Optional: use `history.replace` rather than `history.push`
  replace

  // Optional, used to detect "current" state.
  // "Current" links have an `aria-current='true'` attribute.
  location={history.location}

  // Optional; if true, the "current" detection will match the pathname
  // exactly. By default, it also matches subpaths. Use this for '/'.
  exact

  />

Misc

I'm receptive to suggestions. If this library almost satisfies you but needs changes, open an issue or chat me up. Contacts: https://mitranim.com/#contacts