React Basics Tutorial

In this tutorial, we will be building the front end of a website for a bookstore.

We will be doing the following:

  • Creating a React application using Create React App.
  • Defining the website's valid routes
  • Setting up a router
  • Rendering different content based on the current location.
  • Writing links to navigate within the application.

Demo

You can run a demo of the site we are building with CodeSandbox.

Use the three buttons at the top of the Sandbox to toggle view modes. Clicking the menu button in the top left corner opens a menu to switch between files.

Setup

We will be using create-react-app to develop this website.

Note: The instructions here assume that you have NodeJS and NPM installed on your computer. If you do not, cannot, or prefer to avoid setup altogether, you can follow along using CodeSandbox. Some of the boilerplate will be different, but the differences are minor.

Begin by opening a terminal and navigating to the directory where you want to save your code. Then, we will use npx to create the application.

npx create-react-app curi-react-bookstore # create the app
cd curi-react-bookstore # enter the new app directory

There are three routing related packages that we will be using, so let's install them now.

The @hickory/browser manages locations and navigation within an application. @curi/router creates our router. @curi/react-dom provides React components that interact with the router.

npm install @hickory/browser @curi/router @curi/react-dom

Next, we can start create-react-app's dev server. The dev server will automatically update when we change files, so we can leave that running.

npm run start # start the dev server

Routes

A single-page application is made up of a number of "routes", which are the valid locations within the application. The router matches the application against its routes to determine which one matches.

With Curi, routes are JavaScript objects. They have two required properties: name and path.

// this is a route
{ name: "Home", path: "" }

A route's name needs to be unique. Route names are used to identify which route to interact with for different functionality, like navigation.

A route's path is what the router uses to identify if a location matches the route. The path is only matched against the location's pathname, the other segments of a URL are not used for matching.

Path basics

Route paths are strings describing the pathname segments of a URL that they should match.

{ path: '' } // matches "/"
{ path: 'about/stuff' } // matches "/about/stuff"

Paths never begin with a slash.

// yes
{ path: '' }
// no
{ path: '/' }

Paths can include dynamic parameters. These are specified with a string that starts with a colon (:) followed by the name of the params.

// a param named "id"
{ path: 'user/:id' }
// user/abc -> { id: "abc" }

Routes can be nested using the children property of a route. A nested route inherits the path from its ancestor route(s), so its path is only the additional part of the pathname that should be matched.

{
  name: "Parent",
  path: "parent", // matches /parent
  children: [
    // matches /parent/daughter
    { name: "Daughter", path: "daughter" },
    // matches /parent/son
    { name: "Son", path: "son" }
  ]
}

The website will start with four routes.

namepathuse case
Home""Lists books available for purchase.
Book"book/:id"Details about an individual book. The id param identifies a specific book.
Checkout"checkout"Buy the books in the shopping cart.
Catch All"(.*)"Display a not found page. This path matches every location (using a regular expression syntax), so it should be the last route.
Note: Curi uses the path-to-regexp package for route matching. You can read its documentation to learn about more advanced path syntax.

Inside of the src directory, we will create a routes.js file where we can define the application's routes.

touch src/routes.js

We can create an array of routes using the above names and paths.

@curi/router provides a prepareRoutes function, which is used to setup routes for the router. We will pass the routes array to prepareRoutes and export the result of that function call.

// src/routes.js
import { prepareRoutes } from "@curi/router";

export default prepareRoutes([
  {
    name: "Home",
    path: ""
  },
  {
    name: "Book",
    path: "book/:id"
  },
  {
    name: "Checkout",
    path: "checkout"
  },
  {
    name: "Catch All",
    path: "(.*)"
  }
]);

We will be creating the router in the index.js file, so the routes array should be imported there.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';

import routes from "./routes";
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

History

Along with the routes, we also need to create a history object for the router. The history object is responsible for creating locations and navigation within the application.

Curi uses the Hickory library for its history implementations. There are a few Hickory packages to choose from for different environments. For most websites, the @hickory/browser is the right choice for the front end, which is what we will be using

We can import the Browser function from @hickory/browser in our index file (src/index.js, which create-react-app created for us) and call the function to create a history object.

