Yulei Chen

React Router Tutorial

April 1, 2024

An alternative take on the official tutorial of React Router v6.22.3, built with CodeHike.

Welcome to the tutorial! We'll be building a small, but feature-rich app that lets you keep track of your contacts. We expect it to take between 30-60m if you're following along.

πŸ‘‰ Every time you see this it means you need to do something in the app!

The rest is just there for your information and deeper understanding. Let's get to it.

Setup

If you're not going to follow along in your own app, you can skip this section

We'll be using Vite for our bundler and dev server for this tutorial. You'll need Node.js installed for the npm command line tool.

πŸ‘‰οΈ Open up your terminal and bootstrap a new React app with Vite


_10
npm create vite@latest name-of-your-project -- --template react
_10
# follow prompts
_10
cd <your new project directory>
_10
npm install react-router-dom localforage match-sorter sort-by
_10
npm run dev

You should be able to visit the URL printed in the terminal:


VITE v3.0.7 ready in 175 ms
➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose

We've got some pre-written CSS for this tutorial so we can stay focused on React Router. Feel free to judge it harshly or write your own πŸ˜… (We did things we normally wouldn't in CSS so that the markup in this tutorial could stay as minimal as possible.)

πŸ‘‰ Copy/Paste the tutorial CSS into src/index.css

src/index.css

_384
html {
_384
box-sizing: border-box;
_384
}
_384
*,
_384
*:before,
_384
*:after {
_384
box-sizing: inherit;
_384
}
_384
_384
body {
_384
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
_384
'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
_384
'Droid Sans', 'Helvetica Neue', sans-serif;
_384
-webkit-font-smoothing: antialiased;
_384
-moz-osx-font-smoothing: grayscale;
_384
}
_384
_384
code {
_384
font-family: source-code-pro, Menlo, Monaco, Consolas,
_384
'Courier New', monospace;
_384
}
_384
_384
html,
_384
body {
_384
height: 100%;
_384
margin: 0;
_384
line-height: 1.5;
_384
color: #121212;
_384
}
_384
textarea,
_384
input,
_384
button {
_384
font-size: 1rem;
_384
font-family: inherit;
_384
border: none;
_384
border-radius: 8px;
_384
padding: 0.5rem 0.75rem;
_384
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.2),
_384
0 1px 2px hsla(0, 0%, 0%, 0.2);
_384
background-color: white;
_384
line-height: 1.5;
_384
margin: 0;
_384
}
_384
button {
_384
color: #3992ff;
_384
font-weight: 500;
_384
}
_384
_384
textarea:hover,
_384
input:hover,
_384
button:hover {
_384
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.6),
_384
0 1px 2px hsla(0, 0%, 0%, 0.2);
_384
}
_384
_384
button:active {
_384
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.4);
_384
transform: translateY(1px);
_384
}
_384
_384
#contact h1 {
_384
display: flex;
_384
align-items: flex-start;
_384
gap: 1rem;
_384
}
_384
#contact h1 form {
_384
display: flex;
_384
align-items: center;
_384
margin-top: 0.25rem;
_384
}
_384
#contact h1 form button {
_384
box-shadow: none;
_384
font-size: 1.5rem;
_384
font-weight: 400;
_384
padding: 0;
_384
}
_384
#contact h1 form button[value='true'] {
_384
color: #a4a4a4;
_384
}
_384
#contact h1 form button[value='true']:hover,
_384
#contact h1 form button[value='false'] {
_384
color: #eeb004;
_384
}
_384
_384
form[action$='destroy'] button {
_384
color: #f44250;
_384
}
_384
_384
.sr-only {
_384
position: absolute;
_384
width: 1px;
_384
height: 1px;
_384
padding: 0;
_384
margin: -1px;
_384
overflow: hidden;
_384
clip: rect(0, 0, 0, 0);
_384
white-space: nowrap;
_384
border-width: 0;
_384
}
_384
_384
#root {
_384
display: flex;
_384
height: 100%;
_384
width: 100%;
_384
}
_384
_384
#sidebar {
_384
width: 22rem;
_384
background-color: #f7f7f7;
_384
border-right: solid 1px #e3e3e3;
_384
display: flex;
_384
flex-direction: column;
_384
}
_384
_384
#sidebar > * {
_384
padding-left: 2rem;
_384
padding-right: 2rem;
_384
}
_384
_384
#sidebar h1 {
_384
font-size: 1rem;
_384
font-weight: 500;
_384
display: flex;
_384
align-items: center;
_384
margin: 0;
_384
padding: 1rem 2rem;
_384
border-top: 1px solid #e3e3e3;
_384
order: 1;
_384
line-height: 1;
_384
}
_384
_384
#sidebar h1::before {
_384
content: url("data:image/svg+xml,%3Csvg width='25' height='18' viewBox='0 0 25 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19.4127 6.4904C18.6984 6.26581 18.3295 6.34153 17.5802 6.25965C16.4219 6.13331 15.9604 5.68062 15.7646 4.51554C15.6551 3.86516 15.7844 2.9129 15.5048 2.32334C14.9699 1.19921 13.7183 0.695046 12.461 0.982805C11.3994 1.22611 10.516 2.28708 10.4671 3.37612C10.4112 4.61957 11.1197 5.68054 12.3363 6.04667C12.9143 6.22097 13.5284 6.3087 14.132 6.35315C15.2391 6.43386 15.3241 7.04923 15.6236 7.55574C15.8124 7.87508 15.9954 8.18975 15.9954 9.14193C15.9954 10.0941 15.8112 10.4088 15.6236 10.7281C15.3241 11.2334 14.9547 11.5645 13.8477 11.6464C13.244 11.6908 12.6288 11.7786 12.0519 11.9528C10.8353 12.3201 10.1268 13.3799 10.1828 14.6234C10.2317 15.7124 11.115 16.7734 12.1766 17.0167C13.434 17.3056 14.6855 16.8003 15.2204 15.6762C15.5013 15.0866 15.6551 14.4187 15.7646 13.7683C15.9616 12.6032 16.423 12.1505 17.5802 12.0242C18.3295 11.9423 19.1049 12.0242 19.8071 11.6253C20.5491 11.0832 21.212 10.2696 21.212 9.14192C21.212 8.01428 20.4976 6.83197 19.4127 6.4904Z' fill='%23F44250'/%3E%3Cpath d='M7.59953 11.7459C6.12615 11.7459 4.92432 10.5547 4.92432 9.09441C4.92432 7.63407 6.12615 6.44287 7.59953 6.44287C9.0729 6.44287 10.2747 7.63407 10.2747 9.09441C10.2747 10.5536 9.07172 11.7459 7.59953 11.7459Z' fill='black'/%3E%3Cpath d='M2.64217 17.0965C1.18419 17.093 -0.0034949 15.8971 7.72743e-06 14.4356C0.00352588 12.9765 1.1994 11.7888 2.66089 11.7935C4.12004 11.797 5.30772 12.9929 5.30306 14.4544C5.29953 15.9123 4.10366 17.1 2.64217 17.0965Z' fill='black'/%3E%3Cpath d='M22.3677 17.0965C20.9051 17.1046 19.7046 15.9217 19.6963 14.4649C19.6882 13.0023 20.8712 11.8017 22.3279 11.7935C23.7906 11.7854 24.9911 12.9683 24.9993 14.4251C25.0075 15.8866 23.8245 17.0883 22.3677 17.0965Z' fill='black'/%3E%3C/svg%3E%0A");
_384
margin-right: 0.5rem;
_384
position: relative;
_384
top: 1px;
_384
}
_384
_384
#sidebar > div {
_384
display: flex;
_384
align-items: center;
_384
gap: 0.5rem;
_384
padding-top: 1rem;
_384
padding-bottom: 1rem;
_384
border-bottom: 1px solid #e3e3e3;
_384
}
_384
_384
#sidebar > div form {
_384
position: relative;
_384
}
_384
_384
#sidebar > div form input[type='search'] {
_384
width: 100%;
_384
padding-left: 2rem;
_384
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' class='h-6 w-6' fill='none' viewBox='0 0 24 24' stroke='%23999' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' /%3E%3C/svg%3E");
_384
background-repeat: no-repeat;
_384
background-position: 0.625rem 0.75rem;
_384
background-size: 1rem;
_384
position: relative;
_384
}
_384
_384
#sidebar > div form input[type='search'].loading {
_384
background-image: none;
_384
}
_384
_384
#search-spinner {
_384
width: 1rem;
_384
height: 1rem;
_384
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%23000' strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M20 4v5h-.582m0 0a8.001 8.001 0 00-15.356 2m15.356-2H15M4 20v-5h.581m0 0a8.003 8.003 0 0015.357-2M4.581 15H9' /%3E%3C/svg%3E");
_384
animation: spin 1s infinite linear;
_384
position: absolute;
_384
left: 0.625rem;
_384
top: 0.75rem;
_384
}
_384
_384
@keyframes spin {
_384
from {
_384
transform: rotate(0deg);
_384
}
_384
to {
_384
transform: rotate(360deg);
_384
}
_384
}
_384
_384
#sidebar nav {
_384
flex: 1;
_384
overflow: auto;
_384
padding-top: 1rem;
_384
}
_384
_384
#sidebar nav a span {
_384
float: right;
_384
color: #eeb004;
_384
}
_384
#sidebar nav a.active span {
_384
color: inherit;
_384
}
_384
_384
i {
_384
color: #818181;
_384
}
_384
#sidebar nav .active i {
_384
color: inherit;
_384
}
_384
_384
#sidebar ul {
_384
padding: 0;
_384
margin: 0;
_384
list-style: none;
_384
}
_384
_384
#sidebar li {
_384
margin: 0.25rem 0;
_384
}
_384
_384
#sidebar nav a {
_384
display: flex;
_384
align-items: center;
_384
justify-content: space-between;
_384
overflow: hidden;
_384
_384
white-space: pre;
_384
padding: 0.5rem;
_384
border-radius: 8px;
_384
color: inherit;
_384
text-decoration: none;
_384
gap: 1rem;
_384
}
_384
_384
#sidebar nav a:hover {
_384
background: #e3e3e3;
_384
}
_384
_384
#sidebar nav a.active {
_384
background: hsl(224, 98%, 58%);
_384
color: white;
_384
}
_384
_384
#sidebar nav a.pending {
_384
color: hsl(224, 98%, 58%);
_384
}
_384
_384
#detail {
_384
flex: 1;
_384
padding: 2rem 4rem;
_384
width: 100%;
_384
}
_384
_384
#detail.loading {
_384
opacity: 0.25;
_384
transition: opacity 200ms;
_384
transition-delay: 200ms;
_384
}
_384
_384
#contact {
_384
max-width: 40rem;
_384
display: flex;
_384
}
_384
_384
#contact h1 {
_384
font-size: 2rem;
_384
font-weight: 700;
_384
margin: 0;
_384
line-height: 1.2;
_384
}
_384
_384
#contact h1 + p {
_384
margin: 0;
_384
}
_384
_384
#contact h1 + p + p {
_384
white-space: break-spaces;
_384
}
_384
_384
#contact h1:focus {
_384
outline: none;
_384
color: hsl(224, 98%, 58%);
_384
}
_384
_384
#contact a[href*='twitter'] {
_384
display: flex;
_384
font-size: 1.5rem;
_384
color: #3992ff;
_384
text-decoration: none;
_384
}
_384
#contact a[href*='twitter']:hover {
_384
text-decoration: underline;
_384
}
_384
_384
#contact img {
_384
width: 12rem;
_384
height: 12rem;
_384
background: #c8c8c8;
_384
margin-right: 2rem;
_384
border-radius: 1.5rem;
_384
object-fit: cover;
_384
}
_384
_384
#contact h1 ~ div {
_384
display: flex;
_384
gap: 0.5rem;
_384
margin: 1rem 0;
_384
}
_384
_384
#contact-form {
_384
display: flex;
_384
max-width: 40rem;
_384
flex-direction: column;
_384
gap: 1rem;
_384
}
_384
#contact-form > p:first-child {
_384
margin: 0;
_384
padding: 0;
_384
}
_384
#contact-form > p:first-child > :nth-child(2) {
_384
margin-right: 1rem;
_384
}
_384
#contact-form > p:first-child,
_384
#contact-form label {
_384
display: flex;
_384
}
_384
#contact-form p:first-child span,
_384
#contact-form label span {
_384
width: 8rem;
_384
}
_384
#contact-form p:first-child input,
_384
#contact-form label input,
_384
#contact-form label textarea {
_384
flex-grow: 2;
_384
}
_384
_384
#contact-form-avatar {
_384
margin-right: 2rem;
_384
}
_384
_384
#contact-form-avatar img {
_384
width: 12rem;
_384
height: 12rem;
_384
background: hsla(0, 0%, 0%, 0.2);
_384
border-radius: 1rem;
_384
}
_384
_384
#contact-form-avatar input {
_384
box-sizing: border-box;
_384
width: 100%;
_384
}
_384
_384
#contact-form p:last-child {
_384
display: flex;
_384
gap: 0.5rem;
_384
margin: 0 0 0 8rem;
_384
}
_384
_384
#contact-form p:last-child button[type='button'] {
_384
color: inherit;
_384
}
_384
_384
#zero-state {
_384
margin: 2rem auto;
_384
text-align: center;
_384
color: #818181;
_384
}
_384
_384
#zero-state a {
_384
color: inherit;
_384
}
_384
_384
#zero-state a:hover {
_384
color: #121212;
_384
}
_384
_384
#zero-state:before {
_384
display: block;
_384
margin-bottom: 0.5rem;
_384
content: url("data:image/svg+xml,%3Csvg width='50' height='33' viewBox='0 0 50 33' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M38.8262 11.1744C37.3975 10.7252 36.6597 10.8766 35.1611 10.7128C32.8444 10.4602 31.9215 9.55475 31.5299 7.22456C31.3108 5.92377 31.5695 4.01923 31.0102 2.8401C29.9404 0.591789 27.4373 -0.416556 24.9225 0.158973C22.7992 0.645599 21.0326 2.76757 20.9347 4.94569C20.8228 7.43263 22.2399 9.5546 24.6731 10.2869C25.8291 10.6355 27.0574 10.8109 28.2646 10.8998C30.4788 11.0613 30.6489 12.292 31.2479 13.3051C31.6255 13.9438 31.9914 14.5731 31.9914 16.4775C31.9914 18.3819 31.6231 19.0112 31.2479 19.6499C30.6489 20.6606 29.9101 21.3227 27.696 21.4865C26.4887 21.5754 25.2581 21.7508 24.1044 22.0994C21.6712 22.834 20.2542 24.9537 20.366 27.4406C20.4639 29.6187 22.2306 31.7407 24.3538 32.2273C26.8686 32.8052 29.3717 31.7945 30.4415 29.5462C31.0032 28.3671 31.3108 27.0312 31.5299 25.7304C31.9238 23.4002 32.8467 22.4948 35.1611 22.2421C36.6597 22.0784 38.2107 22.2421 39.615 21.4443C41.099 20.36 42.4248 18.7328 42.4248 16.4775C42.4248 14.2222 40.9961 11.8575 38.8262 11.1744Z' fill='%23E3E3E3'/%3E%3Cpath d='M15.1991 21.6854C12.2523 21.6854 9.84863 19.303 9.84863 16.3823C9.84863 13.4615 12.2523 11.0791 15.1991 11.0791C18.1459 11.0791 20.5497 13.4615 20.5497 16.3823C20.5497 19.3006 18.1436 21.6854 15.1991 21.6854Z' fill='%23E3E3E3'/%3E%3Cpath d='M5.28442 32.3871C2.36841 32.38 -0.00698992 29.9882 1.54551e-05 27.0652C0.00705187 24.1469 2.39884 21.7715 5.32187 21.7808C8.24022 21.7878 10.6156 24.1796 10.6063 27.1027C10.5992 30.0187 8.20746 32.3941 5.28442 32.3871Z' fill='%23E3E3E3'/%3E%3Cpath d='M44.736 32.387C41.8107 32.4033 39.4096 30.0373 39.3932 27.1237C39.3769 24.1984 41.7428 21.7973 44.6564 21.7808C47.5817 21.7645 49.9828 24.1305 49.9993 27.0441C50.0156 29.9671 47.6496 32.3705 44.736 32.387Z' fill='%23E3E3E3'/%3E%3C/svg%3E%0A");
_384
}
_384
_384
#error-page {
_384
display: flex;
_384
flex-direction: column;
_384
align-items: center;
_384
justify-content: center;
_384
width: 100%;
_384
}

This tutorial will be creating, reading, searching, updating, and deleting data. A typical web app would probably be talking to an API on your web server, but we're going to use browser storage and fake some network latency to keep this focused. None of this code is relevant to React Router, so just go ahead and copy/paste it all.

πŸ‘‰ Copy/Paste the tutorial data module into src/contacts.js

src/index.css
src/contacts.js

