Getting Started

There are a few concepts that you should know about Curi.

The Router#

A router is created using a history object and a routes array.

The history object controls navigation between locations within an application.

The routes array defines how the application renders for different locations.

import curi from "@curi/router";

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

History#

There are three kinds of histories:

  1. browser is used for applications whose server can handle dynamic requests.
  2. hash is a fallback history for applications served from static file hosts that can only handle requests for files that exist.
  3. in-memory is used outside of the browser. For example, on the server or in a React Native app.

If you are unfamiliar with how single-page applications interact with a server, please check out this article: Single-Page Applications and the Server.

import Browser from "@hickory/browser";
const browserHistory = Browser();

import Hash from "@hickory/hash";
const hashHistory = Hash();

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

The history object will map URLs into location objects.

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

Only the pathname will be used for route matching.

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

Routes#

routes is an array of route objects. Each route has a unique name and a path that describes what locations to match.

Note: path strings do not include a leading slash.

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

The "Not Found" route's path matches every location, so it is included after all other routes to render a 404 page.

Route names and params are what will be used for navigation within the app. URLs can be annoying to write, so Curi will handle this for you. All you have to know is the name of the route to navigate to. This also prevents you from navigating to a route that doesn't exist (although it doesn't prevent a user from manually navigating to a route that doesn't exist, which is why the "Not Found" route is important).

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

Responses#

When Curi receives a location, it compares the location's pathname to each route's path to find which one matches best and uses that route to create a response object.

Some of a response's properties are set based on the matched route, while others are set by the matched route's response() function.

The body is the most important property because it is what the application will render (e.g. a React or Vue component).

response = {
  // match properties
  name: 'Photo',
  partials: ['Album'],
  params: { photoID: 12345, albumID: 6789 },
  location: {
    pathname: '/photos/6789/12345',
    ...
  },

  // set by matched route's response() function
  body: function Photo() {...},
  status: 200,
  data: {...},
  title: 'Photo 12345',
  error: undefined
}

Setting Response Properties#

Each route can have a response function for adding properties to a response when that route matches the location.

The function receives an object with the match properties of the response.

The argument object also has a resolved property to access any asynchronously resolve data from the matched routes on.initial() and on.every() functions.

import User from "./components/User";
const routes = [
  {
    name: "User",
    path: "",
    response({ match, resolved }) {
      return {
        body: User,
        title: `User ${match.params.id}`
      };
    }
  }
];

Async Routes#

A route can have functions that will be called when a route matches. These are grouped under the on property.

on.initial() will be run the first time a route matches and its return value will be re-used on subsequent matches. This is ideal for code splitting.

on.every() is run every time a route matches, so data that varies based on route params can be loaded here.

Both of these methods receive the matched route properties of a response and are expected to return a Promise.

If either function has an uncaught error, it will be available in the route's response() method as resolved.error.

const routes = [
  {
    name: "User",
    path: "user/:id",
    on: {
      initial() => import("./components/User"),
      every({ params })) => fetch(`api/user/${params.id}`)
        .then(resp => JSON.parse(resp))
    },
    response({ resolved }) {
      if (resolved.error) {
        // handle the error
      }
      return {
        body: resolved.initial,
        data: resolved.every
      }
    }
  }
];

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 on.initial() or on.every() 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#

We've finally gotten to rendering. How this is done really varies based on how you are rendering, but the idea is always the same.

When the app loads and whenever there is navigation in the app, a new response is created. The application will use the properties of this new response, especially response.body, to render new content.

@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 });

Next

Curi can match routes synchronously or asynchronously. The Sync or Async Guide covers how this works and what it means for your application.