Note: The history object can be configured with an options object, but we will stick with the defaults.
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Browser from '@hickory/browser';

import routes from "./routes";
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

The Router

We are now ready to create the router. In the src/index.js file, we should import the curi function from @curi/router. To create the router, call the curi() function passing it the history object and the routes array.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';

import routes from './routes';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

The router is now ready and we can render the application.

Rendering with React

The @curi/react-dom provides the components that we will use to interact with the router.

We create a <Router> component by passing the router to the curiProvider higher-order component.

Note: Curi uses a higher-order component to create a component instead of a regular component because the router is a permanent "prop". An application should only have one router, so this approach discourages trying to swap routers.
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from "@curi/react-dom";

import routes from './routes';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);
const Router = curiProvider(router);

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

The <Router> component will re-render the application whenever there is in-app navigation. It also sets up a React context, so the other @curi/react-dom components need to be descendants of the <Router> in order to access the context.

The <Router> takes one prop: children. children is a render-invoked function that should return the content for the website. This function will receive an object that has three properties: router, response, and navigation. These properties (mostly the response) should be useful in determining what to render.

We can also remove the <App> component import and delete the related files.

rm src/App.js src/App.css src/App.test.js
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from "@curi/react-dom";

import routes from './routes';
import './index.css';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);
const Router = curiProvider(router);

ReactDOM.render((
  <Router>
    {({ router, response, navigation }) => {
      return <div>This is the website</div>;
    }}
  </Router>
), document.getElementById('root'));
registerServiceWorker();

The router property is our Curi router, but what are the other two?

Responses and Navigation

Whenever Curi receives a location, it matches its routes against it and creates a response object, which contains data about the route that matched the location.

// a sample response object
{
  body: undefined,
  data: undefined,
  error: undefined,
  location: { pathname: '/', ... },
  name: 'Home',
  params: {},
  partials: [],
  status: 200
}

The router uses the observer pattern to register functions that will be called when a new response is created. The <Router> automatically observes the router so that it can re-render the application whenever there is a new response.

The navigation object contains additional information about a navigation that doesn't make sense to include in the response object. This includes the navigation's "action" (PUSH, POP, or REPLACE) and the previous response object.

// a sample navigation object
{
  action: "PUSH",
  previous: { name: ..., location: ..., ... }
}

The response is the most useful of these three properties, but the other two may can be handy. For example, the navigation can be useful for animating route transitions.

How do we render using the response? Any way you want! The best way is to use a response's body property.

route.response()

Route's can have a response property, which is a function that returns an object. The (valid) properties of the object will be merged onto the response object.

One of these valid properties is body, so if the response function returns an object with a body property, we can access it from the response as response.body.

{
  name: "Home",
  path: "",
  response() {
    return {
      body: "Home, sweet home."
    };
    /*
      * response = {
      *   name: "Home",
      *   location: {...},
      *   body: "Home, sweet home.",
      *   // ...
      * }
      */
  }
}

If a response's body is a React component, we can render it! We haven't actually defined components for our routes yet, so we should throw together some placeholders.

mkdir -p src/components
touch src/components/Home.js src/components/Book.js \
  src/components/Checkout.js src/components/NotFound.js
// src/components/Home.js
import React from 'react';

export default function Home() {
  return (
    <div>Home</div>
  );
}
// src/components/Book.js
import React from 'react';

export default function Book(){
  return (
    <div>Book</div>
  );
}
// src/components/Checkout.js
import React from 'react';

export default function Checkout() {
  return (
    <div>Checkout</div>
  );
}
// src/components/NotFound.js
import React from 'react';

export default function NotFound() {
  return (
    <div>Not Found</div>
  );
}

These components can be imported in src/routes.js. Each route can be given a response() function which returns an object with their respective component as its body.

// src/routes.js
import { prepareRoutes } from "@curi/router";

import Home from './components/Home';
import Book from './components/Book';
import Checkout from './components/Checkout';
import NotFound from './components/NotFound';