_73
import localforage from 'localforage'
_73
import { matchSorter } from 'match-sorter'
_73
import sortBy from 'sort-by'
_73
_73
export async function getContacts(query) {
_73
await fakeNetwork(`getContacts:${query}`)
_73
let contacts = await localforage.getItem('contacts')
_73
if (!contacts) contacts = []
_73
if (query) {
_73
contacts = matchSorter(contacts, query, { keys: ['first', 'last'] })
_73
}
_73
return contacts.sort(sortBy('last', 'createdAt'))
_73
}
_73
_73
export async function createContact() {
_73
await fakeNetwork()
_73
let id = Math.random().toString(36).substring(2, 9)
_73
let contact = { id, createdAt: Date.now() }
_73
let contacts = await getContacts()
_73
contacts.unshift(contact)
_73
await set(contacts)
_73
return contact
_73
}
_73
_73
export async function getContact(id) {
_73
await fakeNetwork(`contact:${id}`)
_73
let contacts = await localforage.getItem('contacts')
_73
let contact = contacts.find((contact) => contact.id === id)
_73
return contact ?? null
_73
}
_73
_73
export async function updateContact(id, updates) {
_73
await fakeNetwork()
_73
let contacts = await localforage.getItem('contacts')
_73
let contact = contacts.find((contact) => contact.id === id)
_73
if (!contact) throw new Error('No contact found for', id)
_73
Object.assign(contact, updates)
_73
await set(contacts)
_73
return contact
_73
}
_73
_73
export async function deleteContact(id) {
_73
let contacts = await localforage.getItem('contacts')
_73
let index = contacts.findIndex((contact) => contact.id === id)
_73
if (index > -1) {
_73
contacts.splice(index, 1)
_73
await set(contacts)
_73
return true
_73
}
_73
return false
_73
}
_73
_73
function set(contacts) {
_73
return localforage.setItem('contacts', contacts)
_73
}
_73
_73
// fake a cache so we don't slow down stuff we've already seen
_73
let fakeCache = {}
_73
_73
async function fakeNetwork(key) {
_73
if (!key) {
_73
fakeCache = {}
_73
}
_73
_73
if (fakeCache[key]) {
_73
return
_73
}
_73
_73
fakeCache[key] = true
_73
return new Promise((res) => {
_73
setTimeout(res, Math.random() * 800)
_73
})
_73
}

All you need in the src folder are contacts.js, main.jsx, and index.css. You can delete anything else (like App.js and assets, etc.).

πŸ‘‰ Delete unused files in src/ so all you have left are these:


src
β”œβ”€β”€ contacts.js
β”œβ”€β”€ index.css
└── main.jsx

If your app is running, it might blow up momentarily, just keep going πŸ˜‹. And with that, we're ready to get started!

Adding a Router

First thing to do is create a Browser Router and configure our first route. This will enable client side routing for our web app.

The main.jsx file is the entry point. Open it up and we'll put React Router on the page.

πŸ‘‰ Create and render a browser router in main.jsx

src/index.css
src/contacts.js
src/main.jsx

_20
import * as React from 'react'
_20
import * as ReactDOM from 'react-dom/client'
_20
import {
_20
createBrowserRouter,
_20
RouterProvider,
_20
} from 'react-router-dom'
_20
import './index.css'
_20
_20
const router = createBrowserRouter([
_20
{
_20
path: '/',
_20
element: <div>Hello world!</div>,
_20
},
_20
])
_20
_20
ReactDOM.createRoot(document.getElementById('root')).render(
_20
<React.StrictMode>
_20
<RouterProvider router={router} />
_20
</React.StrictMode>
_20
)

This first route is what we often call the "root route" since the rest of our routes will render inside of it. It will serve as the root layout of the UI, we'll have nested layouts as we get farther along.

The Root Route

Let's add the global layout for this app.

πŸ‘‰ Create src/routes/root.jsx and create the root layout component

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx

_43
export default function Root() {
_43
return (
_43
<>
_43
<div id="sidebar">
_43
<h1>React Router Contacts</h1>
_43
<div>
_43
<form id="search-form" role="search">
_43
<input
_43
id="q"
_43
aria-label="Search contacts"
_43
placeholder="Search"
_43
type="search"
_43
name="q"
_43
/>
_43
<div
_43
id="search-spinner"
_43
aria-hidden
_43
hidden={true}
_43
/>
_43
<div
_43
className="sr-only"
_43
aria-live="polite"
_43
></div>
_43
</form>
_43
<form method="post">
_43
<button type="submit">New</button>
_43
</form>
_43
</div>
_43
<nav>
_43
<ul>
_43
<li>
_43
<a href={`/contacts/1`}>Your Name</a>
_43
</li>
_43
<li>
_43
<a href={`/contacts/2`}>Your Friend</a>
_43
</li>
_43
</ul>
_43
</nav>
_43
</div>
_43
<div id="detail"></div>
_43
</>
_43
)
_43
}

Nothing React Router specific yet, so feel free to copy/paste all of that.

πŸ‘‰ Set <Root> as the root route's element

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx

_21
import * as React from 'react'
_21
import * as ReactDOM from 'react-dom/client'
_21
import {
_21
createBrowserRouter,
_21
RouterProvider,
_21
} from 'react-router-dom'
_21
import './index.css'
_21
import Root from './routes/root'
_21
_21
const router = createBrowserRouter([
_21
{
_21
path: '/',
_21
element: <Root />,
_21
},
_21
])
_21
_21
ReactDOM.createRoot(document.getElementById('root')).render(
_21
<React.StrictMode>
_21
<RouterProvider router={router} />
_21
</React.StrictMode>
_21
)

The app should look something like this now. It sure is nice having a designer who can also write the CSS, isn't it? (Thank you Jim πŸ™).

Handling Not Found Errors

It's always a good idea to know how your app responds to errors early in the project because we all write far more bugs than features when building a new app! Not only will your users get a good experience when this happens, but it helps you during development as well.

We added some links to this app, let's see what happens when we click them?

πŸ‘‰ Click one of the sidebar names

screenshot of default React Router error element

Gross! This is the default error screen in React Router, made worse by our flex box styles on the root element in this app πŸ˜‚.

Anytime your app throws an error while rendering, loading data, or performing data mutations, React Router will catch it and render an error screen. Let's make our own error page.

πŸ‘‰ Create an error page component

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx

_16
import { useRouteError } from 'react-router-dom'
_16
_16
export default function ErrorPage() {
_16
const error = useRouteError()
_16
console.error(error)
_16
_16
return (
_16
<div id="error-page">
_16
<h1>Oops!</h1>
_16
<p>Sorry, an unexpected error has occurred.</p>
_16
<p>
_16
<i>{error.statusText || error.message}</i>
_16
</p>
_16
</div>
_16
)
_16
}

πŸ‘‰ Set the <ErrorPage> as the errorElement on the root route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx

_23
import * as React from 'react'
_23
import * as ReactDOM from 'react-dom/client'
_23
import {
_23
createBrowserRouter,
_23
RouterProvider,
_23
} from 'react-router-dom'
_23
import './index.css'
_23
import Root from './routes/root'
_23
import ErrorPage from './error-page'
_23
_23
const router = createBrowserRouter([
_23
{
_23
path: '/',
_23
element: <Root />,
_23
errorElement: <ErrorPage />,
_23
},
_23
])
_23
_23
ReactDOM.createRoot(document.getElementById('root')).render(
_23
<React.StrictMode>
_23
<RouterProvider router={router} />
_23
</React.StrictMode>
_23
)

The error page should now look like this:

new error page, but still ugly

