Creating a Router

The Router#

The router is the controller of the single-page application. A router is created using a history object and a routes array.

import { curi } from '@curi/router';

const history = Browser();
const routes = [...];
const router = curi(history, routes);

History#

The history object manages navigation between locations within an application.

There are three types of history to choose from; which one you use depends on where your application is running.

  1. The browser history is used for applications running in a browser. If you use the browser history, your application should be hosted on a server that can handle dynamic requests.

    import Browser from "@hickory/browser";
    const browserHistory = Browser();
  2. The hash history is a fallback history for applications running in a browser, but are hosted on servers that can only handle requests for files that exist (static file servers).

    import Hash from "@hickory/hash";
    const hashHistory = Hash();
  3. The in-memory history is used for applications not running in a browser. For example, the in-memory history is used on the server or in a React Native app.

    import InMemory from "@hickory/in-memory";
    const inMemoryHistory = InMemory();

If you are not familiar with how single-page applications interact with a server, this article should help: Single-Page Applications and the Server.

Locations#

The history object will map URLs into location objects. Only the pathname, query (search), and hash segments are used; locations ignore the domain and protocol segments of a URL.

When matching loactions to routes, only the pathname is used.

// https://www.example.com/page?key=value#trending
location = {
  pathname: "/page",
  query: "?key=value"
  hash: "trending"
}

The query value of a location is a string by default, but the history object can be configured to automatically parse it into an object.

You can choose whichever query parsing/stringifying package you prefer. Some of the most popular are qs, query-string, and querystring.

import { parse, stringify } from "qs";
import Browser from "@hickory/browser";

const history = Browser({
  query: { parse, stringify }
});
  
// https://www.example.com/page?key=value#trending
location = {
  pathname: "/page",
  query: { key: "value" }
  hash: "trending"
}

Routes#

The routes array defines the valid locations within an application. When the router receives a new location, it matches it against the routes to generate a response.

Each route is an object that has a unique name and a path string, which defines what locations the route matches.

Note: path strings do not start with a slash.

Routes can be nested. A child route's path will build on the paths from any ancestor routes.

You will probably want to include a "catch all" route to match any "invalid" locations and render a 404 page. The path "(.*)" matches every location.

const routes = [
  {
    name: "Home",
    path: ""
  },
  {
    name: "Album",
    path: "photos/:albumID",
    children: [
      {
        name: "Photo",
        // matches /photos/6789/12345
        path: ":photoID"
      }
    ]
  },
  {
    name: "Not Found",
    path: "(.*)"
  }
];

Why do routes have names? Curi lets you interact with routes using their names. For example, Curi provides a pathname route interaction. When you want to navigate to a route within your application, you specify the name of the route to navigate to (and any params for the route) and Curi will create the URL for you.

const pathname = router.route.pathname(
  "Photo",
  {
    albumID: "abcd",
    photoId: "98765"
  }
);
// pathname = "/photos/abcd/98765"

Observers#

When the router has created a response, it emits it to any observers. You can give the router an observer function through its respond() method.

You usually do not have to call this yourself. Framework implementations will set observers up internally to automatically trigger re-renders for new responses. @curi/react does this using the <CuriProvider> component and @curi/vue uses the CuriPlugin.

const router = curi(history, routes);

// { observe: true } sets up an observer function
// to be called for every new response
const stop = router.respond(({ response }) => {
  console.log('new response!', response);
}, { observe: true });
// ...
stop();
// no longer observing

If you have any asynchronous routes (routes with match functions), router.respond() should be used to delay the initial render. If you don't pass the { observe: true } option, the observer function will only be called once, which is perfect for delaying the initial render.

// wait for initial response to render with an
// observer function that will only be called once
router.respond(() => {
  // safe to render async routes now
});

Rendering#

Rendering is left to whatever rendering library you are using. The way that Curi interfaces with each of them varies, but they all use observers to be notified when there is a new response.

@curi/react uses a <CuriProvider> with a render-invoked children function that will be called whenever there is a new response.

In React applications, response.body should be a React component, so rendering the application means creating an element from response.body.

The React Basics Tutorial gets into more detail about how this works.

// React
ReactDOM.render((
  <CuriProvider router={router}>
    {({ response }) => {
      const { body:Body } = response;
      return <Body />;
    }}
  </CuriProvider>
), document.getElementById('root'));

@curi/vue sets up reactive objects that update when there is a new response. <component :is> can be used to render the body component.

The Vue Basics Tutorial details how to use Vue and Curi.

// Vue
Vue.use(CuriPlugin, { router });
new Vue({
  el: '#app',
  template: '<app />',
  components: { app }
});

@curi/svelte uses the Svelte store and <svelte:component> to render.

// Svelte
const store = curiStore(router);
new app({ target, store });