export default prepareRoutes([
  {
    name: "Home",
    path: "",
    response() {
      return { body: Home };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    response() {
      return { body: Book };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    response() {
      return { body: Checkout };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    response() {
      return { body: NotFound };
    }
  }
]);

We can now update the <Router>'s children function to render response.body.

We will also pass the response as a prop to the rendered component, which means that each of the route components will have access to the response when they are rendered.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from '@curi/react-dom';

import routes from './routes';
import './index.css';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);
const Router = curiProvider(router);

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

At this point in time our app is rendering, but is isn't very interesting because we cannot navigate between locations.

Let's go shopping

Users of the website should be able to add books to their shopping cart. For brevity, we will store the cart data in memory (i.e. it will be lost when refreshing the page).

touch src/cart.js

The shopping cart implementation will be a JavaScript Map. We can call its set method to add books, its clear method to reset the cart, and iterate over its entries with a for...of loop.

Note: The Map or some of its features may not work in older browsers.
// src/cart.js
const cart = new Map();

export default {
  add(book, quantity) {
    cart.set(book, quantity);
  },
  items() {
    const books = [];
    for (let [book, quantity] of cart.entries()) {
      books.push({
        title: book.title,
        quantity
      });
    }
    return books;
  },
  reset() {
    cart.clear();
  }
};

Before we edit the <Book> component, we should quickly revisit the <Router>'s children function. In addition to passing the response to the <Body>, we should also pass it our router, which will allow us to do programmatic navigation.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { curi } from '@curi/router';
import Browser from '@hickory/browser';
import { curiProvider } from '@curi/react-dom';

import routes from './routes';
import './index.css';
import NavMenu from './components/NavMenu';
import registerServiceWorker from './registerServiceWorker';

const history = Browser();
const router = curi(history, routes);
const Router = curiProvider(router);

ReactDOM.render((
  <Router>
    {({ response, router }) => {
      const { body:Body } = response;
      return (
        <React.Fragment>
          <header>
            <NavMenu />
          </header>
          <main>
            <Body response={response} router={router} />
          </main>
        </React.Fragment>
      );
    }}
  </Router>
), document.getElementById('root'));
registerServiceWorker();

We can now access our router in the <Book> component. The router's navigate() function can be used to navigate to a new location. This means that when the user clicks a button to add a book to their shopping cart, we can automatically navigate to the checkout page.

We also want to import our shopping cart API so that we can add a book to the cart.

// src/components/Book.js
import React from 'react';

import books from '../books';
import cart from '../cart';

export default function Book({ response, router }) {
  const id = parseInt(response.params.id, 10);
  const book = books.find(b => b.id === id);
  if (!book) {
    return <div>The requested book could not be found</div>;
  }
  return (
    <div>
      <h1>{book.title}</h1>
      <h2>by {book.author}</h2>
      <p>Published in {book.published}</p>
      <p>{book.pages} pages</p>
      <button
        type="button"
        onClick={() => {
          cart.add(book, 1);
          router.navigate({ name: "Checkout" });
        }}
      >
        Add to Cart
      </button>
    </div>
  );
}

Finally, we can update our <Checkout> component to display the books in the shopping cart. To do this, we will import our cart and books. Our cart only stores book ids, so we will need to merge the book data with the cart data.

When a user "buys" the books in their shopping cart, we need to clear out the cart. We will also replace the current location with one whose location.hash is the string "thanks". When that is present in the location, we can render a "Thanks for your purchase" message.

// src/components/Checkout.js
import React from 'react';

import cart from '../cart';

export default function Checkout({ router, response }) {
  const books = cart.items();  
  if (!books.length) {
    return response.location.hash === 'thanks'
      ? <div>Thanks for your purchase!</div>
      : <div>The cart is currently empty</div>;
  }
  return (
    <div>
      <h1>Checkout</h1>
      <table>
        <thead>
          <tr>
            <th>Title</th>
            <th>Quantity</th>
          </tr>
        </thead>
        <tbody>
          {books.map(book => (
            <tr key={book.title}>
              <td>{book.title}</td>
              <td>{book.quantity}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <button
        type="button"
        onClick={() => {
          cart.reset();
          const pathname = router.route.pathname('Checkout');
          router.navigate({
            name: "Checkout",
            hash: "thanks",
            method: "REPLACE"
          });
        }}
      >
        Buy
      </button>
    </div>
  );
};