React Basics Tutorial

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

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.

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 parts 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.

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

The routes define what the application renders for a particular location, but we also need to define how the application navigates. When we create the router, we will pass it a history function that will be used to enable navigation.

Curi uses the Hickory library for its history. 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.

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

// 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';

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 createRouter function from @curi/router. To create the router, call the createRouter function passing it the history function and the routes array.

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

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

let router = createRouter(browser, routes);

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

The router is now ready and we can render the application, but first we should do something really important: make the site more accessible.

Announcing Navigation

In a multi-page application, a screen reader will announce navigation to users. This happens automatically when a new Document is loaded. A single-page application reuses its Document, which is great for removing unnecessary server requests, but also means that the navigation is no longer automatically announced.

Curi has a concept of "side effects". These are functions that are called after a navigation happens and are passed an object with data about the navigation.

The @curi/router package provides a few side effects that are useful for websites. For now, we will focus on the announce side effect. The announce side effect returns a string, which sets the text content of a ARIA Live region. Screen readers will detect the changed text and read it to the users.

Let's go ahead and add the announce side effect to the router. We will have it return a string of the response's pathname.

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

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

let router = createRouter(browser, routes, {
  sideEffects: [
    announce(({ response }) => {
      return `Navigated to ${response.location.pathname}`;
    })
  ]
});

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

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 createRouterComponent higher-order component.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createRouter, announce } from "@curi/router";
import { browser } from '@hickory/browser';
import { createRouterComponent } from "@curi/react-dom";

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

let router = createRouter(browser, routes, {
  sideEffects: [
    announce(({ response }) => {
      return `Navigated to ${response.location.pathname}`;
    })
  ]
});
let Router = createRouterComponent(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 any @curi/react-dom components and hooks need to be descendants of the Router in order to access the context.

We will pass the Router the App element, which is where we will render the application's content.

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { createRouter, announce } from "@curi/router";
import { browser } from '@hickory/browser';
import { createRouterComponent } from "@curi/react-dom";

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

let router = createRouter(browser, routes, {
  sideEffects: [
    announce(({ response }) => {
      return `Navigated to ${response.location.pathname}`;
    })
  ]
});
let Router = createRouterComponent(router);

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

The existing content from src/App.js can be removed and we will start from scratch.

We will import the useResponse hook from @curi/react-dom. This hook lets us read the context data that was set by the Router. useResponse returns three objects: router, response, and navigation.

// src/App.js
import React from "react";
import { useResponse } from "@curi/react-dom";

export default function App() {

}

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,
  location: { pathname: '/', ... },
  name: 'Home',
  params: {},
  partials: [],
  meta: {
    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.respond

Route's can have a respond 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 respond function returns an object with a body property, we can access it from the response as response.body.

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

We can update the App to get the response using useResponse.

// src/App.js
import React from "react";
import { useResponse } from "@curi/react-dom";

export default function App() {
  let { response } = useResponse();
}

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 (
    <article>
      <Paragraph>Home</Paragraph>
    </article>
  );
}
// src/components/Book.js
import React from 'react';

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

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

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

These components can be imported in src/routes.js. Each route can be given a respond 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: "",
    respond() {
      return { body: Home };
    }
  },
  {
    name: "Book",
    path: "book/:id",
    respond() {
      return { body: Book };
    }
  },
  {
    name: "Checkout",
    path: "checkout",
    respond() {
      return { body: Checkout };
    }
  },
  {
    name: "Catch All",
    path: "(.*)",
    respond() {
      return { body: NotFound };
    }
  }
]);

Now that the responses have body properties that are React components, we can update the App to render them.

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. This isn't strictly necessary, but can come in handy.

// src/App.js
import React from "react";
import { useResponse } from "@curi/react-dom";

export default function App() {
  let { response } = useResponse();
  let { body:Body } = response;
  return <Body response={response} />
}

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

The @curi/react-dom package provides a Link component that we can use to navigate between locations within our application.

Navigation isn't done by manually typing the pathname of the location the link should navigate to. Instead, we specify the name of the route using the name prop.

// { name: "Home", path: "" }
<Link name="Home">Home</Link>
// <a href="/">Home</a>

If a route has params, we provide these to the Link as a params object. For a nested route, we would also need to provide params for any ancestor routes.

// { name: "Book", path: "book/:id" }
<Link name="Book" params={{ id: 7 }}>The Dark Forest</Link>
// <a href="/book/7">The Dark Forest</a>

The Link is only for in-app navigation. If you want to link to pages outside of the application, use an anchor.

// in-app
<Link name="Some Route">Some Route</Link>

// out of app
<a href="https://github.com">GitHub</a>

If you need to attach query or hash data to a Link, use the query and hash props.

// { name: "Checkout", path: "checkout" }
<Link name="Checkout" query='affiliate=123'>Checkout</Link>
// <a href="/checkout?affiliate=123">Checkout</a>

The application will have a navigation menu component with links to our home page and checkout page.

touch src/components/NavMenu.js

In order to link to these routes, we only need to know their name, not the actual pathname for the URL.

// src/components/NavMenu.js
import React from 'react';
import { Link } from '@curi/react-dom';

export default function NavMenu() {
  return (
    <nav>
      <ul>
        <li>
          <Link name="Home">Home</Link>
        </li>
        <li>
          <Link name="Checkout">Checkout</Link>
        </li>
      </ul>
    </nav>
  );
}

The menu can be rendered by the App component. We can also add structure to the site by rendering <header> and <main> elements around their respective content.

// src/App.js
import React from "react";
import { useResponse } from "@curi/react-dom";