(Well, that's not much better. Maybe somebody forgot to ask the designer to make an error page. Maybe everybody forgets to ask the designer to make an error page and then blames the designer for not thinking of it πŸ˜†)

Note that useRouteError provides the error that was thrown. When the user navigates to routes that don't exist you'll get an error response with a "Not Found" statusText. We'll see some other errors later in the tutorial and discuss them more.

For now, it's enough to know that pretty much all of your errors will now be handled by this page instead of infinite spinners, unresponsive pages, or blank screens πŸ™Œ

The Contact Route UI

Instead of a 404 "Not Found" page, we want to actually render something at the URLs we've linked to. For that, we need to make a new route.

πŸ‘‰ **Create the contact route module src/routes/contact.jsx and add the contact component UI **

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_90
import { Form } from 'react-router-dom'
_90
_90
export default function Contact() {
_90
const contact = {
_90
first: 'Your',
_90
last: 'Name',
_90
avatar: 'https://placekitten.com/g/200/200',
_90
twitter: 'your_handle',
_90
notes: 'Some notes',
_90
favorite: true,
_90
}
_90
_90
return (
_90
<div id="contact">
_90
<div>
_90
<img
_90
key={contact.avatar}
_90
src={contact.avatar || null}
_90
/>
_90
</div>
_90
_90
<div>
_90
<h1>
_90
{contact.first || contact.last ? (
_90
<>
_90
{contact.first} {contact.last}
_90
</>
_90
) : (
_90
<i>No Name</i>
_90
)}{' '}
_90
<Favorite contact={contact} />
_90
</h1>
_90
_90
{contact.twitter && (
_90
<p>
_90
<a
_90
target="_blank"
_90
href={`https://twitter.com/${contact.twitter}`}
_90
>
_90
{contact.twitter}
_90
</a>
_90
</p>
_90
)}
_90
_90
{contact.notes && <p>{contact.notes}</p>}
_90
_90
<div>
_90
<Form action="edit">
_90
<button type="submit">Edit</button>
_90
</Form>
_90
<Form
_90
method="post"
_90
action="destroy"
_90
onSubmit={(event) => {
_90
if (
_90
!confirm(
_90
'Please confirm you want to delete this record.'
_90
)
_90
) {
_90
event.preventDefault()
_90
}
_90
}}
_90
>
_90
<button type="submit">Delete</button>
_90
</Form>
_90
</div>
_90
</div>
_90
</div>
_90
)
_90
}
_90
_90
function Favorite({ contact }) {
_90
// yes, this is a `let` for later
_90
let favorite = contact.favorite
_90
return (
_90
<Form method="post">
_90
<button
_90
name="favorite"
_90
value={favorite ? 'false' : 'true'}
_90
aria-label={
_90
favorite
_90
? 'Remove from favorites'
_90
: 'Add to favorites'
_90
}
_90
>
_90
{favorite ? 'β˜…' : 'β˜†'}
_90
</button>
_90
</Form>
_90
)
_90
}

It's just a bunch of elements, feel free to copy/paste.

Now that we've got a component, let's hook it up to a new route.

πŸ‘‰ Import the contact component and create a new route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_28
import * as React from 'react'
_28
import * as ReactDOM from 'react-dom/client'
_28
import {
_28
createBrowserRouter,
_28
RouterProvider,
_28
} from 'react-router-dom'
_28
import './index.css'
_28
import Root from './routes/root'
_28
import ErrorPage from './error-page'
_28
import Contact from './routes/contact'
_28
_28
const router = createBrowserRouter([
_28
{
_28
path: '/',
_28
element: <Root />,
_28
errorElement: <ErrorPage />,
_28
},
_28
{
_28
path: 'contacts/:contactId',
_28
element: <Contact />,
_28
},
_28
])
_28
_28
ReactDOM.createRoot(document.getElementById('root')).render(
_28
<React.StrictMode>
_28
<RouterProvider router={router} />
_28
</React.StrictMode>
_28
)

Now if we click one of the links or visit /contacts/1 we get our new component!

contact route rendering without the parent layout

However, it's not inside of our root layout 😠

Nested Routes

We want the contact component to render inside of the <Root> layout like this.

We do it by making the contact route a child of the root route.

πŸ‘‰ Move the contacts route to be a child of the root route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_30
import * as React from 'react'
_30
import * as ReactDOM from 'react-dom/client'
_30
import {
_30
createBrowserRouter,
_30
RouterProvider,
_30
} from 'react-router-dom'
_30
import './index.css'
_30
import Root from './routes/root'
_30
import ErrorPage from './error-page'
_30
import Contact from './routes/contact'
_30
_30
const router = createBrowserRouter([
_30
{
_30
path: '/',
_30
element: <Root />,
_30
errorElement: <ErrorPage />,
_30
children: [
_30
{
_30
path: 'contacts/:contactId',
_30
element: <Contact />,
_30
},
_30
],
_30
},
_30
])
_30
_30
ReactDOM.createRoot(document.getElementById('root')).render(
_30
<React.StrictMode>
_30
<RouterProvider router={router} />
_30
</React.StrictMode>
_30
)

You'll now see the root layout again but a blank page on the right. We need to tell the root route where we want it to render its child routes. We do that with <Outlet>.

Find the <div id="detail"> and put an outlet inside

πŸ‘‰ Render an <Outlet>

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_47
import { Outlet } from 'react-router-dom'
_47
_47
export default function Root() {
_47
return (
_47
<>
_47
<div id="sidebar">
_47
<h1>React Router Contacts</h1>
_47
<div>
_47
<form id="search-form" role="search">
_47
<input
_47
id="q"
_47
aria-label="Search contacts"
_47
placeholder="Search"
_47
type="search"
_47
name="q"
_47
/>
_47
<div
_47
id="search-spinner"
_47
aria-hidden
_47
hidden={true}
_47
/>
_47
<div
_47
className="sr-only"
_47
aria-live="polite"
_47
></div>
_47
</form>
_47
<form method="post">
_47
<button type="submit">New</button>
_47
</form>
_47
</div>
_47
<nav>
_47
<ul>
_47
<li>
_47
<a href={`/contacts/1`}>Your Name</a>
_47
</li>
_47
<li>
_47
<a href={`/contacts/2`}>Your Friend</a>
_47
</li>
_47
</ul>
_47
</nav>
_47
</div>
_47
<div id="detail">
_47
<Outlet />
_47
</div>
_47
</>
_47
)
_47
}

Client Side Routing

You may or may not have noticed, but when we click the links in the sidebar, the browser is doing a full document request for the next URL instead of using React Router.

Client side routing allows our app to update the URL without requesting another document from the server. Instead, the app can immediately render new UI. Let's make it happen with <Link>.

πŸ‘‰ Change the sidebar <a href> to <Link to>

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_47
import { Outlet, Link } from 'react-router-dom'
_47
_47
export default function Root() {
_47
return (
_47
<>
_47
<div id="sidebar">
_47
<h1>React Router Contacts</h1>
_47
<div>
_47
<form id="search-form" role="search">
_47
<input
_47
id="q"
_47
aria-label="Search contacts"
_47
placeholder="Search"
_47
type="search"
_47
name="q"
_47
/>
_47
<div
_47
id="search-spinner"
_47
aria-hidden
_47
hidden={true}
_47
/>
_47
<div
_47
className="sr-only"
_47
aria-live="polite"
_47
></div>
_47
</form>
_47
<form method="post">
_47
<button type="submit">New</button>
_47
</form>
_47
</div>
_47
<nav>
_47
<ul>
_47
<li>
_47
<Link to={`contacts/1`}>Your Name</Link>
_47
</li>
_47
<li>
_47
<Link to={`contacts/2`}>Your Friend</Link>
_47
</li>
_47
</ul>
_47
</nav>
_47
</div>
_47
<div id="detail">
_47
<Outlet />
_47
</div>
_47
</>
_47
)
_47
}

You can open the network tab in the browser devtools to see that it's not requesting documents anymore.

Loading Data

URL segments, layouts, and data are more often than not coupled (tripled?) together. We can see it in this app already:

URL SegmentComponentData
/<Root>list of contacts
contacts/:id<Contact>individual contact

Because of this natural coupling, React Router has data conventions to get data into your route components easily.

There are two APIs we'll be using to load data, loader and useLoaderData. First we'll create and export a loader function in the root module, then we'll hook it up to the route. Finally, we'll access and render the data.

πŸ‘‰ Export a loader from root.jsx

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_53
import { Outlet, Link } from 'react-router-dom'
_53
import { getContacts } from '../contacts'
_53
_53
export async function loader() {
_53
const contacts = await getContacts()
_53
return { contacts }
_53
}
_53
_53
export default function Root() {
_53
return (
_53
<>
_53
<div id="sidebar">
_53
<h1>React Router Contacts</h1>
_53
<div>
_53
<form id="search-form" role="search">
_53
<input
_53
id="q"
_53
aria-label="Search contacts"
_53
placeholder="Search"
_53
type="search"
_53
name="q"
_53
/>
_53
<div
_53
id="search-spinner"
_53
aria-hidden
_53
hidden={true}
_53
/>
_53
<div
_53
className="sr-only"
_53
aria-live="polite"
_53
></div>
_53
</form>
_53
<form method="post">
_53
<button type="submit">New</button>
_53
</form>
_53
</div>
_53
<nav>
_53
<ul>
_53
<li>
_53
<Link to={`contacts/1`}>Your Name</Link>
_53
</li>
_53
<li>
_53
<Link to={`contacts/2`}>Your Friend</Link>
_53
</li>
_53
</ul>
_53
</nav>
_53
</div>
_53
<div id="detail">
_53
<Outlet />
_53
</div>
_53
</>
_53
)
_53
}

πŸ‘‰ Configure the loader on the route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_31
import * as React from 'react'
_31
import * as ReactDOM from 'react-dom/client'
_31
import {
_31
createBrowserRouter,
_31
RouterProvider,
_31
} from 'react-router-dom'
_31
import './index.css'
_31
import Root, { loader as rootLoader } from './routes/root'
_31
import ErrorPage from './error-page'
_31
import Contact from './routes/contact'
_31
_31
const router = createBrowserRouter([
_31
{
_31
path: '/',
_31
element: <Root />,
_31
errorElement: <ErrorPage />,
_31
loader: rootLoader,
_31
children: [
_31
{
_31
path: 'contacts/:contactId',
_31
element: <Contact />,
_31
},
_31
],
_31
},
_31
])
_31
_31
ReactDOM.createRoot(document.getElementById('root')).render(
_31
<React.StrictMode>
_31
<RouterProvider router={router} />
_31
</React.StrictMode>
_31
)

πŸ‘‰ Access and render the data

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_72
import {
_72
Outlet,
_72
Link,
_72
useLoaderData,
_72
} from 'react-router-dom'
_72
import { getContacts } from '../contacts'
_72
_72
export async function loader() {
_72
const contacts = await getContacts()
_72
return { contacts }
_72
}
_72
_72
export default function Root() {
_72
const { contacts } = useLoaderData()
_72
return (
_72
<>
_72
<div id="sidebar">
_72
<h1>React Router Contacts</h1>
_72
<div>
_72
<form id="search-form" role="search">
_72
<input
_72
id="q"
_72
aria-label="Search contacts"
_72
placeholder="Search"
_72
type="search"
_72
name="q"
_72
/>
_72
<div
_72
id="search-spinner"
_72
aria-hidden
_72
hidden={true}
_72
/>
_72
<div
_72
className="sr-only"
_72
aria-live="polite"
_72
></div>
_72
</form>
_72
<form method="post">
_72
<button type="submit">New</button>
_72
</form>
_72
</div>
_72
<nav>
_72
{contacts.length ? (
_72
<ul>
_72
{contacts.map((contact) => (
_72
<li key={contact.id}>
_72
<Link to={`contacts/${contact.id}`}>
_72
{contact.first || contact.last ? (
_72
<>
_72
{contact.first} {contact.last}
_72
</>
_72
) : (
_72
<i>No Name</i>
_72
)}{' '}
_72
{contact.favorite && <span>β˜…</span>}
_72
</Link>
_72
</li>
_72
))}
_72
</ul>
_72
) : (
_72
<p>
_72
<i>No contacts</i>
_72
</p>
_72
)}
_72
</nav>
_72
</div>
_72
<div id="detail">
_72
<Outlet />
_72
</div>
_72
</>
_72
)
_72
}

That's it! React Router will now automatically keep that data in sync with your UI. We don't have any data yet, so you're probably getting a blank list like this:

Data Writes + HTML Forms

We'll create our first contact in a second, but first let's talk about HTML.

React Router emulates HTML Form navigation as the data mutation primitive, according to web development before the JavaScript cambrian explosion. It gives you the UX capabilities of client rendered apps with the simplicity of the "old school" web model.

While unfamiliar to some web developers, HTML forms actually cause a navigation in the browser, just like clicking a link. The only difference is in the request: links can only change the URL while forms can also change the request method (GET vs POST) and the request body (POST form data).

Without client side routing, the browser will serialize the form's data automatically and send it to the server as the request body for POST, and as URLSearchParams for GET. React Router does the same thing, except instead of sending the request to the server, it uses client side routing and sends it to a route action.

We can test this out by clicking the "New" button in our app. The app should blow up because the Vite server isn't configured to handle a POST request (it sends a 404, though it should probably be a 405 🀷).

Instead of sending that POST to the Vite server to create a new contact, let's use client side routing instead.

Creating Contacts

We'll create new contacts by exporting an action in our root route, wiring it up to the route config, and changing our <form> to a React Router <Form>.

πŸ‘‰ Create the action and change <form> to <Form>

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_78
import {
_78
Outlet,
_78
Link,
_78
useLoaderData,
_78
Form,
_78
} from 'react-router-dom'
_78
import { getContacts, createContact } from '../contacts'
_78
_78
export async function action() {
_78
const contact = await createContact()
_78
return { contact }
_78
}
_78
_78
export async function loader() {
_78
const contacts = await getContacts()
_78
return { contacts }
_78
}
_78
_78
export default function Root() {
_78
const { contacts } = useLoaderData()
_78
return (
_78
<>
_78
<div id="sidebar">
_78
<h1>React Router Contacts</h1>
_78
<div>
_78
<form id="search-form" role="search">
_78
<input
_78
id="q"
_78
aria-label="Search contacts"
_78
placeholder="Search"
_78
type="search"
_78
name="q"
_78
/>
_78
<div
_78
id="search-spinner"
_78
aria-hidden
_78
hidden={true}
_78
/>
_78
<div
_78
className="sr-only"
_78
aria-live="polite"
_78
></div>
_78
</form>
_78
<Form method="post">
_78
<button type="submit">New</button>
_78
</Form>
_78
</div>
_78
<nav>
_78
{contacts.length ? (
_78
<ul>
_78
{contacts.map((contact) => (
_78
<li key={contact.id}>
_78
<Link to={`contacts/${contact.id}`}>
_78
{contact.first || contact.last ? (
_78
<>
_78
{contact.first} {contact.last}
_78
</>
_78
) : (
_78
<i>No Name</i>
_78
)}{' '}
_78
{contact.favorite && <span>β˜…</span>}
_78
</Link>
_78
</li>
_78
))}
_78
</ul>
_78
) : (
_78
<p>
_78
<i>No contacts</i>
_78
</p>
_78
)}
_78
</nav>
_78
</div>
_78
<div id="detail">
_78
<Outlet />
_78
</div>
_78
</>
_78
)
_78
}

πŸ‘‰ Import and set the action on the route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_35
import * as React from 'react'
_35
import * as ReactDOM from 'react-dom/client'
_35
import {
_35
createBrowserRouter,
_35
RouterProvider,
_35
} from 'react-router-dom'
_35
import './index.css'
_35
import Root, {
_35
loader as rootLoader,
_35
action as rootAction,
_35
} from './routes/root'
_35
import ErrorPage from './error-page'
_35
import Contact from './routes/contact'
_35
_35
const router = createBrowserRouter([
_35
{
_35
path: '/',
_35
element: <Root />,
_35
errorElement: <ErrorPage />,
_35
loader: rootLoader,
_35
action: rootAction,
_35
children: [
_35
{
_35
path: 'contacts/:contactId',
_35
element: <Contact />,
_35
},
_35
],
_35
},
_35
])
_35
_35
ReactDOM.createRoot(document.getElementById('root')).render(
_35
<React.StrictMode>
_35
<RouterProvider router={router} />
_35
</React.StrictMode>
_35
)

That's it! Go ahead and click the "New" button and you should see a new record pop into the list πŸ₯³

The createContact method just creates an empty contact with no name or data or anything. But it does still create a record, promise!

🧐 Wait a sec ... How did the sidebar update? Where did we call the action? Where's the code to refetch the data? Where are useState, onSubmit and useEffect?!

This is where the "old school web" programming model shows up. As we discussed earlier, <Form> prevents the browser from sending the request to the server and sends it to your route action instead. In web semantics, a POST usually means some data is changing. By convention, React Router uses this as a hint to automatically revalidate the data on the page after the action finishes. That means all of your useLoaderData hooks update and the UI stays in sync with your data automatically! Pretty cool.

URL Params in Loaders

πŸ‘‰ Click on the No Name record

We should be seeing our old static contact page again, with one difference: the URL now has a real ID for the record.

Reviewing the route config, the route looks like this:

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_89
import { Form, useLoaderData } from 'react-router-dom'
_89
import { getContact } from '../contacts'
_89
_89
export async function loader({ params }) {
_89
const contact = await getContact(params.contactId)
_89
return { contact }
_89
}
_89
_89
export default function Contact() {
_89
const { contact } = useLoaderData()
_89
_89
return (
_89
<div id="contact">
_89
<div>
_89
<img
_89
key={contact.avatar}
_89
src={contact.avatar || null}
_89
/>
_89
</div>
_89
_89
<div>
_89
<h1>
_89
{contact.first || contact.last ? (
_89
<>
_89
{contact.first} {contact.last}
_89
</>
_89
) : (
_89
<i>No Name</i>
_89
)}{' '}
_89
<Favorite contact={contact} />
_89
</h1>
_89
_89
{contact.twitter && (
_89
<p>
_89
<a
_89
target="_blank"
_89
href={`https://twitter.com/${contact.twitter}`}
_89
>
_89
{contact.twitter}
_89
</a>
_89
</p>
_89
)}
_89
_89
{contact.notes && <p>{contact.notes}</p>}
_89
_89
<div>
_89
<Form action="edit">
_89
<button type="submit">Edit</button>
_89
</Form>
_89
<Form
_89
method="post"
_89
action="destroy"
_89
onSubmit={(event) => {
_89
if (
_89
!confirm(
_89
'Please confirm you want to delete this record.'
_89
)
_89
) {
_89
event.preventDefault()
_89
}
_89
}}
_89
>
_89
<button type="submit">Delete</button>
_89
</Form>
_89
</div>
_89
</div>
_89
</div>
_89
)
_89
}
_89
_89
function Favorite({ contact }) {
_89
// yes, this is a `let` for later
_89
let favorite = contact.favorite
_89
return (
_89
<Form method="post">
_89
<button
_89
name="favorite"
_89
value={favorite ? 'false' : 'true'}
_89
aria-label={
_89
favorite
_89
? 'Remove from favorites'
_89
: 'Add to favorites'
_89
}
_89
>
_89
{favorite ? 'β˜…' : 'β˜†'}
_89
</button>
_89
</Form>
_89
)
_89
}


;[
{
path: 'contacts/:contactId',
element: <Contact />,
},
]

Note the :contactId URL segment. The colon (:) has special meaning, turning it into a "dynamic segment". Dynamic segments will match dynamic (changing) values in that position of the URL, like the contact ID. We call these values in the URL "URL Params", or just "params" for short.

These params are passed to the loader with keys that match the dynamic segment. For example, our segment is named :contactId so the value will be passed as params.contactId.

These params are most often used to find a record by ID. Let's try it out.

πŸ‘‰ Add a loader to the contact page and access data with useLoaderData

πŸ‘‰ Configure the loader on the route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx

_38
import * as React from 'react'
_38
import * as ReactDOM from 'react-dom/client'
_38
import {
_38
createBrowserRouter,
_38
RouterProvider,
_38
} from 'react-router-dom'
_38
import './index.css'
_38
import Root, {
_38
loader as rootLoader,
_38
action as rootAction,
_38
} from './routes/root'
_38
import ErrorPage from './error-page'
_38
import Contact, {
_38
loader as contactLoader,
_38
} from './routes/contact'
_38
_38
const router = createBrowserRouter([
_38
{
_38
path: '/',
_38
element: <Root />,
_38
errorElement: <ErrorPage />,
_38
loader: rootLoader,
_38
action: rootAction,
_38
children: [
_38
{
_38
path: 'contacts/:contactId',
_38
element: <Contact />,
_38
loader: contactLoader,
_38
},
_38
],
_38
},
_38
])
_38
_38
ReactDOM.createRoot(document.getElementById('root')).render(
_38
<React.StrictMode>
_38
<RouterProvider router={router} />
_38
</React.StrictMode>
_38
)

Updating Data

Just like creating data, you update data with <Form>. Let's make a new route at contacts/:contactId/edit. Again, we'll start with the component and then wire it up to the route config.

πŸ‘‰ Create the edit component

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_58
import { Form, useLoaderData } from 'react-router-dom'
_58
_58
export default function EditContact() {
_58
const { contact } = useLoaderData()
_58
_58
return (
_58
<Form method="post" id="contact-form">
_58
<p>
_58
<span>Name</span>
_58
<input
_58
placeholder="First"
_58
aria-label="First name"
_58
type="text"
_58
name="first"
_58
defaultValue={contact.first}
_58
/>
_58
<input
_58
placeholder="Last"
_58
aria-label="Last name"
_58
type="text"
_58
name="last"
_58
defaultValue={contact.last}
_58
/>
_58
</p>
_58
<label>
_58
<span>Twitter</span>
_58
<input
_58
type="text"
_58
name="twitter"
_58
placeholder="@jack"
_58
defaultValue={contact.twitter}
_58
/>
_58
</label>
_58
<label>
_58
<span>Avatar URL</span>
_58
<input
_58
placeholder="https://example.com/avatar.jpg"
_58
aria-label="Avatar URL"
_58
type="text"
_58
name="avatar"
_58
defaultValue={contact.avatar}
_58
/>
_58
</label>
_58
<label>
_58
<span>Notes</span>
_58
<textarea
_58
name="notes"
_58
defaultValue={contact.notes}
_58
rows={6}
_58
/>
_58
</label>
_58
<p>
_58
<button type="submit">Save</button>
_58
<button type="button">Cancel</button>
_58
</p>
_58
</Form>
_58
)
_58
}


touch src/routes/edit.jsx

πŸ‘‰ Add the edit page UI

Nothing we haven't seen before, feel free to copy/paste:

πŸ‘‰ Add the new edit route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_44
import * as React from 'react'
_44
import * as ReactDOM from 'react-dom/client'
_44
import {
_44
createBrowserRouter,
_44
RouterProvider,
_44
} from 'react-router-dom'
_44
import './index.css'
_44
import Root, {
_44
loader as rootLoader,
_44
action as rootAction,
_44
} from './routes/root'
_44
import ErrorPage from './error-page'
_44
import Contact, {
_44
loader as contactLoader,
_44
} from './routes/contact'
_44
import EditContact from './routes/edit'
_44
_44
const router = createBrowserRouter([
_44
{
_44
path: '/',
_44
element: <Root />,
_44
errorElement: <ErrorPage />,
_44
loader: rootLoader,
_44
action: rootAction,
_44
children: [
_44
{
_44
path: 'contacts/:contactId',
_44
element: <Contact />,
_44
loader: contactLoader,
_44
},
_44
{
_44
path: 'contacts/:contactId/edit',
_44
element: <EditContact />,
_44
loader: contactLoader,
_44
},
_44
],
_44
},
_44
])
_44
_44
ReactDOM.createRoot(document.getElementById('root')).render(
_44
<React.StrictMode>
_44
<RouterProvider router={router} />
_44
</React.StrictMode>
_44
)

We want it to be rendered in the root route's outlet, so we made it a sibling to the existing child route.

(You might note we reused the contactLoader for this route. This is only because we're being lazy in the tutorial. There is no reason to attempt to share loaders among routes, they usually have their own.)

Alright, clicking the "Edit" button gives us this new UI:

Updating Contacts with FormData

The edit route we just created already renders a form. All we need to do to update the record is wire up an action to the route. The form will post to the action and the data will be automatically revalidated.

πŸ‘‰ Add an action to the edit module

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_70
import {
_70
Form,
_70
useLoaderData,
_70
redirect,
_70
} from 'react-router-dom'
_70
import { updateContact } from '../contacts'
_70
_70
export async function action({ request, params }) {
_70
const formData = await request.formData()
_70
const updates = Object.fromEntries(formData)
_70
await updateContact(params.contactId, updates)
_70
return redirect(`/contacts/${params.contactId}`)
_70
}
_70
_70
export default function EditContact() {
_70
const { contact } = useLoaderData()
_70
_70
return (
_70
<Form method="post" id="contact-form">
_70
<p>
_70
<span>Name</span>
_70
<input
_70
placeholder="First"
_70
aria-label="First name"
_70
type="text"
_70
name="first"
_70
defaultValue={contact.first}
_70
/>
_70
<input
_70
placeholder="Last"
_70
aria-label="Last name"
_70
type="text"
_70
name="last"
_70
defaultValue={contact.last}
_70
/>
_70
</p>
_70
<label>
_70
<span>Twitter</span>
_70
<input
_70
type="text"
_70
name="twitter"
_70
placeholder="@jack"
_70
defaultValue={contact.twitter}
_70
/>
_70
</label>
_70
<label>
_70
<span>Avatar URL</span>
_70
<input
_70
placeholder="https://example.com/avatar.jpg"
_70
aria-label="Avatar URL"
_70
type="text"
_70
name="avatar"
_70
defaultValue={contact.avatar}
_70
/>
_70
</label>
_70
<label>
_70
<span>Notes</span>
_70
<textarea
_70
name="notes"
_70
defaultValue={contact.notes}
_70
rows={6}
_70
/>
_70
</label>
_70
<p>
_70
<button type="submit">Save</button>
_70
<button type="button">Cancel</button>
_70
</p>
_70
</Form>
_70
)
_70
}

πŸ‘‰ Wire the action up to the route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_47
import * as React from 'react'
_47
import * as ReactDOM from 'react-dom/client'
_47
import {
_47
createBrowserRouter,
_47
RouterProvider,
_47
} from 'react-router-dom'
_47
import './index.css'
_47
import Root, {
_47
loader as rootLoader,
_47
action as rootAction,
_47
} from './routes/root'
_47
import ErrorPage from './error-page'
_47
import Contact, {
_47
loader as contactLoader,
_47
} from './routes/contact'
_47
import EditContact, {
_47
action as editAction,
_47
} from './routes/edit'
_47
_47
const router = createBrowserRouter([
_47
{
_47
path: '/',
_47
element: <Root />,
_47
errorElement: <ErrorPage />,
_47
loader: rootLoader,
_47
action: rootAction,
_47
children: [
_47
{
_47
path: 'contacts/:contactId',
_47
element: <Contact />,
_47
loader: contactLoader,
_47
},
_47
{
_47
path: 'contacts/:contactId/edit',
_47
element: <EditContact />,
_47
loader: contactLoader,
_47
action: editAction,
_47
},
_47
],
_47
},
_47
])
_47
_47
ReactDOM.createRoot(document.getElementById('root')).render(
_47
<React.StrictMode>
_47
<RouterProvider router={router} />
_47
</React.StrictMode>
_47
)

Fill out the form, hit save, and you should see something like this! (Except easier on the eyes and maybe less hairy.)

Mutation Discussion

πŸ˜‘ It worked, but I have no idea what is going on here...

Let's dig in a bit...

Open up src/routes/edit.jsx and look at the form elements. Notice how they each have a name:

src/routes/edit.jsx

<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>

Without JavaScript, when a form is submitted, the browser will create FormData and set it as the body of the request when it sends it to the server. As mentioned before, React Router prevents that and sends the request to your action instead, including the FormData.

Each field in the form is accessible with formData.get(name). For example, given the input field from above, you could access the first and last names like this:


export async function action({ request, params }) {
const formData = await request.formData()
const firstName = formData.get('first')
const lastName = formData.get('last')
// ...
}

Since we have a handful of form fields, we used Object.fromEntries to collect them all into an object, which is exactly what our updateContact function wants.


const updates = Object.fromEntries(formData)
updates.first // "Some"
updates.last // "Name"

Aside from action, none of these APIs we're discussing are provided by React Router: request, request.formData, Object.fromEntries are all provided by the web platform.

After we finished the action, note the redirect at the end:

src/routes/edit.jsx

export async function action({ request, params }) {
const formData = await request.formData()
const updates = Object.fromEntries(formData)
await updateContact(params.contactId, updates)
return redirect(`/contacts/${params.contactId}`)
}

Loaders and actions can both return a Response (makes sense, since they received a Request!). The redirect helper just makes it easier to return a response that tells the app to change locations.

Without client side routing, if a server redirected after a POST request, the new page would fetch the latest data and render. As we learned before, React Router emulates this model and automatically revalidates the data on the page after the action. That's why the sidebar automatically updates when we save the form. The extra revalidation code doesn't exist without client side routing, so it doesn't need to exist with client side routing either!

Redirecting new records to the edit page

Now that we know how to redirect, let's update the action that creates new contacts to redirect to the edit page:

πŸ‘‰ Redirect to the new record's edit page

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_79
import {
_79
Outlet,
_79
Link,
_79
useLoaderData,
_79
Form,
_79
redirect,
_79
} from 'react-router-dom'
_79
import { getContacts, createContact } from '../contacts'
_79
_79
export async function action() {
_79
const contact = await createContact()
_79
return redirect(`/contacts/${contact.id}/edit`)
_79
}
_79
_79
export async function loader() {
_79
const contacts = await getContacts()
_79
return { contacts }
_79
}
_79
_79
export default function Root() {
_79
const { contacts } = useLoaderData()
_79
return (
_79
<>
_79
<div id="sidebar">
_79
<h1>React Router Contacts</h1>
_79
<div>
_79
<form id="search-form" role="search">
_79
<input
_79
id="q"
_79
aria-label="Search contacts"
_79
placeholder="Search"
_79
type="search"
_79
name="q"
_79
/>
_79
<div
_79
id="search-spinner"
_79
aria-hidden
_79
hidden={true}
_79
/>
_79
<div
_79
className="sr-only"
_79
aria-live="polite"
_79
></div>
_79
</form>
_79
<Form method="post">
_79
<button type="submit">New</button>
_79
</Form>
_79
</div>
_79
<nav>
_79
{contacts.length ? (
_79
<ul>
_79
{contacts.map((contact) => (
_79
<li key={contact.id}>
_79
<Link to={`contacts/${contact.id}`}>
_79
{contact.first || contact.last ? (
_79
<>
_79
{contact.first} {contact.last}
_79
</>
_79
) : (
_79
<i>No Name</i>
_79
)}{' '}
_79
{contact.favorite && <span>β˜…</span>}
_79
</Link>
_79
</li>
_79
))}
_79
</ul>
_79
) : (
_79
<p>
_79
<i>No contacts</i>
_79
</p>
_79
)}
_79
</nav>
_79
</div>
_79
<div id="detail">
_79
<Outlet />
_79
</div>
_79
</>
_79
)
_79
}

Now when we click "New", we should end up on the edit page:

πŸ‘‰ Add a handful of records

I'm going to use the stellar lineup of speakers from the first Remix Conference 😁

Active Link Styling

Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use NavLink to fix this.

πŸ‘‰ Use a NavLink in the sidebar

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_88
import {
_88
Outlet,
_88
NavLink,
_88
useLoaderData,
_88
Form,
_88
redirect,
_88
} from 'react-router-dom'
_88
import { getContacts, createContact } from '../contacts'
_88
_88
export async function action() {
_88
const contact = await createContact()
_88
return redirect(`/contacts/${contact.id}/edit`)
_88
}
_88
_88
export async function loader() {
_88
const contacts = await getContacts()
_88
return { contacts }
_88
}
_88
_88
export default function Root() {
_88
const { contacts } = useLoaderData()
_88
return (
_88
<>
_88
<div id="sidebar">
_88
<h1>React Router Contacts</h1>
_88
<div>
_88
<form id="search-form" role="search">
_88
<input
_88
id="q"
_88
aria-label="Search contacts"
_88
placeholder="Search"
_88
type="search"
_88
name="q"
_88
/>
_88
<div
_88
id="search-spinner"
_88
aria-hidden
_88
hidden={true}
_88
/>
_88
<div
_88
className="sr-only"
_88
aria-live="polite"
_88
></div>
_88
</form>
_88
<Form method="post">
_88
<button type="submit">New</button>
_88
</Form>
_88
</div>
_88
<nav>
_88
{contacts.length ? (
_88
<ul>
_88
{contacts.map((contact) => (
_88
<li key={contact.id}>
_88
<NavLink
_88
to={`contacts/${contact.id}`}
_88
className={({ isActive, isPending }) =>
_88
isActive
_88
? 'active'
_88
: isPending
_88
? 'pending'
_88
: ''
_88
}
_88
>
_88
{contact.first || contact.last ? (
_88
<>
_88
{contact.first} {contact.last}
_88
</>
_88
) : (
_88
<i>No Name</i>
_88
)}{' '}
_88
{contact.favorite && <span>β˜…</span>}
_88
</NavLink>
_88
</li>
_88
))}
_88
</ul>
_88
) : (
_88
<p>
_88
<i>No contacts</i>
_88
</p>
_88
)}
_88
</nav>
_88
</div>
_88
<div id="detail">
_88
<Outlet />
_88
</div>
_88
</>
_88
)
_88
}

Note that we are passing a function to className. When the user is at the URL in the NavLink, then isActive will be true. When it's about to be active (the data is still loading) then isPending will be true. This allows us to easily indicate where the user is, as well as provide immediate feedback on links that have been clicked but we're still waiting for data to load.

Global Pending UI

As the user navigates the app, React Router will leave the old page up as data is loading for the next page. You may have noticed the app feels a little unresponsive as you click between the list. Let's provide the user with some feedback so the app doesn't feel unresponsive.

React Router is managing all of the state behind the scenes and reveals the pieces of it you need to build dynamic web apps. In this case, we'll use the useNavigation hook.

πŸ‘‰ useNavigation to add global pending UI

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx

_96
import {
_96
Outlet,
_96
NavLink,
_96
useLoaderData,
_96
Form,
_96
redirect,
_96
useNavigation,
_96
} from 'react-router-dom'
_96
import { getContacts, createContact } from '../contacts'
_96
_96
export async function action() {
_96
const contact = await createContact()
_96
return redirect(`/contacts/${contact.id}/edit`)
_96
}
_96
_96
export async function loader() {
_96
const contacts = await getContacts()
_96
return { contacts }
_96
}
_96
_96
export default function Root() {
_96
const { contacts } = useLoaderData()
_96
const navigation = useNavigation()
_96
_96
return (
_96
<>
_96
<div id="sidebar">
_96
<h1>React Router Contacts</h1>
_96
<div>
_96
<form id="search-form" role="search">
_96
<input
_96
id="q"
_96
aria-label="Search contacts"
_96
placeholder="Search"
_96
type="search"
_96
name="q"
_96
/>
_96
<div
_96
id="search-spinner"
_96
aria-hidden
_96
hidden={true}
_96
/>
_96
<div
_96
className="sr-only"
_96
aria-live="polite"
_96
></div>
_96
</form>
_96
<Form method="post">
_96
<button type="submit">New</button>
_96
</Form>
_96
</div>
_96
<nav>
_96
{contacts.length ? (
_96
<ul>
_96
{contacts.map((contact) => (
_96
<li key={contact.id}>
_96
<NavLink
_96
to={`contacts/${contact.id}`}
_96
className={({ isActive, isPending }) =>
_96
isActive
_96
? 'active'
_96
: isPending
_96
? 'pending'
_96
: ''
_96
}
_96
>
_96
{contact.first || contact.last ? (
_96
<>
_96
{contact.first} {contact.last}
_96
</>
_96
) : (
_96
<i>No Name</i>
_96
)}{' '}
_96
{contact.favorite && <span>β˜…</span>}
_96
</NavLink>
_96
</li>
_96
))}
_96
</ul>
_96
) : (
_96
<p>
_96
<i>No contacts</i>
_96
</p>
_96
)}
_96
</nav>
_96
</div>
_96
<div
_96
id="detail"
_96
className={
_96
navigation.state === 'loading' ? 'loading' : ''
_96
}
_96
>
_96
<Outlet />
_96
</div>
_96
</>
_96
)
_96
}

useNavigation returns the current navigation state: it can be one of "idle" | "submitting" | "loading".

In our case, we add a "loading" class to the main part of the app if we're not idle. The CSS then adds a nice fade after a short delay (to avoid flickering the UI for fast loads). You could do anything you want though, like show a spinner or loading bar across the top.

Note that our data model (src/contacts.js) has a clientside cache, so navigating to the same contact is fast the second time. This behavior is not React Router, it will re-load data for changing routes no matter if you've been there before or not. It does, however, avoid calling the loaders for unchanging routes (like the list) during a navigation.

Deleting Records

If we review code in the contact route, we can find the delete button looks like this

Note the action points to "destroy". Like <Link to>, <Form action> can take a relative value. Since the form is rendered in contact/:contactId, then a relative action with destroy will submit the form to contact/:contactId/destroy when clicked.

At this point you should know everything you need to know to make the delete button work. Maybe give it a shot before moving on? You'll need:

  1. A new route
  2. An action at that route
  3. deleteContact from src/contacts.js
src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx

_10
import { redirect } from 'react-router-dom'
_10
import { deleteContact } from '../contacts'
_10
_10
export async function action({ params }) {
_10
await deleteContact(params.contactId)
_10
return redirect('/')
_10
}

πŸ‘‰ Create the "destroy" route module


touch src/routes/destroy.jsx

πŸ‘‰ Add the destroy action

πŸ‘‰ Add the destroy route to the route config

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx

_52
import * as React from 'react'
_52
import * as ReactDOM from 'react-dom/client'
_52
import {
_52
createBrowserRouter,
_52
RouterProvider,
_52
} from 'react-router-dom'
_52
import './index.css'
_52
import Root, {
_52
loader as rootLoader,
_52
action as rootAction,
_52
} from './routes/root'
_52
import ErrorPage from './error-page'
_52
import Contact, {
_52
loader as contactLoader,
_52
} from './routes/contact'
_52
import EditContact, {
_52
action as editAction,
_52
} from './routes/edit'
_52
import { action as destroyAction } from './routes/destroy'
_52
_52
const router = createBrowserRouter([
_52
{
_52
path: '/',
_52
element: <Root />,
_52
errorElement: <ErrorPage />,
_52
loader: rootLoader,
_52
action: rootAction,
_52
children: [
_52
{
_52
path: 'contacts/:contactId',
_52
element: <Contact />,
_52
loader: contactLoader,
_52
},
_52
{
_52
path: 'contacts/:contactId/edit',
_52
element: <EditContact />,
_52
loader: contactLoader,
_52
action: editAction,
_52
},
_52
{
_52
path: 'contacts/:contactId/destroy',
_52
action: destroyAction,
_52
},
_52
],
_52
},
_52
])
_52
_52
ReactDOM.createRoot(document.getElementById('root')).render(
_52
<React.StrictMode>
_52
<RouterProvider router={router} />
_52
</React.StrictMode>
_52
)

Alright, navigate to a record and click the "Delete" button. It works!

πŸ˜… I'm still confused why this all works

When the user clicks the submit button:

  1. <Form> prevents the default browser behavior of sending a new POST request to the server, but instead emulates the browser by creating a POST request with client side routing
  2. The <Form action="destroy"> matches the new route at "contacts/:contactId/destroy" and sends it the request
  3. After the action redirects, React Router calls all of the loaders for the data on the page to get the latest values (this is "revalidation"). useLoaderData returns new values and causes the components to update!

Add a form, add an action, React Router does the rest.

Contextual Errors

πŸ‘‰ Just for kicks, throw an error in the destroy action

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx

_10
import { redirect } from 'react-router-dom'
_10
import { deleteContact } from '../contacts'
_10
_10
export async function action({ params }) {
_10
throw new Error('oh dang!')
_10
await deleteContact(params.contactId)
_10
return redirect('/')
_10
}

Recognize that screen? It's our errorElement from before. The user, however, can't really do anything to recover from this screen except to hit refresh.

πŸ‘‰ Let's create a contextual error message for the destroy route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx

_53
import * as React from 'react'
_53
import * as ReactDOM from 'react-dom/client'
_53
import {
_53
createBrowserRouter,
_53
RouterProvider,
_53
} from 'react-router-dom'
_53
import './index.css'
_53
import Root, {
_53
loader as rootLoader,
_53
action as rootAction,
_53
} from './routes/root'
_53
import ErrorPage from './error-page'
_53
import Contact, {
_53
loader as contactLoader,
_53
} from './routes/contact'
_53
import EditContact, {
_53
action as editAction,
_53
} from './routes/edit'
_53
import { action as destroyAction } from './routes/destroy'
_53
_53
const router = createBrowserRouter([
_53
{
_53
path: '/',
_53
element: <Root />,
_53
errorElement: <ErrorPage />,
_53
loader: rootLoader,
_53
action: rootAction,
_53
children: [
_53
{
_53
path: 'contacts/:contactId',
_53
element: <Contact />,
_53
loader: contactLoader,
_53
},
_53
{
_53
path: 'contacts/:contactId/edit',
_53
element: <EditContact />,
_53
loader: contactLoader,
_53
action: editAction,
_53
},
_53
{
_53
path: 'contacts/:contactId/destroy',
_53
action: destroyAction,
_53
errorElement: <div>Oops! There was an error.</div>,
_53
},
_53
],
_53
},
_53
])
_53
_53
ReactDOM.createRoot(document.getElementById('root')).render(
_53
<React.StrictMode>
_53
<RouterProvider router={router} />
_53
</React.StrictMode>
_53
)

Now try it again:

Our user now has more options than slamming refresh, they can continue to interact with the parts of the page that aren't having trouble πŸ™Œ

Because the destroy route has its own errorElement and is a child of the root route, the error will render there instead of the root. As you probably noticed, these errors bubble up to the nearest errorElement. Add as many or as few as you like, as long as you've got one at the root.

πŸ‘‰ Remember to remove the error we added for kicks

src/routes/destroy.jsx

import { redirect } from 'react-router-dom'
import { deleteContact } from '../contacts'
export async function action({ params }) {
throw new Error('oh dang!') // remove this line
await deleteContact(params.contactId)
return redirect('/')
}

Index Routes

When we load up the app, you'll notice a big blank page on the right side of our list.

When a route has children, and you're at the parent route's path, the <Outlet> has nothing to render because no children match. You can think of index routes as the default child route to fill in that space.

πŸ‘‰ Create the index route module, and fill in the index component's elements

Feel free to copy paste, nothing special here.

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_13
export default function Index() {
_13
return (
_13
<p id="zero-state">
_13
This is a demo for React Router.
_13
<br />
_13
Check out{' '}
_13
<a href="https://reactrouter.com">
_13
the docs at reactrouter.com
_13
</a>
_13
.
_13
</p>
_13
)
_13
}

πŸ‘‰ Configure the index route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_55
import * as React from 'react'
_55
import * as ReactDOM from 'react-dom/client'
_55
import {
_55
createBrowserRouter,
_55
RouterProvider,
_55
} from 'react-router-dom'
_55
import './index.css'
_55
import Root, {
_55
loader as rootLoader,
_55
action as rootAction,
_55
} from './routes/root'
_55
import ErrorPage from './error-page'
_55
import Contact, {
_55
loader as contactLoader,
_55
} from './routes/contact'
_55
import EditContact, {
_55
action as editAction,
_55
} from './routes/edit'
_55
import { action as destroyAction } from './routes/destroy'
_55
import Index from './routes/index'
_55
_55
const router = createBrowserRouter([
_55
{
_55
path: '/',
_55
element: <Root />,
_55
errorElement: <ErrorPage />,
_55
loader: rootLoader,
_55
action: rootAction,
_55
children: [
_55
{ index: true, element: <Index /> },
_55
{
_55
path: 'contacts/:contactId',
_55
element: <Contact />,
_55
loader: contactLoader,
_55
},
_55
{
_55
path: 'contacts/:contactId/edit',
_55
element: <EditContact />,
_55
loader: contactLoader,
_55
action: editAction,
_55
},
_55
{
_55
path: 'contacts/:contactId/destroy',
_55
action: destroyAction,
_55
errorElement: <div>Oops! There was an error.</div>,
_55
},
_55
],
_55
},
_55
])
_55
_55
ReactDOM.createRoot(document.getElementById('root')).render(
_55
<React.StrictMode>
_55
<RouterProvider router={router} />
_55
</React.StrictMode>
_55
)

Note the { index:true } instead of { path: "" }. That tells the router to match and render this route when the user is at the parent route's exact path, so there are no other child routes to render in the <Outlet>.

Voila! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well.

Cancel Button

On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button.

We'll need a click handler on the button as well as useNavigate from React Router.

πŸ‘‰ Add the cancel button click handler with useNavigate

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_79
import {
_79
Form,
_79
useLoaderData,
_79
redirect,
_79
useNavigate,
_79
} from 'react-router-dom'
_79
import { updateContact } from '../contacts'
_79
_79
export async function action({ request, params }) {
_79
const formData = await request.formData()
_79
const updates = Object.fromEntries(formData)
_79
await updateContact(params.contactId, updates)
_79
return redirect(`/contacts/${params.contactId}`)
_79
}
_79
_79
export default function EditContact() {
_79
const { contact } = useLoaderData()
_79
const navigate = useNavigate()
_79
_79
return (
_79
<Form method="post" id="contact-form">
_79
<p>
_79
<span>Name</span>
_79
<input
_79
placeholder="First"
_79
aria-label="First name"
_79
type="text"
_79
name="first"
_79
defaultValue={contact.first}
_79
/>
_79
<input
_79
placeholder="Last"
_79
aria-label="Last name"
_79
type="text"
_79
name="last"
_79
defaultValue={contact.last}
_79
/>
_79
</p>
_79
<label>
_79
<span>Twitter</span>
_79
<input
_79
type="text"
_79
name="twitter"
_79
placeholder="@jack"
_79
defaultValue={contact.twitter}
_79
/>
_79
</label>
_79
<label>
_79
<span>Avatar URL</span>
_79
<input
_79
placeholder="https://example.com/avatar.jpg"
_79
aria-label="Avatar URL"
_79
type="text"
_79
name="avatar"
_79
defaultValue={contact.avatar}
_79
/>
_79
</label>
_79
<label>
_79
<span>Notes</span>
_79
<textarea
_79
name="notes"
_79
defaultValue={contact.notes}
_79
rows={6}
_79
/>
_79
</label>
_79
<p>
_79
<button type="submit">Save</button>
_79
<button
_79
type="button"
_79
onClick={() => {
_79
navigate(-1)
_79
}}
_79
>
_79
Cancel
_79
</button>
_79
</p>
_79
</Form>
_79
)
_79
}

Now when the user clicks "Cancel", they'll be sent back one entry in the browser's history.

🧐 Why is there no event.preventDefault on the button?

A <button type="button">, while seemingly redundant, is the HTML way of preventing a button from submitting its form.

Two more features to go. We're on the home stretch!

URL Search Params and GET Submissions

All of our interactive UI so far have been either links that change the URL or forms that post data to actions. The search field is interesting because it's a mix of both: it's a form but it only changes the URL, it doesn't change data.

Right now it's just a normal HTML <form>, not a React Router <Form>. Let's see what the browser does with it by default:

πŸ‘‰ Type a name into the search field and hit the enter key

Note the browser's URL now contains your query in the URL as URLSearchParams:


http://127.0.0.1:5173/?q=ryan

If we review the search form, it looks like this:

src/routes/root.jsx

<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</form>

As we've seen before, browsers can serialize forms by the name attribute of it's input elements. The name of this input is q, that's why the URL has ?q=. If we named it search the URL would be ?search=.

Note that this form is different from the others we've used, it does not have <form method="post">. The default method is "get". That means when the browser creates the request for the next document, it doesn't put the form data into the request POST body, but into the URLSearchParams of a GET request.

GET Submissions with Client Side Routing

Let's use client side routing to submit this form and filter the list in our existing loader.

πŸ‘‰ Change <form> to <Form>

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_96
import {
_96
Outlet,
_96
NavLink,
_96
useLoaderData,
_96
Form,
_96
redirect,
_96
useNavigation,
_96
} from 'react-router-dom'
_96
import { getContacts, createContact } from '../contacts'
_96
_96
export async function action() {
_96
const contact = await createContact()
_96
return redirect(`/contacts/${contact.id}/edit`)
_96
}
_96
_96
export async function loader() {
_96
const contacts = await getContacts()
_96
return { contacts }
_96
}
_96
_96
export default function Root() {
_96
const { contacts } = useLoaderData()
_96
const navigation = useNavigation()
_96
_96
return (
_96
<>
_96
<div id="sidebar">
_96
<h1>React Router Contacts</h1>
_96
<div>
_96
<Form id="search-form" role="search">
_96
<input
_96
id="q"
_96
aria-label="Search contacts"
_96
placeholder="Search"
_96
type="search"
_96
name="q"
_96
/>
_96
<div
_96
id="search-spinner"
_96
aria-hidden
_96
hidden={true}
_96
/>
_96
<div
_96
className="sr-only"
_96
aria-live="polite"
_96
></div>
_96
</Form>
_96
<Form method="post">
_96
<button type="submit">New</button>
_96
</Form>
_96
</div>
_96
<nav>
_96
{contacts.length ? (
_96
<ul>
_96
{contacts.map((contact) => (
_96
<li key={contact.id}>
_96
<NavLink
_96
to={`contacts/${contact.id}`}
_96
className={({ isActive, isPending }) =>
_96
isActive
_96
? 'active'
_96
: isPending
_96
? 'pending'
_96
: ''
_96
}
_96
>
_96
{contact.first || contact.last ? (
_96
<>
_96
{contact.first} {contact.last}
_96
</>
_96
) : (
_96
<i>No Name</i>
_96
)}{' '}
_96
{contact.favorite && <span>β˜…</span>}
_96
</NavLink>
_96
</li>
_96
))}
_96
</ul>
_96
) : (
_96
<p>
_96
<i>No contacts</i>
_96
</p>
_96
)}
_96
</nav>
_96
</div>
_96
<div
_96
id="detail"
_96
className={
_96
navigation.state === 'loading' ? 'loading' : ''
_96
}
_96
>
_96
<Outlet />
_96
</div>
_96
</>
_96
)
_96
}

πŸ‘‰ Filter the list if there are URLSearchParams

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_98
import {
_98
Outlet,
_98
NavLink,
_98
useLoaderData,
_98
Form,
_98
redirect,
_98
useNavigation,
_98
} from 'react-router-dom'
_98
import { getContacts, createContact } from '../contacts'
_98
_98
export async function action() {
_98
const contact = await createContact()
_98
return redirect(`/contacts/${contact.id}/edit`)
_98
}
_98
_98
export async function loader({ request }) {
_98
const url = new URL(request.url)
_98
const q = url.searchParams.get('q')
_98
const contacts = await getContacts(q)
_98
return { contacts }
_98
}
_98
_98
export default function Root() {
_98
const { contacts } = useLoaderData()
_98
const navigation = useNavigation()
_98
_98
return (
_98
<>
_98
<div id="sidebar">
_98
<h1>React Router Contacts</h1>
_98
<div>
_98
<Form id="search-form" role="search">
_98
<input
_98
id="q"
_98
aria-label="Search contacts"
_98
placeholder="Search"
_98
type="search"
_98
name="q"
_98
/>
_98
<div
_98
id="search-spinner"
_98
aria-hidden
_98
hidden={true}
_98
/>
_98
<div
_98
className="sr-only"
_98
aria-live="polite"
_98
></div>
_98
</Form>
_98
<Form method="post">
_98
<button type="submit">New</button>
_98
</Form>
_98
</div>
_98
<nav>
_98
{contacts.length ? (
_98
<ul>
_98
{contacts.map((contact) => (
_98
<li key={contact.id}>
_98
<NavLink
_98
to={`contacts/${contact.id}`}
_98
className={({ isActive, isPending }) =>
_98
isActive
_98
? 'active'
_98
: isPending
_98
? 'pending'
_98
: ''
_98
}
_98
>
_98
{contact.first || contact.last ? (
_98
<>
_98
{contact.first} {contact.last}
_98
</>
_98
) : (
_98
<i>No Name</i>
_98
)}{' '}
_98
{contact.favorite && <span>β˜…</span>}
_98
</NavLink>
_98
</li>
_98
))}
_98
</ul>
_98
) : (
_98
<p>
_98
<i>No contacts</i>
_98
</p>
_98
)}
_98
</nav>
_98
</div>
_98
<div
_98
id="detail"
_98
className={
_98
navigation.state === 'loading' ? 'loading' : ''
_98
}
_98
>
_98
<Outlet />
_98
</div>
_98
</>
_98
)
_98
}

Because this is a GET, not a POST, React Router does not call the action. Submitting a GET form is the same as clicking a link: only the URL changes. That's why the code we added for filtering is in the loader, not the action of this route.

This also means it's a normal page navigation. You can click the back button to get back to where you were.

Synchronizing URLs to Form State

There are a couple of UX issues here that we can take care of quickly.

  1. If you click back after a search, the form field still has the value you entered even though the list is no longer filtered.
  2. If you refresh the page after searching, the form field no longer has the value in it, even though the list is filtered

In other words, the URL and our form state are out of sync.

πŸ‘‰ Return q from your loader and set it as the search field default value

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_99
import {
_99
Outlet,
_99
NavLink,
_99
useLoaderData,
_99
Form,
_99
redirect,
_99
useNavigation,
_99
} from 'react-router-dom'
_99
import { getContacts, createContact } from '../contacts'
_99
_99
export async function action() {
_99
const contact = await createContact()
_99
return redirect(`/contacts/${contact.id}/edit`)
_99
}
_99
_99
export async function loader({ request }) {
_99
const url = new URL(request.url)
_99
const q = url.searchParams.get('q')
_99
const contacts = await getContacts(q)
_99
return { contacts, q }
_99
}
_99
_99
export default function Root() {
_99
const { contacts, q } = useLoaderData()
_99
const navigation = useNavigation()
_99
_99
return (
_99
<>
_99
<div id="sidebar">
_99
<h1>React Router Contacts</h1>
_99
<div>
_99
<Form id="search-form" role="search">
_99
<input
_99
id="q"
_99
aria-label="Search contacts"
_99
placeholder="Search"
_99
type="search"
_99
name="q"
_99
defaultValue={q}
_99
/>
_99
<div
_99
id="search-spinner"
_99
aria-hidden
_99
hidden={true}
_99
/>
_99
<div
_99
className="sr-only"
_99
aria-live="polite"
_99
></div>
_99
</Form>
_99
<Form method="post">
_99
<button type="submit">New</button>
_99
</Form>
_99
</div>
_99
<nav>
_99
{contacts.length ? (
_99
<ul>
_99
{contacts.map((contact) => (
_99
<li key={contact.id}>
_99
<NavLink
_99
to={`contacts/${contact.id}`}
_99
className={({ isActive, isPending }) =>
_99
isActive
_99
? 'active'
_99
: isPending
_99
? 'pending'
_99
: ''
_99
}
_99
>
_99
{contact.first || contact.last ? (
_99
<>
_99
{contact.first} {contact.last}
_99
</>
_99
) : (
_99
<i>No Name</i>
_99
)}{' '}
_99
{contact.favorite && <span>β˜…</span>}
_99
</NavLink>
_99
</li>
_99
))}
_99
</ul>
_99
) : (
_99
<p>
_99
<i>No contacts</i>
_99
</p>
_99
)}
_99
</nav>
_99
</div>
_99
<div
_99
id="detail"
_99
className={
_99
navigation.state === 'loading' ? 'loading' : ''
_99
}
_99
>
_99
<Outlet />
_99
</div>
_99
</>
_99
)
_99
}

That solves problem (2). If you refresh the page now, the input field will show the query.

Now for problem (1), clicking the back button and updating the input. We can bring in useEffect from React to manipulate the form's state in the DOM directly.

πŸ‘‰ Synchronize input value with the URL Search Params

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_104
import {
_104
Outlet,
_104
NavLink,
_104
useLoaderData,
_104
Form,
_104
redirect,
_104
useNavigation,
_104
} from 'react-router-dom'
_104
import { getContacts, createContact } from '../contacts'
_104
import { useEffect } from 'react'
_104
_104
export async function action() {
_104
const contact = await createContact()
_104
return redirect(`/contacts/${contact.id}/edit`)
_104
}
_104
_104
export async function loader({ request }) {
_104
const url = new URL(request.url)
_104
const q = url.searchParams.get('q')
_104
const contacts = await getContacts(q)
_104
return { contacts, q }
_104
}
_104
_104
export default function Root() {
_104
const { contacts, q } = useLoaderData()
_104
const navigation = useNavigation()
_104
_104
useEffect(() => {
_104
document.getElementById('q').value = q
_104
}, [q])
_104
_104
return (
_104
<>
_104
<div id="sidebar">
_104
<h1>React Router Contacts</h1>
_104
<div>
_104
<Form id="search-form" role="search">
_104
<input
_104
id="q"
_104
aria-label="Search contacts"
_104
placeholder="Search"
_104
type="search"
_104
name="q"
_104
defaultValue={q}
_104
/>
_104
<div
_104
id="search-spinner"
_104
aria-hidden
_104
hidden={true}
_104
/>
_104
<div
_104
className="sr-only"
_104
aria-live="polite"
_104
></div>
_104
</Form>
_104
<Form method="post">
_104
<button type="submit">New</button>
_104
</Form>
_104
</div>
_104
<nav>
_104
{contacts.length ? (
_104
<ul>
_104
{contacts.map((contact) => (
_104
<li key={contact.id}>
_104
<NavLink
_104
to={`contacts/${contact.id}`}
_104
className={({ isActive, isPending }) =>
_104
isActive
_104
? 'active'
_104
: isPending
_104
? 'pending'
_104
: ''
_104
}
_104
>
_104
{contact.first || contact.last ? (
_104
<>
_104
{contact.first} {contact.last}
_104
</>
_104
) : (
_104
<i>No Name</i>
_104
)}{' '}
_104
{contact.favorite && <span>β˜…</span>}
_104
</NavLink>
_104
</li>
_104
))}
_104
</ul>
_104
) : (
_104
<p>
_104
<i>No contacts</i>
_104
</p>
_104
)}
_104
</nav>
_104
</div>
_104
<div
_104
id="detail"
_104
className={
_104
navigation.state === 'loading' ? 'loading' : ''
_104
}
_104
>
_104
<Outlet />
_104
</div>
_104
</>
_104
)
_104
}

πŸ€” Shouldn't you use a controlled component and React State for this?

You could certainly do this as a controlled component, but you'll end up with more complexity for the same behavior. You don't control the URL, the user does with the back/forward buttons. There would be more synchronization points with a controlled component.

If you're still concerned, expand this to see what it would look like

Notice how controlling the input requires three points of synchronization now instead of just one. The behavior is identical but the code is more complex.

src/routes/root.jsx

import { useEffect, useState } from 'react'
// existing code
export async function loader({ request }) {
const url = new URL(request.url)
const q = url.searchParams.get('q') || ''
const contacts = await getContacts(q)
return { contacts, q }
}
// existing code
export default function Root() {
const { contacts, q } = useLoaderData()
const [query, setQuery] = useState(q)
const navigation = useNavigation()
useEffect(() => {
setQuery(q)
}, [q])
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
value={query}
onChange={(e) => {
setQuery(e.target.value)
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
</>
)
}

Submitting Forms onChange

We've got a product decision to make here. For this UI, we'd probably rather have the filtering happen on every key stroke instead of when the form is explicitly submitted.

We've seen useNavigate already, we'll use its cousin, useSubmit, for this.

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_109
import {
_109
Outlet,
_109
NavLink,
_109
useLoaderData,
_109
Form,
_109
redirect,
_109
useNavigation,
_109
useSubmit,
_109
} from 'react-router-dom'
_109
import { getContacts, createContact } from '../contacts'
_109
import { useEffect } from 'react'
_109
_109
export async function action() {
_109
const contact = await createContact()
_109
return redirect(`/contacts/${contact.id}/edit`)
_109
}
_109
_109
export async function loader({ request }) {
_109
const url = new URL(request.url)
_109
const q = url.searchParams.get('q')
_109
const contacts = await getContacts(q)
_109
return { contacts, q }
_109
}
_109
_109
export default function Root() {
_109
const { contacts, q } = useLoaderData()
_109
const navigation = useNavigation()
_109
const submit = useSubmit()
_109
_109
useEffect(() => {
_109
document.getElementById('q').value = q
_109
}, [q])
_109
_109
return (
_109
<>
_109
<div id="sidebar">
_109
<h1>React Router Contacts</h1>
_109
<div>
_109
<Form id="search-form" role="search">
_109
<input
_109
id="q"
_109
aria-label="Search contacts"
_109
placeholder="Search"
_109
type="search"
_109
name="q"
_109
defaultValue={q}
_109
onChange={(event) => {
_109
submit(event.currentTarget.form)
_109
}}
_109
/>
_109
<div
_109
id="search-spinner"
_109
aria-hidden
_109
hidden={true}
_109
/>
_109
<div
_109
className="sr-only"
_109
aria-live="polite"
_109
></div>
_109
</Form>
_109
<Form method="post">
_109
<button type="submit">New</button>
_109
</Form>
_109
</div>
_109
<nav>
_109
{contacts.length ? (
_109
<ul>
_109
{contacts.map((contact) => (
_109
<li key={contact.id}>
_109
<NavLink
_109
to={`contacts/${contact.id}`}
_109
className={({ isActive, isPending }) =>
_109
isActive
_109
? 'active'
_109
: isPending
_109
? 'pending'
_109
: ''
_109
}
_109
>
_109
{contact.first || contact.last ? (
_109
<>
_109
{contact.first} {contact.last}
_109
</>
_109
) : (
_109
<i>No Name</i>
_109
)}{' '}
_109
{contact.favorite && <span>β˜…</span>}
_109
</NavLink>
_109
</li>
_109
))}
_109
</ul>
_109
) : (
_109
<p>
_109
<i>No contacts</i>
_109
</p>
_109
)}
_109
</nav>
_109
</div>
_109
<div
_109
id="detail"
_109
className={
_109
navigation.state === 'loading' ? 'loading' : ''
_109
}
_109
>
_109
<Outlet />
_109
</div>
_109
</>
_109
)
_109
}

Now as you type, the form is submitted automatically!

Note the argument to submit. We're passing in event.currentTarget.form. The currentTarget is the DOM node the event is attached to, and the currentTarget.form is the input's parent form node. The submit function will serialize and submit any form you pass to it.

Adding Search Spinner

In a production app, it's likely this search will be looking for records in a database that is too large to send all at once and filter client side. That's why this demo has some faked network latency.

Without any loading indicator, the search feels kinda sluggish. Even if we could make our database faster, we'll always have the user's network latency in the way and out of our control. For a better UX, let's add some immediate UI feedback for the search. For this we'll use useNavigation again.

πŸ‘‰ Add the search spinner

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_114
import {
_114
Outlet,
_114
NavLink,
_114
useLoaderData,
_114
Form,
_114
redirect,
_114
useNavigation,
_114
useSubmit,
_114
} from 'react-router-dom'
_114
import { getContacts, createContact } from '../contacts'
_114
import { useEffect } from 'react'
_114
_114
export async function action() {
_114
const contact = await createContact()
_114
return redirect(`/contacts/${contact.id}/edit`)
_114
}
_114
_114
export async function loader({ request }) {
_114
const url = new URL(request.url)
_114
const q = url.searchParams.get('q')
_114
const contacts = await getContacts(q)
_114
return { contacts, q }
_114
}
_114
_114
export default function Root() {
_114
const { contacts, q } = useLoaderData()
_114
const navigation = useNavigation()
_114
const submit = useSubmit()
_114
_114
const searching =
_114
navigation.location &&
_114
new URLSearchParams(navigation.location.search).has('q')
_114
_114
useEffect(() => {
_114
document.getElementById('q').value = q
_114
}, [q])
_114
_114
return (
_114
<>
_114
<div id="sidebar">
_114
<h1>React Router Contacts</h1>
_114
<div>
_114
<Form id="search-form" role="search">
_114
<input
_114
id="q"
_114
className={searching ? 'loading' : ''}
_114
aria-label="Search contacts"
_114
placeholder="Search"
_114
type="search"
_114
name="q"
_114
defaultValue={q}
_114
onChange={(event) => {
_114
submit(event.currentTarget.form)
_114
}}
_114
/>
_114
<div
_114
id="search-spinner"
_114
aria-hidden
_114
hidden={!searching}
_114
/>
_114
<div
_114
className="sr-only"
_114
aria-live="polite"
_114
></div>
_114
</Form>
_114
<Form method="post">
_114
<button type="submit">New</button>
_114
</Form>
_114
</div>
_114
<nav>
_114
{contacts.length ? (
_114
<ul>
_114
{contacts.map((contact) => (
_114
<li key={contact.id}>
_114
<NavLink
_114
to={`contacts/${contact.id}`}
_114
className={({ isActive, isPending }) =>
_114
isActive
_114
? 'active'
_114
: isPending
_114
? 'pending'
_114
: ''
_114
}
_114
>
_114
{contact.first || contact.last ? (
_114
<>
_114
{contact.first} {contact.last}
_114
</>
_114
) : (
_114
<i>No Name</i>
_114
)}{' '}
_114
{contact.favorite && <span>β˜…</span>}
_114
</NavLink>
_114
</li>
_114
))}
_114
</ul>
_114
) : (
_114
<p>
_114
<i>No contacts</i>
_114
</p>
_114
)}
_114
</nav>
_114
</div>
_114
<div
_114
id="detail"
_114
className={
_114
navigation.state === 'loading' ? 'loading' : ''
_114
}
_114
>
_114
<Outlet />
_114
</div>
_114
</>
_114
)
_114
}

The navigation.location will show up when the app is navigating to a new URL and loading the data for it. It then goes away when there is no pending navigation anymore.

Managing the History Stack

Now that the form is submitted for every key stroke, if we type the characters "seba" and then delete them with backspace, we end up with 7 new entries in the stack πŸ˜‚. We definitely don't want this

We can avoid this by replacing the current entry in the history stack with the next page, instead of pushing into it.

πŸ‘‰ Use replace in submit

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_117
import {
_117
Outlet,
_117
NavLink,
_117
useLoaderData,
_117
Form,
_117
redirect,
_117
useNavigation,
_117
useSubmit,
_117
} from 'react-router-dom'
_117
import { getContacts, createContact } from '../contacts'
_117
import { useEffect } from 'react'
_117
_117
export async function action() {
_117
const contact = await createContact()
_117
return redirect(`/contacts/${contact.id}/edit`)
_117
}
_117
_117
export async function loader({ request }) {
_117
const url = new URL(request.url)
_117
const q = url.searchParams.get('q')
_117
const contacts = await getContacts(q)
_117
return { contacts, q }
_117
}
_117
_117
export default function Root() {
_117
const { contacts, q } = useLoaderData()
_117
const navigation = useNavigation()
_117
const submit = useSubmit()
_117
_117
const searching =
_117
navigation.location &&
_117
new URLSearchParams(navigation.location.search).has('q')
_117
_117
useEffect(() => {
_117
document.getElementById('q').value = q
_117
}, [q])
_117
_117
return (
_117
<>
_117
<div id="sidebar">
_117
<h1>React Router Contacts</h1>
_117
<div>
_117
<Form id="search-form" role="search">
_117
<input
_117
id="q"
_117
className={searching ? 'loading' : ''}
_117
aria-label="Search contacts"
_117
placeholder="Search"
_117
type="search"
_117
name="q"
_117
defaultValue={q}
_117
onChange={(event) => {
_117
const isFirstSearch = q == null
_117
submit(event.currentTarget.form, {
_117
replace: !isFirstSearch,
_117
})
_117
}}
_117
/>
_117
<div
_117
id="search-spinner"
_117
aria-hidden
_117
hidden={!searching}
_117
/>
_117
<div
_117
className="sr-only"
_117
aria-live="polite"
_117
></div>
_117
</Form>
_117
<Form method="post">
_117
<button type="submit">New</button>
_117
</Form>
_117
</div>
_117
<nav>
_117
{contacts.length ? (
_117
<ul>
_117
{contacts.map((contact) => (
_117
<li key={contact.id}>
_117
<NavLink
_117
to={`contacts/${contact.id}`}
_117
className={({ isActive, isPending }) =>
_117
isActive
_117
? 'active'
_117
: isPending
_117
? 'pending'
_117
: ''
_117
}
_117
>
_117
{contact.first || contact.last ? (
_117
<>
_117
{contact.first} {contact.last}
_117
</>
_117
) : (
_117
<i>No Name</i>
_117
)}{' '}
_117
{contact.favorite && <span>β˜…</span>}
_117
</NavLink>
_117
</li>
_117
))}
_117
</ul>
_117
) : (
_117
<p>
_117
<i>No contacts</i>
_117
</p>
_117
)}
_117
</nav>
_117
</div>
_117
<div
_117
id="detail"
_117
className={
_117
navigation.state === 'loading' ? 'loading' : ''
_117
}
_117
>
_117
<Outlet />
_117
</div>
_117
</>
_117
)
_117
}

We only want to replace search results, not the page before we started searching, so we do a quick check if this is the first search or not and then decide to replace.

Each key stroke no longer creates new entries, so the user can click back out of the search results without having to click it 7 times πŸ˜….

Mutations Without Navigation

So far all of our mutations (the times we change data) have used forms that navigate, creating new entries in the history stack. While these user flows are common, it's equally as common to want to change data without causing a navigation.

For these cases, we have the useFetcher hook. It allows us to communicate with loaders and actions without causing a navigation.

The β˜… button on the contact page makes sense for this. We aren't creating or deleting a new record, we don't want to change pages, we simply want to change the data on the page we're looking at.

πŸ‘‰ Change the <Favorite> form to a fetcher form

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_94
import {
_94
Form,
_94
useLoaderData,
_94
useFetcher,
_94
} from 'react-router-dom'
_94
import { getContact } from '../contacts'
_94
_94
export async function loader({ params }) {
_94
const contact = await getContact(params.contactId)
_94
return { contact }
_94
}
_94
_94
export default function Contact() {
_94
const { contact } = useLoaderData()
_94
_94
return (
_94
<div id="contact">
_94
<div>
_94
<img
_94
key={contact.avatar}
_94
src={contact.avatar || null}
_94
/>
_94
</div>
_94
_94
<div>
_94
<h1>
_94
{contact.first || contact.last ? (
_94
<>
_94
{contact.first} {contact.last}
_94
</>
_94
) : (
_94
<i>No Name</i>
_94
)}{' '}
_94
<Favorite contact={contact} />
_94
</h1>
_94
_94
{contact.twitter && (
_94
<p>
_94
<a
_94
target="_blank"
_94
href={`https://twitter.com/${contact.twitter}`}
_94
>
_94
{contact.twitter}
_94
</a>
_94
</p>
_94
)}
_94
_94
{contact.notes && <p>{contact.notes}</p>}
_94
_94
<div>
_94
<Form action="edit">
_94
<button type="submit">Edit</button>
_94
</Form>
_94
<Form
_94
method="post"
_94
action="destroy"
_94
onSubmit={(event) => {
_94
if (
_94
!confirm(
_94
'Please confirm you want to delete this record.'
_94
)
_94
) {
_94
event.preventDefault()
_94
}
_94
}}
_94
>
_94
<button type="submit">Delete</button>
_94
</Form>
_94
</div>
_94
</div>
_94
</div>
_94
)
_94
}
_94
_94
function Favorite({ contact }) {
_94
const fetcher = useFetcher()
_94
// yes, this is a `let` for later
_94
let favorite = contact.favorite
_94
return (
_94
<fetcher.Form method="post">
_94
<button
_94
name="favorite"
_94
value={favorite ? 'false' : 'true'}
_94
aria-label={
_94
favorite
_94
? 'Remove from favorites'
_94
: 'Add to favorites'
_94
}
_94
>
_94
{favorite ? 'β˜…' : 'β˜†'}
_94
</button>
_94
</fetcher.Form>
_94
)
_94
}

Might want to take a look at that form while we're here. As always, our form has fields with a name prop. This form will send formData with a favorite key that's either "true" | "false". Since it's got method="post" it will call the action. Since there is no <fetcher.Form action="..."> prop, it will post to the route where the form is rendered.

πŸ‘‰ Create the action

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_101
import {
_101
Form,
_101
useLoaderData,
_101
useFetcher,
_101
} from 'react-router-dom'
_101
import { getContact, updateContact } from '../contacts'
_101
_101
export async function loader({ params }) {
_101
const contact = await getContact(params.contactId)
_101
return { contact }
_101
}
_101
_101
export async function action({ request, params }) {
_101
let formData = await request.formData()
_101
return updateContact(params.contactId, {
_101
favorite: formData.get('favorite') === 'true',
_101
})
_101
}
_101
_101
export default function Contact() {
_101
const { contact } = useLoaderData()
_101
_101
return (
_101
<div id="contact">
_101
<div>
_101
<img
_101
key={contact.avatar}
_101
src={contact.avatar || null}
_101
/>
_101
</div>
_101
_101
<div>
_101
<h1>
_101
{contact.first || contact.last ? (
_101
<>
_101
{contact.first} {contact.last}
_101
</>
_101
) : (
_101
<i>No Name</i>
_101
)}{' '}
_101
<Favorite contact={contact} />
_101
</h1>
_101
_101
{contact.twitter && (
_101
<p>
_101
<a
_101
target="_blank"
_101
href={`https://twitter.com/${contact.twitter}`}
_101
>
_101
{contact.twitter}
_101
</a>
_101
</p>
_101
)}
_101
_101
{contact.notes && <p>{contact.notes}</p>}
_101
_101
<div>
_101
<Form action="edit">
_101
<button type="submit">Edit</button>
_101
</Form>
_101
<Form
_101
method="post"
_101
action="destroy"
_101
onSubmit={(event) => {
_101
if (
_101
!confirm(
_101
'Please confirm you want to delete this record.'
_101
)
_101
) {
_101
event.preventDefault()
_101
}
_101
}}
_101
>
_101
<button type="submit">Delete</button>
_101
</Form>
_101
</div>
_101
</div>
_101
</div>
_101
)
_101
}
_101
_101
function Favorite({ contact }) {
_101
const fetcher = useFetcher()
_101
// yes, this is a `let` for later
_101
let favorite = contact.favorite
_101
return (
_101
<fetcher.Form method="post">
_101
<button
_101
name="favorite"
_101
value={favorite ? 'false' : 'true'}
_101
aria-label={
_101
favorite
_101
? 'Remove from favorites'
_101
: 'Add to favorites'
_101
}
_101
>
_101
{favorite ? 'β˜…' : 'β˜†'}
_101
</button>
_101
</fetcher.Form>
_101
)
_101
}

Pretty simple. Pull the form data off the request and send it to the data model.

πŸ‘‰ Configure the route's new action

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_57
import * as React from 'react'
_57
import * as ReactDOM from 'react-dom/client'
_57
import {
_57
createBrowserRouter,
_57
RouterProvider,
_57
} from 'react-router-dom'
_57
import './index.css'
_57
import Root, {
_57
loader as rootLoader,
_57
action as rootAction,
_57
} from './routes/root'
_57
import ErrorPage from './error-page'
_57
import Contact, {
_57
loader as contactLoader,
_57
action as contactAction,
_57
} from './routes/contact'
_57
import EditContact, {
_57
action as editAction,
_57
} from './routes/edit'
_57
import { action as destroyAction } from './routes/destroy'
_57
import Index from './routes/index'
_57
_57
const router = createBrowserRouter([
_57
{
_57
path: '/',
_57
element: <Root />,
_57
errorElement: <ErrorPage />,
_57
loader: rootLoader,
_57
action: rootAction,
_57
children: [
_57
{ index: true, element: <Index /> },
_57
{
_57
path: 'contacts/:contactId',
_57
element: <Contact />,
_57
loader: contactLoader,
_57
action: contactAction,
_57
},
_57
{
_57
path: 'contacts/:contactId/edit',
_57
element: <EditContact />,
_57
loader: contactLoader,
_57
action: editAction,
_57
},
_57
{
_57
path: 'contacts/:contactId/destroy',
_57
action: destroyAction,
_57
errorElement: <div>Oops! There was an error.</div>,
_57
},
_57
],
_57
},
_57
])
_57
_57
ReactDOM.createRoot(document.getElementById('root')).render(
_57
<React.StrictMode>
_57
<RouterProvider router={router} />
_57
</React.StrictMode>
_57
)

Alright, we're ready to click the star next to the user's name!

Check that out, both stars automatically update. Our new <fetcher.Form method="post"> works almost exactly like the <Form> we've been using: it calls the action and then all data is revalidated automatically--even your errors will be caught the same way.

There is one key difference though, it's not a navigation--the URL doesn't change, the history stack is unaffected.

Optimistic UI

You probably noticed the app felt kind of unresponsive when we clicked the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world!

To give the user some feedback, we could put the star into a loading state with fetcher.state (a lot like navigation.state from before), but we can do something even better this time. We can use a strategy called "optimistic UI"

The fetcher knows the form data being submitted to the action, so it's available to you on fetcher.formData. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data.

πŸ‘‰ Read the optimistic value from fetcher.formData

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_105
import {
_105
Form,
_105
useLoaderData,
_105
useFetcher,
_105
} from 'react-router-dom'
_105
import { getContact, updateContact } from '../contacts'
_105
_105
export async function loader({ params }) {
_105
const contact = await getContact(params.contactId)
_105
return { contact }
_105
}
_105
_105
export async function action({ request, params }) {
_105
let formData = await request.formData()
_105
return updateContact(params.contactId, {
_105
favorite: formData.get('favorite') === 'true',
_105
})
_105
}
_105
_105
export default function Contact() {
_105
const { contact } = useLoaderData()
_105
_105
return (
_105
<div id="contact">
_105
<div>
_105
<img
_105
key={contact.avatar}
_105
src={contact.avatar || null}
_105
/>
_105
</div>
_105
_105
<div>
_105
<h1>
_105
{contact.first || contact.last ? (
_105
<>
_105
{contact.first} {contact.last}
_105
</>
_105
) : (
_105
<i>No Name</i>
_105
)}{' '}
_105
<Favorite contact={contact} />
_105
</h1>
_105
_105
{contact.twitter && (
_105
<p>
_105
<a
_105
target="_blank"
_105
href={`https://twitter.com/${contact.twitter}`}
_105
>
_105
{contact.twitter}
_105
</a>
_105
</p>
_105
)}
_105
_105
{contact.notes && <p>{contact.notes}</p>}
_105
_105
<div>
_105
<Form action="edit">
_105
<button type="submit">Edit</button>
_105
</Form>
_105
<Form
_105
method="post"
_105
action="destroy"
_105
onSubmit={(event) => {
_105
if (
_105
!confirm(
_105
'Please confirm you want to delete this record.'
_105
)
_105
) {
_105
event.preventDefault()
_105
}
_105
}}
_105
>
_105
<button type="submit">Delete</button>
_105
</Form>
_105
</div>
_105
</div>
_105
</div>
_105
)
_105
}
_105
_105
function Favorite({ contact }) {
_105
const fetcher = useFetcher()
_105
_105
let favorite = contact.favorite
_105
if (fetcher.formData) {
_105
favorite = fetcher.formData.get('favorite') === 'true'
_105
}
_105
_105
return (
_105
<fetcher.Form method="post">
_105
<button
_105
name="favorite"
_105
value={favorite ? 'false' : 'true'}
_105
aria-label={
_105
favorite
_105
? 'Remove from favorites'
_105
: 'Add to favorites'
_105
}
_105
>
_105
{favorite ? 'β˜…' : 'β˜†'}
_105
</button>
_105
</fetcher.Form>
_105
)
_105
}

If you click the button now you should see the star immediately change to the new state. Instead of always rendering the actual data, we check if the fetcher has any formData being submitted, if so, we'll use that instead. When the action is done, the fetcher.formData will no longer exist and we're back to using the actual data. So even if you write bugs in your optimistic UI code, it'll eventually go back to the correct state πŸ₯Ή

Not Found Data

What happens if the contact we're trying to load doesn't exist?

Our root errorElement is catching this unexpected error as we try to render a null contact. Nice the error was properly handled, but we can do better!

Whenever you have an expected error case in a loader or action–like the data not existing–you can throw. The call stack will break, React Router will catch it, and the error path is rendered instead. We won't even try to render a null contact.

πŸ‘‰ Throw a 404 response in the loader

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_111
import {
_111
Form,
_111
useLoaderData,
_111
useFetcher,
_111
} from 'react-router-dom'
_111
import { getContact, updateContact } from '../contacts'
_111
_111
export async function loader({ params }) {
_111
const contact = await getContact(params.contactId)
_111
if (!contact) {
_111
throw new Response('', {
_111
status: 404,
_111
statusText: 'Not Found',
_111
})
_111
}
_111
return { contact }
_111
}
_111
_111
export async function action({ request, params }) {
_111
let formData = await request.formData()
_111
return updateContact(params.contactId, {
_111
favorite: formData.get('favorite') === 'true',
_111
})
_111
}
_111
_111
export default function Contact() {
_111
const { contact } = useLoaderData()
_111
_111
return (
_111
<div id="contact">
_111
<div>
_111
<img
_111
key={contact.avatar}
_111
src={contact.avatar || null}
_111
/>
_111
</div>
_111
_111
<div>
_111
<h1>
_111
{contact.first || contact.last ? (
_111
<>
_111
{contact.first} {contact.last}
_111
</>
_111
) : (
_111
<i>No Name</i>
_111
)}{' '}
_111
<Favorite contact={contact} />
_111
</h1>
_111
_111
{contact.twitter && (
_111
<p>
_111
<a
_111
target="_blank"
_111
href={`https://twitter.com/${contact.twitter}`}
_111
>
_111
{contact.twitter}
_111
</a>
_111
</p>
_111
)}
_111
_111
{contact.notes && <p>{contact.notes}</p>}
_111
_111
<div>
_111
<Form action="edit">
_111
<button type="submit">Edit</button>
_111
</Form>
_111
<Form
_111
method="post"
_111
action="destroy"
_111
onSubmit={(event) => {
_111
if (
_111
!confirm(
_111
'Please confirm you want to delete this record.'
_111
)
_111
) {
_111
event.preventDefault()
_111
}
_111
}}
_111
>
_111
<button type="submit">Delete</button>
_111
</Form>
_111
</div>
_111
</div>
_111
</div>
_111
)
_111
}
_111
_111
function Favorite({ contact }) {
_111
const fetcher = useFetcher()
_111
_111
let favorite = contact.favorite
_111
if (fetcher.formData) {
_111
favorite = fetcher.formData.get('favorite') === 'true'
_111
}
_111
_111
return (
_111
<fetcher.Form method="post">
_111
<button
_111
name="favorite"
_111
value={favorite ? 'false' : 'true'}
_111
aria-label={
_111
favorite
_111
? 'Remove from favorites'
_111
: 'Add to favorites'
_111
}
_111
>
_111
{favorite ? 'β˜…' : 'β˜†'}
_111
</button>
_111
</fetcher.Form>
_111
)
_111
}

Instead of hitting a render error with Cannot read properties of null, we avoid the component completely and render the error path instead, telling the user something more specific.

This keeps your happy paths, happy. Your route elements don't need to concern themselves with error and loading states.

Pathless Routes

One last thing. The last error page we saw would be better if it rendered inside the root outlet, instead of the whole page. In fact, every error in all of our child routes would be better in the outlet, then the user has more options than hitting refresh.

We'd like it to look like this:

We could add the error element to every one of the child routes but, since it's all the same error page, this isn't recommended.

There's a cleaner way. Routes can be used without a path, which lets them participate in the UI layout without requiring new path segments in the URL. Check it out:

πŸ‘‰ Wrap the child routes in a pathless route

src/index.css
src/contacts.js
src/main.jsx
src/routes/root.jsx
src/error-page.jsx
src/routes/contact.jsx
src/routes/edit.jsx
src/routes/destroy.jsx
src/routes/index.jsx

_64
import * as React from 'react'
_64
import * as ReactDOM from 'react-dom/client'
_64
import {
_64
createBrowserRouter,
_64
RouterProvider,
_64
} from 'react-router-dom'
_64
import './index.css'
_64
import Root, {
_64
loader as rootLoader,
_64
action as rootAction,
_64
} from './routes/root'
_64
import ErrorPage from './error-page'
_64
import Contact, {
_64
loader as contactLoader,
_64
action as contactAction,
_64
} from './routes/contact'
_64
import EditContact, {
_64
action as editAction,
_64
} from './routes/edit'
_64
import { action as destroyAction } from './routes/destroy'
_64
import Index from './routes/index'
_64
_64
const router = createBrowserRouter([
_64
{
_64
path: '/',
_64
element: <Root />,
_64
errorElement: <ErrorPage />,
_64
loader: rootLoader,
_64
action: rootAction,
_64
children: [
_64
{
_64
errorElement: <ErrorPage />,
_64
children: [
_64
{ index: true, element: <Index /> },
_64
{
_64
path: 'contacts/:contactId',
_64
element: <Contact />,
_64
loader: contactLoader,
_64
action: contactAction,
_64
},
_64
{
_64
path: 'contacts/:contactId/edit',
_64
element: <EditContact />,
_64
loader: contactLoader,
_64
action: editAction,
_64
},
_64
{
_64
path: 'contacts/:contactId/destroy',
_64
action: destroyAction,
_64
errorElement: (
_64
<div>Oops! There was an error.</div>
_64
),
_64
},
_64
],
_64
},
_64
],
_64
},
_64
])
_64
_64
ReactDOM.createRoot(document.getElementById('root')).render(
_64
<React.StrictMode>
_64
<RouterProvider router={router} />
_64
</React.StrictMode>
_64
)

When any errors are thrown in the child routes, our new pathless route will catch it and render, preserving the root route's UI!

JSX Routes

And for our final trick, many folks prefer to configure their routes with JSX. You can do that with createRoutesFromElements. There is no functional difference between JSX or objects when configuring your routes, it's simply a stylistic preference.


import {
createRoutesFromElements,
createBrowserRouter,
Route,
} from 'react-router-dom'
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
element={<Root />}
loader={rootLoader}
action={rootAction}
errorElement={<ErrorPage />}
>
<Route errorElement={<ErrorPage />}>
<Route index element={<Index />} />
<Route
path="contacts/:contactId"
element={<Contact />}
loader={contactLoader}
action={contactAction}
/>
<Route
path="contacts/:contactId/edit"
element={<EditContact />}
loader={contactLoader}
action={editAction}
/>
<Route
path="contacts/:contactId/destroy"
action={destroyAction}
/>
</Route>
</Route>
)
)

That's it! Thanks for giving React Router a shot. We hope this tutorial gives you a solid start to build great user experiences. There's a lot more you can do with React Router, so make sure to check out all the APIs πŸ˜€

Setup

If you're not going to follow along in your own app, you can skip this section

We'll be using Vite for our bundler and dev server for this tutorial. You'll need Node.js installed for the npm command line tool.

πŸ‘‰οΈ Open up your terminal and bootstrap a new React app with Vite

You should be able to visit the URL printed in the terminal:


VITE v3.0.7 ready in 175 ms
➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose

We've got some pre-written CSS for this tutorial so we can stay focused on React Router. Feel free to judge it harshly or write your own πŸ˜… (We did things we normally wouldn't in CSS so that the markup in this tutorial could stay as minimal as possible.)

πŸ‘‰ Copy/Paste the tutorial CSS into src/index.css

This tutorial will be creating, reading, searching, updating, and deleting data. A typical web app would probably be talking to an API on your web server, but we're going to use browser storage and fake some network latency to keep this focused. None of this code is relevant to React Router, so just go ahead and copy/paste it all.

πŸ‘‰ Copy/Paste the tutorial data module into src/contacts.js

All you need in the src folder are contacts.js, main.jsx, and index.css. You can delete anything else (like App.js and assets, etc.).

πŸ‘‰ Delete unused files in src/ so all you have left are these:


src
β”œβ”€β”€ contacts.js
β”œβ”€β”€ index.css
└── main.jsx

If your app is running, it might blow up momentarily, just keep going πŸ˜‹. And with that, we're ready to get started!

Adding a Router

First thing to do is create a Browser Router and configure our first route. This will enable client side routing for our web app.

The main.jsx file is the entry point. Open it up and we'll put React Router on the page.

πŸ‘‰ Create and render a browser router in main.jsx

This first route is what we often call the "root route" since the rest of our routes will render inside of it. It will serve as the root layout of the UI, we'll have nested layouts as we get farther along.

The Root Route

Let's add the global layout for this app.

πŸ‘‰ Create src/routes/root.jsx and create the root layout component