import NavMenu from './components/NavMenu';

export default function App() {
  let { response } = useResponse();
  let { body:Body } = response;
  return (
    <React.Fragment>
      <header>
        <NavMenu />
      </header>
      <main>
        <Body response={response} />
      </main>
    </React.Fragment>
  );
}

The website should link to individual books from its home page. To do this, we need data about the available books. Since we don't have a backend to fetch book data from, we'll hard-code the books data in the src/books.js module.

touch src/books.js

You can copy+paste or modify the data, but the structure of the provided data should stay the same.

// src/books.js
export default [
  {
    id: 0,
    title: 'The Name of the Wind',
    author: 'Patrick Rothfuss',
    published: '2007',
    pages: 662
  },
  {
    id: 1,
    title: "The Wise Man's Fear",
    author: 'Patrick Rothfuss',
    published: '2011',
    pages: 994
  },
  {
    id: 2,
    title: 'The Way of Kings',
    author: 'Brandon Sanderson',
    published: '2010',
    pages: 1007
  },
  {
    id: 3,
    title: 'A Storm of Swords',
    author: 'George R.R. Martin',
    published: '2003',
    pages: 1177
  },
  {
    id: 78,
    title: 'Words of Radiance',
    author: 'Brandon Sanderson',
    published: '2014',
    pages: 1087
  }
];

The data can be imported in the Home component and we can iterate over the books to render a Link to each one.

// src/components/Home.js
import React from 'react';
import { Link } from '@curi/react-dom';

import books from '../books';

export default function Home() {
  return (
    <article>
      <ul>
        {books.map(book => (
          <li key={book.id}>
            <Link name="Book" params={{ id: book.id }} >
              {book.title} by {book.author}
            </Link>
          </li>
        ))}
      </ul>
    </article>
  );
}

Now that we can navigate to the books, we should fill out the UI for the Book component. Up above, we passed the response object as a prop to the response.body component. Now, we can use that object in the Book component to access the captured route params so that we know which book to show.

We will once again import the books.js data. We can use params.id to select the correct book. params.id is a string, so we will need to parse it into an integer. Sometimes there won't be a valid book for the params.id. In that case, we will also want to display a message that the requested book could not be found.

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

import books from '../books';

export default function Book({ response }) {
  let id = parseInt(response.params.id, 10);
  let book = books.find(b => b.id === id);
  if (!book) {
    return (
      <article>
        <Paragraph>The requested book could not be found</Paragraph>
      </article>
    );
  }
  return (
    <article>
      <h1>{book.title}</h1>
      <h2>by {book.author}</h2>
      <Paragraph>Published in {book.published}</Paragraph>
      <Paragraph>{book.pages} pages</Paragraph>
    </article>
  );
}

A Shopping API

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.

// src/cart.js
let cart = new Map();

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

Using useRouter

The useRouter hook allows us to access our router from anywhere in our component tree (that is a descendant of the <Router>).

While links are generally the best way to navigate, sometimes an application should navigate as the result of another action. For instance, after a user login is authenticated, the application may redirect to another page.

We will implement something similar in the Book component by having the application navigate to their shopping cart after they add a book to it.

The Router's URL & Navigate Methods

The router has a url method that is used to generate a URL string using the name of a route and an object of the route's params.

let url = router.url({ name: "New" });

The router's navigate method is used to navigate; it takes a URL (such as one defined using router.url). The function can also take a method type for the navigation: push, replace, or anchor.

push pushes a new location after the current index, removing any locations after the current location.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/new", method: "push" });
// session = ['/one', '/two', '/new']
// index = 2
// current = '/new'

replace replaces the location at the current index.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/replacement", method: "replace" });
// session = ['/one', '/replacement', '/three']
// index = 1
// current = '/replacement'

anchor is a mix between push and replace. It mimics the behavior of clicking on links, so if you navigate to the same location as the current one it will replace, and if you navigate to a new location it will push.

If method.navigate is called without a navigation method, it will default to anchor.

// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/two", method: "anchor" });
// session = ['/one', '/two', '/three']
// index = 1
// current = '/two'
router.navigate({ url: "/new", method: "anchor" });
// session = ['/one', '/two', '/new']
// index = 2
// current = '/new'`}

In the Book components module, we should import the useRouter hook from @curi/react-dom as well as our shopping cart API.

// src/components/Book.js
import React from 'react';
import { useRouter } from '@curi/react-dom';

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

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

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 instead of the cart's contents. Once again, we will use the useRouter hook to access the router in order to change locations.

// src/components/Checkout.js
import React from 'react';
import { useRouter } from '@curi/react-dom';

import cart from '../cart';

export default function Checkout({ response }) {
  let router = useRouter();
  let books = cart.items();
  if (!books.length) {
    return response.location.hash === 'thanks'
      ? <article>
          <Paragraph>Thanks for your purchase!</Paragraph>
        </article>
      : <article>
          <Paragraph>The cart is currently empty</Paragraph>
        </article>;
  }
  return (
    <article>
      <h1>Checkout</h1>
      <ScrollableTable>
        <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>
      </ScrollableTable>
      <button
        type="button"
        onClick={() => {
          cart.reset();
          let url = router.url({
            name: "Checkout",
            hash: "thanks"
          });
          router.navigate({ url, method: "replace" });
        }}
      >
        Buy
      </button>
    </article>
  );
};

What's next?

We now have a functional website built with React and Curi. What should you do next? Build another site!

There is an advanced React tutorial that continues where this tutorial leaves off. The advanced tutorial implements code splitting and data prefetching.

You can also check out the guides for information on other advanced techniques.