Nothing React Router specific yet, so feel free to copy/paste all of that.

πŸ‘‰ Set <Root> as the root route's element

The app should look something like this now. It sure is nice having a designer who can also write the CSS, isn't it? (Thank you Jim πŸ™).

Handling Not Found Errors

It's always a good idea to know how your app responds to errors early in the project because we all write far more bugs than features when building a new app! Not only will your users get a good experience when this happens, but it helps you during development as well.

We added some links to this app, let's see what happens when we click them?

πŸ‘‰ Click one of the sidebar names

screenshot of default React Router error element

Gross! This is the default error screen in React Router, made worse by our flex box styles on the root element in this app πŸ˜‚.

Anytime your app throws an error while rendering, loading data, or performing data mutations, React Router will catch it and render an error screen. Let's make our own error page.

πŸ‘‰ Create an error page component

πŸ‘‰ Set the <ErrorPage> as the errorElement on the root route

The error page should now look like this:

new error page, but still ugly

(Well, that's not much better. Maybe somebody forgot to ask the designer to make an error page. Maybe everybody forgets to ask the designer to make an error page and then blames the designer for not thinking of it πŸ˜†)

Note that useRouteError provides the error that was thrown. When the user navigates to routes that don't exist you'll get an error response with a "Not Found" statusText. We'll see some other errors later in the tutorial and discuss them more.

For now, it's enough to know that pretty much all of your errors will now be handled by this page instead of infinite spinners, unresponsive pages, or blank screens πŸ™Œ

The Contact Route UI

Instead of a 404 "Not Found" page, we want to actually render something at the URLs we've linked to. For that, we need to make a new route.

πŸ‘‰ **Create the contact route module src/routes/contact.jsx and add the contact component UI **

It's just a bunch of elements, feel free to copy/paste.

Now that we've got a component, let's hook it up to a new route.

πŸ‘‰ Import the contact component and create a new route

Now if we click one of the links or visit /contacts/1 we get our new component!

contact route rendering without the parent layout

However, it's not inside of our root layout 😠

Nested Routes

We want the contact component to render inside of the <Root> layout like this.

We do it by making the contact route a child of the root route.

πŸ‘‰ Move the contacts route to be a child of the root route

You'll now see the root layout again but a blank page on the right. We need to tell the root route where we want it to render its child routes. We do that with <Outlet>.

Find the <div id="detail"> and put an outlet inside

πŸ‘‰ Render an <Outlet>

Client Side Routing

You may or may not have noticed, but when we click the links in the sidebar, the browser is doing a full document request for the next URL instead of using React Router.

Client side routing allows our app to update the URL without requesting another document from the server. Instead, the app can immediately render new UI. Let's make it happen with <Link>.

πŸ‘‰ Change the sidebar <a href> to <Link to>

You can open the network tab in the browser devtools to see that it's not requesting documents anymore.

Loading Data

URL segments, layouts, and data are more often than not coupled (tripled?) together. We can see it in this app already:

URL SegmentComponentData
/<Root>list of contacts
contacts/:id<Contact>individual contact

Because of this natural coupling, React Router has data conventions to get data into your route components easily.

There are two APIs we'll be using to load data, loader and useLoaderData. First we'll create and export a loader function in the root module, then we'll hook it up to the route. Finally, we'll access and render the data.

πŸ‘‰ Export a loader from root.jsx

πŸ‘‰ Configure the loader on the route

πŸ‘‰ Access and render the data

That's it! React Router will now automatically keep that data in sync with your UI. We don't have any data yet, so you're probably getting a blank list like this:

Data Writes + HTML Forms

We'll create our first contact in a second, but first let's talk about HTML.

React Router emulates HTML Form navigation as the data mutation primitive, according to web development before the JavaScript cambrian explosion. It gives you the UX capabilities of client rendered apps with the simplicity of the "old school" web model.

While unfamiliar to some web developers, HTML forms actually cause a navigation in the browser, just like clicking a link. The only difference is in the request: links can only change the URL while forms can also change the request method (GET vs POST) and the request body (POST form data).

Without client side routing, the browser will serialize the form's data automatically and send it to the server as the request body for POST, and as URLSearchParams for GET. React Router does the same thing, except instead of sending the request to the server, it uses client side routing and sends it to a route action.

We can test this out by clicking the "New" button in our app. The app should blow up because the Vite server isn't configured to handle a POST request (it sends a 404, though it should probably be a 405 🀷).

Instead of sending that POST to the Vite server to create a new contact, let's use client side routing instead.

Creating Contacts

We'll create new contacts by exporting an action in our root route, wiring it up to the route config, and changing our <form> to a React Router <Form>.

πŸ‘‰ Create the action and change <form> to <Form>

πŸ‘‰ Import and set the action on the route

That's it! Go ahead and click the "New" button and you should see a new record pop into the list πŸ₯³

The createContact method just creates an empty contact with no name or data or anything. But it does still create a record, promise!

🧐 Wait a sec ... How did the sidebar update? Where did we call the action? Where's the code to refetch the data? Where are useState, onSubmit and useEffect?!

This is where the "old school web" programming model shows up. As we discussed earlier, <Form> prevents the browser from sending the request to the server and sends it to your route action instead. In web semantics, a POST usually means some data is changing. By convention, React Router uses this as a hint to automatically revalidate the data on the page after the action finishes. That means all of your useLoaderData hooks update and the UI stays in sync with your data automatically! Pretty cool.

URL Params in Loaders

πŸ‘‰ Click on the No Name record

We should be seeing our old static contact page again, with one difference: the URL now has a real ID for the record.

Reviewing the route config, the route looks like this:


;[
{
path: 'contacts/:contactId',
element: <Contact />,
},
]

Note the :contactId URL segment. The colon (:) has special meaning, turning it into a "dynamic segment". Dynamic segments will match dynamic (changing) values in that position of the URL, like the contact ID. We call these values in the URL "URL Params", or just "params" for short.

These params are passed to the loader with keys that match the dynamic segment. For example, our segment is named :contactId so the value will be passed as params.contactId.

These params are most often used to find a record by ID. Let's try it out.

πŸ‘‰ Add a loader to the contact page and access data with useLoaderData

πŸ‘‰ Configure the loader on the route

Updating Data

Just like creating data, you update data with <Form>. Let's make a new route at contacts/:contactId/edit. Again, we'll start with the component and then wire it up to the route config.

πŸ‘‰ Create the edit component


touch src/routes/edit.jsx

πŸ‘‰ Add the edit page UI

Nothing we haven't seen before, feel free to copy/paste:

πŸ‘‰ Add the new edit route

We want it to be rendered in the root route's outlet, so we made it a sibling to the existing child route.

(You might note we reused the contactLoader for this route. This is only because we're being lazy in the tutorial. There is no reason to attempt to share loaders among routes, they usually have their own.)

Alright, clicking the "Edit" button gives us this new UI:

Updating Contacts with FormData

The edit route we just created already renders a form. All we need to do to update the record is wire up an action to the route. The form will post to the action and the data will be automatically revalidated.

πŸ‘‰ Add an action to the edit module

πŸ‘‰ Wire the action up to the route

Fill out the form, hit save, and you should see something like this! (Except easier on the eyes and maybe less hairy.)

Mutation Discussion

πŸ˜‘ It worked, but I have no idea what is going on here...

Let's dig in a bit...

Open up src/routes/edit.jsx and look at the form elements. Notice how they each have a name:

src/routes/edit.jsx

<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>

Without JavaScript, when a form is submitted, the browser will create FormData and set it as the body of the request when it sends it to the server. As mentioned before, React Router prevents that and sends the request to your action instead, including the FormData.

Each field in the form is accessible with formData.get(name). For example, given the input field from above, you could access the first and last names like this:


export async function action({ request, params }) {
const formData = await request.formData()
const firstName = formData.get('first')
const lastName = formData.get('last')
// ...
}

Since we have a handful of form fields, we used Object.fromEntries to collect them all into an object, which is exactly what our updateContact function wants.


const updates = Object.fromEntries(formData)
updates.first // "Some"
updates.last // "Name"

Aside from action, none of these APIs we're discussing are provided by React Router: request, request.formData, Object.fromEntries are all provided by the web platform.

After we finished the action, note the redirect at the end:

src/routes/edit.jsx

export async function action({ request, params }) {
const formData = await request.formData()
const updates = Object.fromEntries(formData)
await updateContact(params.contactId, updates)
return redirect(`/contacts/${params.contactId}`)
}

Loaders and actions can both return a Response (makes sense, since they received a Request!). The redirect helper just makes it easier to return a response that tells the app to change locations.

Without client side routing, if a server redirected after a POST request, the new page would fetch the latest data and render. As we learned before, React Router emulates this model and automatically revalidates the data on the page after the action. That's why the sidebar automatically updates when we save the form. The extra revalidation code doesn't exist without client side routing, so it doesn't need to exist with client side routing either!

Redirecting new records to the edit page

Now that we know how to redirect, let's update the action that creates new contacts to redirect to the edit page:

πŸ‘‰ Redirect to the new record's edit page

Now when we click "New", we should end up on the edit page:

πŸ‘‰ Add a handful of records

I'm going to use the stellar lineup of speakers from the first Remix Conference 😁

Active Link Styling

Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use NavLink to fix this.

πŸ‘‰ Use a NavLink in the sidebar

Note that we are passing a function to className. When the user is at the URL in the NavLink, then isActive will be true. When it's about to be active (the data is still loading) then isPending will be true. This allows us to easily indicate where the user is, as well as provide immediate feedback on links that have been clicked but we're still waiting for data to load.

Global Pending UI

As the user navigates the app, React Router will leave the old page up as data is loading for the next page. You may have noticed the app feels a little unresponsive as you click between the list. Let's provide the user with some feedback so the app doesn't feel unresponsive.

React Router is managing all of the state behind the scenes and reveals the pieces of it you need to build dynamic web apps. In this case, we'll use the useNavigation hook.

πŸ‘‰ useNavigation to add global pending UI

useNavigation returns the current navigation state: it can be one of "idle" | "submitting" | "loading".

In our case, we add a "loading" class to the main part of the app if we're not idle. The CSS then adds a nice fade after a short delay (to avoid flickering the UI for fast loads). You could do anything you want though, like show a spinner or loading bar across the top.

Note that our data model (src/contacts.js) has a clientside cache, so navigating to the same contact is fast the second time. This behavior is not React Router, it will re-load data for changing routes no matter if you've been there before or not. It does, however, avoid calling the loaders for unchanging routes (like the list) during a navigation.

Deleting Records

If we review code in the contact route, we can find the delete button looks like this

Note the action points to "destroy". Like <Link to>, <Form action> can take a relative value. Since the form is rendered in contact/:contactId, then a relative action with destroy will submit the form to contact/:contactId/destroy when clicked.

At this point you should know everything you need to know to make the delete button work. Maybe give it a shot before moving on? You'll need:

  1. A new route
  2. An action at that route
  3. deleteContact from src/contacts.js

πŸ‘‰ Create the "destroy" route module


touch src/routes/destroy.jsx

πŸ‘‰ Add the destroy action

πŸ‘‰ Add the destroy route to the route config

Alright, navigate to a record and click the "Delete" button. It works!

πŸ˜… I'm still confused why this all works

When the user clicks the submit button:

  1. <Form> prevents the default browser behavior of sending a new POST request to the server, but instead emulates the browser by creating a POST request with client side routing
  2. The <Form action="destroy"> matches the new route at "contacts/:contactId/destroy" and sends it the request
  3. After the action redirects, React Router calls all of the loaders for the data on the page to get the latest values (this is "revalidation"). useLoaderData returns new values and causes the components to update!

Add a form, add an action, React Router does the rest.

Contextual Errors

πŸ‘‰ Just for kicks, throw an error in the destroy action

Recognize that screen? It's our errorElement from before. The user, however, can't really do anything to recover from this screen except to hit refresh.

πŸ‘‰ Let's create a contextual error message for the destroy route

Now try it again:

Our user now has more options than slamming refresh, they can continue to interact with the parts of the page that aren't having trouble πŸ™Œ

Because the destroy route has its own errorElement and is a child of the root route, the error will render there instead of the root. As you probably noticed, these errors bubble up to the nearest errorElement. Add as many or as few as you like, as long as you've got one at the root.

πŸ‘‰ Remember to remove the error we added for kicks

src/routes/destroy.jsx

import { redirect } from 'react-router-dom'
import { deleteContact } from '../contacts'
export async function action({ params }) {
throw new Error('oh dang!') // remove this line
await deleteContact(params.contactId)
return redirect('/')
}

Index Routes

When we load up the app, you'll notice a big blank page on the right side of our list.

When a route has children, and you're at the parent route's path, the <Outlet> has nothing to render because no children match. You can think of index routes as the default child route to fill in that space.

πŸ‘‰ Create the index route module, and fill in the index component's elements

Feel free to copy paste, nothing special here.

πŸ‘‰ Configure the index route

Note the { index:true } instead of { path: "" }. That tells the router to match and render this route when the user is at the parent route's exact path, so there are no other child routes to render in the <Outlet>.

Voila! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well.

Cancel Button

On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button.

We'll need a click handler on the button as well as useNavigate from React Router.

πŸ‘‰ Add the cancel button click handler with useNavigate

Now when the user clicks "Cancel", they'll be sent back one entry in the browser's history.

🧐 Why is there no event.preventDefault on the button?

A <button type="button">, while seemingly redundant, is the HTML way of preventing a button from submitting its form.

Two more features to go. We're on the home stretch!

URL Search Params and GET Submissions

All of our interactive UI so far have been either links that change the URL or forms that post data to actions. The search field is interesting because it's a mix of both: it's a form but it only changes the URL, it doesn't change data.

Right now it's just a normal HTML <form>, not a React Router <Form>. Let's see what the browser does with it by default:

πŸ‘‰ Type a name into the search field and hit the enter key

Note the browser's URL now contains your query in the URL as URLSearchParams:


http://127.0.0.1:5173/?q=ryan

If we review the search form, it looks like this:

src/routes/root.jsx

<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</form>

As we've seen before, browsers can serialize forms by the name attribute of it's input elements. The name of this input is q, that's why the URL has ?q=. If we named it search the URL would be ?search=.

Note that this form is different from the others we've used, it does not have <form method="post">. The default method is "get". That means when the browser creates the request for the next document, it doesn't put the form data into the request POST body, but into the URLSearchParams of a GET request.

GET Submissions with Client Side Routing

Let's use client side routing to submit this form and filter the list in our existing loader.

πŸ‘‰ Change <form> to <Form>

πŸ‘‰ Filter the list if there are URLSearchParams

Because this is a GET, not a POST, React Router does not call the action. Submitting a GET form is the same as clicking a link: only the URL changes. That's why the code we added for filtering is in the loader, not the action of this route.

This also means it's a normal page navigation. You can click the back button to get back to where you were.

Synchronizing URLs to Form State

There are a couple of UX issues here that we can take care of quickly.

  1. If you click back after a search, the form field still has the value you entered even though the list is no longer filtered.
  2. If you refresh the page after searching, the form field no longer has the value in it, even though the list is filtered

In other words, the URL and our form state are out of sync.

πŸ‘‰ Return q from your loader and set it as the search field default value

That solves problem (2). If you refresh the page now, the input field will show the query.

Now for problem (1), clicking the back button and updating the input. We can bring in useEffect from React to manipulate the form's state in the DOM directly.

πŸ‘‰ Synchronize input value with the URL Search Params

πŸ€” Shouldn't you use a controlled component and React State for this?

You could certainly do this as a controlled component, but you'll end up with more complexity for the same behavior. You don't control the URL, the user does with the back/forward buttons. There would be more synchronization points with a controlled component.

If you're still concerned, expand this to see what it would look like

Notice how controlling the input requires three points of synchronization now instead of just one. The behavior is identical but the code is more complex.

src/routes/root.jsx

import { useEffect, useState } from 'react'
// existing code
export async function loader({ request }) {
const url = new URL(request.url)
const q = url.searchParams.get('q') || ''
const contacts = await getContacts(q)
return { contacts, q }
}
// existing code
export default function Root() {
const { contacts, q } = useLoaderData()
const [query, setQuery] = useState(q)
const navigation = useNavigation()
useEffect(() => {
setQuery(q)
}, [q])
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
value={query}
onChange={(e) => {
setQuery(e.target.value)
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
</>
)
}

Submitting Forms onChange

We've got a product decision to make here. For this UI, we'd probably rather have the filtering happen on every key stroke instead of when the form is explicitly submitted.

We've seen useNavigate already, we'll use its cousin, useSubmit, for this.

Now as you type, the form is submitted automatically!

Note the argument to submit. We're passing in event.currentTarget.form. The currentTarget is the DOM node the event is attached to, and the currentTarget.form is the input's parent form node. The submit function will serialize and submit any form you pass to it.

Adding Search Spinner

In a production app, it's likely this search will be looking for records in a database that is too large to send all at once and filter client side. That's why this demo has some faked network latency.

Without any loading indicator, the search feels kinda sluggish. Even if we could make our database faster, we'll always have the user's network latency in the way and out of our control. For a better UX, let's add some immediate UI feedback for the search. For this we'll use useNavigation again.

πŸ‘‰ Add the search spinner

The navigation.location will show up when the app is navigating to a new URL and loading the data for it. It then goes away when there is no pending navigation anymore.

Managing the History Stack

Now that the form is submitted for every key stroke, if we type the characters "seba" and then delete them with backspace, we end up with 7 new entries in the stack πŸ˜‚. We definitely don't want this

We can avoid this by replacing the current entry in the history stack with the next page, instead of pushing into it.

πŸ‘‰ Use replace in submit

We only want to replace search results, not the page before we started searching, so we do a quick check if this is the first search or not and then decide to replace.

Each key stroke no longer creates new entries, so the user can click back out of the search results without having to click it 7 times πŸ˜….

Mutations Without Navigation

So far all of our mutations (the times we change data) have used forms that navigate, creating new entries in the history stack. While these user flows are common, it's equally as common to want to change data without causing a navigation.

For these cases, we have the useFetcher hook. It allows us to communicate with loaders and actions without causing a navigation.

The β˜… button on the contact page makes sense for this. We aren't creating or deleting a new record, we don't want to change pages, we simply want to change the data on the page we're looking at.

πŸ‘‰ Change the <Favorite> form to a fetcher form

Might want to take a look at that form while we're here. As always, our form has fields with a name prop. This form will send formData with a favorite key that's either "true" | "false". Since it's got method="post" it will call the action. Since there is no <fetcher.Form action="..."> prop, it will post to the route where the form is rendered.

πŸ‘‰ Create the action

Pretty simple. Pull the form data off the request and send it to the data model.

πŸ‘‰ Configure the route's new action

Alright, we're ready to click the star next to the user's name!

Check that out, both stars automatically update. Our new <fetcher.Form method="post"> works almost exactly like the <Form> we've been using: it calls the action and then all data is revalidated automatically--even your errors will be caught the same way.

There is one key difference though, it's not a navigation--the URL doesn't change, the history stack is unaffected.

Optimistic UI

You probably noticed the app felt kind of unresponsive when we clicked the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world!

To give the user some feedback, we could put the star into a loading state with fetcher.state (a lot like navigation.state from before), but we can do something even better this time. We can use a strategy called "optimistic UI"

The fetcher knows the form data being submitted to the action, so it's available to you on fetcher.formData. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data.

πŸ‘‰ Read the optimistic value from fetcher.formData

If you click the button now you should see the star immediately change to the new state. Instead of always rendering the actual data, we check if the fetcher has any formData being submitted, if so, we'll use that instead. When the action is done, the fetcher.formData will no longer exist and we're back to using the actual data. So even if you write bugs in your optimistic UI code, it'll eventually go back to the correct state πŸ₯Ή

Not Found Data

What happens if the contact we're trying to load doesn't exist?

Our root errorElement is catching this unexpected error as we try to render a null contact. Nice the error was properly handled, but we can do better!

Whenever you have an expected error case in a loader or action–like the data not existing–you can throw. The call stack will break, React Router will catch it, and the error path is rendered instead. We won't even try to render a null contact.

πŸ‘‰ Throw a 404 response in the loader

Instead of hitting a render error with Cannot read properties of null, we avoid the component completely and render the error path instead, telling the user something more specific.

This keeps your happy paths, happy. Your route elements don't need to concern themselves with error and loading states.

Pathless Routes

One last thing. The last error page we saw would be better if it rendered inside the root outlet, instead of the whole page. In fact, every error in all of our child routes would be better in the outlet, then the user has more options than hitting refresh.

We'd like it to look like this:

We could add the error element to every one of the child routes but, since it's all the same error page, this isn't recommended.

There's a cleaner way. Routes can be used without a path, which lets them participate in the UI layout without requiring new path segments in the URL. Check it out:

πŸ‘‰ Wrap the child routes in a pathless route

When any errors are thrown in the child routes, our new pathless route will catch it and render, preserving the root route's UI!

JSX Routes

And for our final trick, many folks prefer to configure their routes with JSX. You can do that with createRoutesFromElements. There is no functional difference between JSX or objects when configuring your routes, it's simply a stylistic preference.


import {
createRoutesFromElements,
createBrowserRouter,
Route,
} from 'react-router-dom'
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
element={<Root />}
loader={rootLoader}
action={rootAction}
errorElement={<ErrorPage />}
>
<Route errorElement={<ErrorPage />}>
<Route index element={<Index />} />
<Route
path="contacts/:contactId"
element={<Contact />}
loader={contactLoader}
action={contactAction}
/>
<Route
path="contacts/:contactId/edit"
element={<EditContact />}
loader={contactLoader}
action={editAction}
/>
<Route
path="contacts/:contactId/destroy"
action={destroyAction}
/>
</Route>
</Route>
)
)

That's it! Thanks for giving React Router a shot. We hope this tutorial gives you a solid start to build great user experiences. There's a lot more you can do with React Router, so make sure to check out all the APIs πŸ˜€


_10
npm create vite@latest name-of-your-project -- --template react
_10
# follow prompts
_10
cd <your new project directory>
_10
npm install react-router-dom localforage match-sorter sort-by
_10
npm run dev