Tutorial v6.15.0 - React Router
Tutorial v6.15.0 - React Router
Tutorial
On this page
Tutorial
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
N OT E
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:
➜ 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.)
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.
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.
src/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.
mkdir src/routes
touch src/routes/root.jsx
(If you don't want to be a command line nerd, use your editor instead of those commands 🤓)
👉 Create the root layout component
src/routes/root.jsx
1 export default function Root() {
2 return (
3 <>
4 <div id="sidebar">
5 <h1>React Router Contacts</h1>
6 <div>
7 <form id="search-form" role="search">
8 <input
9 id="q"
10 aria-label="Search contacts"
11 placeholder="Search"
12 type="search"
13 name="q"
14 />
15 <div
16 id="search-spinner"
17 aria-hidden
18 hidden={true}
19 />
20 <div
21 className="sr-only"
22 aria-live="polite"
23 ></div>
24 </form>
25 <form method="post">
26 <button type="submit">New</button>
27 </form>
28 </div>
29 <nav>
30 <ul>
31 <li>
32 <a href={`/contacts/1`}>Your Name</a>
33 </li>
34 <li>
35 <a href={`/contacts/2`}>Your Friend</a>
36 </li>
37 </ul>
38 </nav>
39 </div>
40 <div id="detail"></div>
41 </>
42 );
43 }
Nothing React Router specific yet, so feel free to copy/paste all of that.
1 /* existing imports */
2 import Root from "./routes/root";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 },
9 ]);
10
11 ReactDOM.createRoot(document.getElementById("root")).render(
12 <React.StrictMode>
13 <RouterProvider router={router} />
14 </React.StrictMode>
15 );
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?
Gross! This is the default error screen in React Router, made worse by our
😂
flex box styles on the root element in this app .
src/error-page.jsx
src/main.jsx
1 /* previous imports */
2 import ErrorPage from "./error-page";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 errorElement: <ErrorPage />,
9 },
10 ]);
11
12 ReactDOM.createRoot(document.getElementById("root")).render(
13 <React.StrictMode>
14 <RouterProvider router={router} />
15 </React.StrictMode>
16 );
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.
src/routes/contact.jsx
1 import { Form } from "react-router-dom";
2
3 export default function Contact() {
4 const contact = {
5 first: "Your",
6 last: "Name",
7 avatar: "https://placekitten.com/g/200/200",
8 twitter: "your_handle",
9 notes: "Some notes",
10 favorite: true,
11 };
12
13 return (
14 <div id="contact">
15 <div>
16 <img
17 key={contact.avatar}
18 src={contact.avatar || null}
19 />
20 </div>
21
22 <div>
23 <h1>
24 {contact.first || contact.last ? (
25 <>
26 {contact.first} {contact.last}
27 </>
28 ) : (
29 <i>No Name</i>
30 )}{" "}
31 <Favorite contact={contact} />
32 </h1>
33
34 {contact.twitter && (
35 <p>
36 <a
37 target="_blank"
38 href={`https://twitter.com/${contact.twitter}`}
39 >
40 {contact.twitter}
41 </a>
42 </p>
43 )}
44
45 {contact.notes && <p>{contact.notes}</p>}
46
47 <div>
48 <Form action="edit">
49 <button type="submit">Edit</button>
50 </Form>
51 <Form
52 method="post"
53 action="destroy"
54 onSubmit={(event) => {
55 if (
56 !confirm(
57 "Please confirm you want to delete this record."
58 )
59 ) {
60 event.preventDefault();
61 }
62 }}
63 >
64 <button type="submit">Delete</button>
65 </Form>
66 </div>
67 </div>
68 </div>
69 );
70 }
71
72 function Favorite({ contact }) {
73 // yes, this is a `let` for later
74 let favorite = contact.favorite;
75 return (
76 <Form method="post">
77 <button
78 name="favorite"
79 value={favorite ? "false" : "true"}
80 aria-label={
81 favorite
82 ? "Remove from favorites"
83 : "Add to favorites"
84 }
85 >
86 {favorite ? "★" : "☆"}
87 </button>
88 </Form>
89 );
90 }
1 /* existing imports */
2 import Contact from "./routes/contact";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 errorElement: <ErrorPage />,
9 },
10 {
11 path: "contacts/:contactId",
12 element: <Contact />,
13 },
14 ]);
15
16 /* existing code */
Now if we click one of the links or visit `/contacts/1` we get our new
component!
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.
src/main.jsx
1 const router = createBrowserRouter([
2 {
3 path: "/",
4 element: <Root />,
5 errorElement: <ErrorPage />,
6 children: [
7 {
8 path: "contacts/:contactId",
9 element: <Contact />,
10 },
11 ],
12 },
13 ]);
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>`.
👉 Render an `<Outlet>`
src/routes/root.jsx
src/routes/root.jsx
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 Segment Component Data
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.
src/routes/root.jsx
src/main.jsx
1 /* other imports */
2 import Root, { loader as rootLoader } from "./routes/root";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 errorElement: <ErrorPage />,
9 loader: rootLoader,
10 children: [
11 {
12 path: "contacts/:contactId",
13 element: <Contact />,
14 },
15 ],
16 },
17 ]);
src/routes/root.jsx
1 import {
2 Outlet,
3 Link,
4 useLoaderData,
5 } from "react-router-dom";
6 import { getContacts } from "../contacts";
7
8 /* other code */
9
10 export default function Root() {
11 const { contacts } = useLoaderData();
12 return (
13 <>
14 <div id="sidebar">
15 <h1>React Router Contacts</h1>
16 {/* other code */}
17
18 <nav>
19 {contacts.length ? (
20 <ul>
21 {contacts.map((contact) => (
22 <li key={contact.id}>
23 <Link to={`contacts/${contact.id}`}>
24 {contact.first || contact.last ? (
25 <>
26 {contact.first} {contact.last}
27 </>
28 ) : (
29 <i>No Name</i>
30 )}{" "}
31 {contact.favorite && <span>★</span>}
32 </Link>
33 </li>
34 ))}
35 </ul>
36 ) : (
37 <p>
38 <i>No contacts</i>
39 </p>
40 )}
41 </nav>
42
43 {/* other code */}
44 </div>
45 </>
46 );
47 }
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:
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>`.
src/routes/root.jsx
1 import {
2 Outlet,
3 Link,
4 useLoaderData,
5 Form,
6 } from "react-router-dom";
7 import { getContacts, createContact } from "../contacts";
8
9 export async function action() {
10 const contact = await createContact();
11 return { contact };
12 }
13
14 /* other code */
15
16 export default function Root() {
17 const { contacts } = useLoaderData();
18 return (
19 <>
20 <div id="sidebar">
21 <h1>React Router Contacts</h1>
22 <div>
23 {/* other code */}
24 <Form method="post">
25 <button type="submit">New</button>
26 </Form>
27 </div>
28
29 {/* other code */}
30 </div>
31 </>
32 );
33 }
src/main.jsx
1 /* other imports */
2
3 import Root, {
4 loader as rootLoader,
5 action as rootAction,
6 } from "./routes/root";
7
8 const router = createBrowserRouter([
9 {
10 path: "/",
11 element: <Root />,
12 errorElement: <ErrorPage />,
13 loader: rootLoader,
14 action: rootAction,
15 children: [
16 {
17 path: "contacts/:contactId",
18 element: <Contact />,
19 },
20 ],
21 },
22 ]);
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.
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:
1 [
2 {
3 path: "contacts/:contactId",
4 element: <Contact />,
5 },
6 ];
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.
src/main.jsx
1 /* existing code */
2 import Contact, {
3 loader as contactLoader,
4 } from "./routes/contact";
5
6 const router = createBrowserRouter([
7 {
8 path: "/",
9 element: <Root />,
10 errorElement: <ErrorPage />,
11 loader: rootLoader,
12 action: rootAction,
13 children: [
14 {
15 path: "contacts/:contactId",
16 element: <Contact />,
17 loader: contactLoader,
18 },
19 ],
20 },
21 ]);
22
23 /* existing code */
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.
touch src/routes/edit.jsx
src/routes/edit.jsx
1 import { Form, useLoaderData } from "react-router-dom";
2
3 export default function EditContact() {
4 const { contact } = useLoaderData();
5
6 return (
7 <Form method="post" id="contact-form">
8 <p>
9 <span>Name</span>
10 <input
11 placeholder="First"
12 aria-label="First name"
13 type="text"
14 name="first"
15 defaultValue={contact.first}
16 />
17 <input
18 placeholder="Last"
19 aria-label="Last name"
20 type="text"
21 name="last"
22 defaultValue={contact.last}
23 />
24 </p>
25 <label>
26 <span>Twitter</span>
27 <input
28 type="text"
29 name="twitter"
30 placeholder="@jack"
31 defaultValue={contact.twitter}
32 />
33 </label>
34 <label>
35 <span>Avatar URL</span>
36 <input
37 placeholder="https://example.com/avatar.jpg"
38 aria-label="Avatar URL"
39 type="text"
40 name="avatar"
41 defaultValue={contact.avatar}
42 />
43 </label>
44 <label>
45 <span>Notes</span>
46 <textarea
47 name="notes"
48 defaultValue={contact.notes}
49 rows={6}
50 />
51 </label>
52 <p>
53 <button type="submit">Save</button>
54 <button type="button">Cancel</button>
55 </p>
56 </Form>
57 );
58 }
src/main.jsx
1 /* existing code */
2 import EditContact from "./routes/edit";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 errorElement: <ErrorPage />,
9 loader: rootLoader,
10 action: rootAction,
11 children: [
12 {
13 path: "contacts/:contactId",
14 element: <Contact />,
15 loader: contactLoader,
16 },
17 {
18 path: "contacts/:contactId/edit",
19 element: <EditContact />,
20 loader: contactLoader,
21 },
22 ],
23 },
24 ]);
25
26 /* existing code */
src/routes/edit.jsx
1 import {
2 Form,
3 useLoaderData,
4 redirect,
5 } from "react-router-dom";
6 import { updateContact } from "../contacts";
7
8 export async function action({ request, params }) {
9 const formData = await request.formData();
10 const updates = Object.fromEntries(formData);
11 await updateContact(params.contactId, updates);
12 return redirect(`/contacts/${params.contactId}`);
13 }
14
15 /* existing code */
src/main.jsx
1 /* existing code */
2 import EditContact, {
3 action as editAction,
4 } from "./routes/edit";
5
6 const router = createBrowserRouter([
7 {
8 path: "/",
9 element: <Root />,
10 errorElement: <ErrorPage />,
11 loader: rootLoader,
12 action: rootAction,
13 children: [
14 {
15 path: "contacts/:contactId",
16 element: <Contact />,
17 loader: contactLoader,
18 },
19 {
20 path: "contacts/:contactId/edit",
21 element: <EditContact />,
22 loader: contactLoader,
23 action: editAction,
24 },
25 ],
26 },
27 ]);
28
29 /* existing code */
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...
src/routes/edit.jsx
1 <input
2 placeholder="First"
3 aria-label="First name"
4 type="text"
5 name="first"
6 defaultValue={contact.first}
7 />
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`.
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.
src/routes/edit.jsx
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!
src/routes/root.jsx
1 import {
2 Outlet,
3 Link,
4 useLoaderData,
5 Form,
6 redirect,
7 } from "react-router-dom";
8 import { getContacts, createContact } from "../contacts";
9
10 export async function action() {
11 const contact = await createContact();
12 return redirect(`/contacts/${contact.id}/edit`);
13 }
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.
src/routes/root.jsx
1 import {
2 Outlet,
3 NavLink,
4 useLoaderData,
5 Form,
6 redirect,
7 } from "react-router-dom";
8
9 export default function Root() {
10 return (
11 <>
12 <div id="sidebar">
13 {/* other code */}
14
15 <nav>
16 {contacts.length ? (
17 <ul>
18 {contacts.map((contact) => (
19 <li key={contact.id}>
20 <NavLink
21 to={`contacts/${contact.id}`}
22 className={({ isActive, isPending }) =>
23 isActive
24 ? "active"
25 : isPending
26 ? "pending"
27 : ""
28 }
29 >
30 {/* other code */}
31 </NavLink>
32 </li>
33 ))}
34 </ul>
35 ) : (
36 <p>{/* other code */}</p>
37 )}
38 </nav>
39 </div>
40 </>
41 );
42 }
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.
src/routes/root.jsx
1 import {
2 // existing code
3 useNavigation,
4 } from "react-router-dom";
5
6 // existing code
7
8 export default function Root() {
9 const { contacts } = useLoaderData();
10 const navigation = useNavigation();
11
12 return (
13 <>
14 <div id="sidebar">{/* existing code */}</div>
15 <div
16 id="detail"
17 className={
18 navigation.state === "loading" ? "loading" : ""
19 }
20 >
21 <Outlet />
22 </div>
23 </>
24 );
25 }
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:
src/routes/contact.jsx
1 <Form
2 method="post"
3 action="destroy"
4 onSubmit={(event) => {
5 if (
6 !confirm(
7 "Please confirm you want to delete this record."
8 )
9 ) {
10 event.preventDefault();
11 }
12 }}
13 >
14 <button type="submit">Delete</button>
15 </Form>
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`
touch src/routes/destroy.jsx
src/routes/destroy.jsx
src/main.jsx
1 /* existing code */
2 import { action as destroyAction } from "./routes/destroy";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 /* existing root route props */
8 children: [
9 /* existing routes */
10 {
11 path: "contacts/:contactId/destroy",
12 action: destroyAction,
13 },
14 ],
15 },
16 ]);
17
18 /* existing code */
Contextual Errors
Just for kicks, throw an error in the destroy action:
src/routes/destroy.jsx
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.
src/main.jsx
1 [
2 /* other routes */
3 {
4 path: "contacts/:contactId/destroy",
5 action: destroyAction,
6 errorElement: <div>Oops! There was an error.</div>,
7 },
8 ];
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.
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.
touch src/routes/index.jsx
src/routes/index.jsx
1 export default function Index() {
2 return (
3 <p id="zero-state">
4 This is a demo for React Router.
5 <br />
6 Check out{" "}
7 <a href="https://reactrouter.com">
8 the docs at reactrouter.com
9 </a>
10 .
11 </p>
12 );
13 }
src/main.jsx
1 // existing code
2 import Index from "./routes/index";
3
4 const router = createBrowserRouter([
5 {
6 path: "/",
7 element: <Root />,
8 errorElement: <ErrorPage />,
9 loader: rootLoader,
10 action: rootAction,
11 children: [
12 { index: true, element: <Index /> },
13 /* existing routes */
14 ],
15 },
16 ]);
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.
src/routes/edit.jsx
1 import {
2 Form,
3 useLoaderData,
4 redirect,
5 useNavigate,
6 } from "react-router-dom";
7
8 export default function EditContact() {
9 const { contact } = useLoaderData();
10 const navigate = useNavigate();
11
12 return (
13 <Form method="post" id="contact-form">
14 {/* existing code */}
15
16 <p>
17 <button type="submit">Save</button>
18 <button
19 type="button"
20 onClick={() => {
21 navigate(-1);
22 }}
23 >
24 Cancel
25 </button>
26 </p>
27 </Form>
28 );
29 }
Now when the user clicks "Cancel", they'll be sent back one entry in the
browser's history.
👉 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
src/routes/root.jsx
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.
src/routes/root.jsx
This also means it's a normal page navigation. You can click the back
button to get back to where you were.
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/routes/root.jsx
1 // existing code
2
3 export async function loader({ request }) {
4 const url = new URL(request.url);
5 const q = url.searchParams.get("q");
6 const contacts = await getContacts(q);
7 return { contacts, q };
8 }
9
10 export default function Root() {
11 const { contacts, q } = useLoaderData();
12 const navigation = useNavigation();
13
14 return (
15 <>
16 <div id="sidebar">
17 <h1>React Router Contacts</h1>
18 <div>
19 <Form id="search-form" role="search">
20 <input
21 id="q"
22 aria-label="Search contacts"
23 placeholder="Search"
24 type="search"
25 name="q"
26 defaultValue={q}
27 />
28 {/* existing code */}
29 </Form>
30 {/* existing code */}
31 </div>
32 {/* existing code */}
33 </div>
34 {/* existing code */}
35 </>
36 );
37 }
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.
src/routes/root.jsx
If you're still concerned, expand this to see what it would look like
We've seen `useNavigate` already, we'll use its cousin, `useSubmit`, for
this.
src/routes/root.jsx
1 // existing code
2 import {
3 // existing code
4 useSubmit,
5 } from "react-router-dom";
6
7 export default function Root() {
8 const { contacts, q } = useLoaderData();
9 const navigation = useNavigation();
10 const submit = useSubmit();
11
12 return (
13 <>
14 <div id="sidebar">
15 <h1>React Router Contacts</h1>
16 <div>
17 <Form id="search-form" role="search">
18 <input
19 id="q"
20 aria-label="Search contacts"
21 placeholder="Search"
22 type="search"
23 name="q"
24 defaultValue={q}
25 onChange={(event) => {
26 submit(event.currentTarget.form);
27 }}
28 />
29 {/* existing code */}
30 </Form>
31 {/* existing code */}
32 </div>
33 {/* existing code */}
34 </div>
35 {/* existing code */}
36 </>
37 );
38 }
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.
src/routes/root.jsx
1 // existing code
2
3 export default function Root() {
4 const { contacts, q } = useLoaderData();
5 const navigation = useNavigation();
6 const submit = useSubmit();
7
8 const searching =
9 navigation.location &&
10 new URLSearchParams(navigation.location.search).has(
11 "q"
12 );
13
14 useEffect(() => {
15 document.getElementById("q").value = q;
16 }, [q]);
17
18 return (
19 <>
20 <div id="sidebar">
21 <h1>React Router Contacts</h1>
22 <div>
23 <Form id="search-form" role="search">
24 <input
25 id="q"
26 className={searching ? "loading" : ""}
27 // existing code
28 />
29 <div
30 id="search-spinner"
31 aria-hidden
32 hidden={!searching}
33 />
34 {/* existing code */}
35 </Form>
36 {/* existing code */}
37 </div>
38 {/* existing code */}
39 </div>
40 {/* existing code */}
41 </>
42 );
43 }
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.
src/routes/root.jsx
1 // existing code
2
3 export default function Root() {
4 // existing code
5
6 return (
7 <>
8 <div id="sidebar">
9 <h1>React Router Contacts</h1>
10 <div>
11 <Form id="search-form" role="search">
12 <input
13 id="q"
14 // existing code
15 onChange={(event) => {
16 const isFirstSearch = q == null;
17 submit(event.currentTarget.form, {
18 replace: !isFirstSearch,
19 });
20 }}
21 />
22 {/* existing code */}
23 </Form>
24 {/* existing code */}
25 </div>
26 {/* existing code */}
27 </div>
28 {/* existing code */}
29 </>
30 );
31 }
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 .
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.
src/routes/contact.jsx
1 import {
2 useLoaderData,
3 Form,
4 useFetcher,
5 } from "react-router-dom";
6
7 // existing code
8
9 function Favorite({ contact }) {
10 const fetcher = useFetcher();
11 let favorite = contact.favorite;
12
13 return (
14 <fetcher.Form method="post">
15 <button
16 name="favorite"
17 value={favorite ? "false" : "true"}
18 aria-label={
19 favorite
20 ? "Remove from favorites"
21 : "Add to favorites"
22 }
23 >
24 {favorite ? "★" : "☆"}
25 </button>
26 </fetcher.Form>
27 );
28 }
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/routes/contact.jsx
1 // existing code
2 import { getContact, updateContact } from "../contacts";
3
4 export async function action({ request, params }) {
5 let formData = await request.formData();
6 return updateContact(params.contactId, {
7 favorite: formData.get("favorite") === "true",
8 });
9 }
10
11 export default function Contact() {
12 // existing code
13 }
Pretty simple. Pull the form data off the request and send it to the data
model.
src/main.jsx
1 // existing code
2 import Contact, {
3 loader as contactLoader,
4 action as contactAction,
5 } from "./routes/contact";
6
7 const router = createBrowserRouter([
8 {
9 path: "/",
10 element: <Root />,
11 errorElement: <ErrorPage />,
12 loader: rootLoader,
13 action: rootAction,
14 children: [
15 { index: true, element: <Index /> },
16 {
17 path: "contacts/:contactId",
18 element: <Contact />,
19 loader: contactLoader,
20 action: contactAction,
21 },
22 /* existing code */
23 ],
24 },
25 ]);
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.
src/routes/contact.jsx
1 // existing code
2
3 function Favorite({ contact }) {
4 const fetcher = useFetcher();
5
6 let favorite = contact.favorite;
7 if (fetcher.formData) {
8 favorite = fetcher.formData.get("favorite") === "true";
9 }
10
11 return (
12 <fetcher.Form method="post">
13 <button
14 name="favorite"
15 value={favorite ? "false" : "true"}
16 aria-label={
17 favorite
18 ? "Remove from favorites"
19 : "Add to favorites"
20 }
21 >
22 {favorite ? "★" : "☆"}
23 </button>
24 </fetcher.Form>
25 );
26 }
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!
src/routes/contact.jsx
1 export async function loader({ params }) {
2 const contact = await getContact(params.contactId);
3 if (!contact) {
4 throw new Response("", {
5 status: 404,
6 statusText: "Not Found",
7 });
8 }
9 return { contact };
10 }
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 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:
src/main.jsx
1 createBrowserRouter([
2 {
3 path: "/",
4 element: <Root />,
5 loader: rootLoader,
6 action: rootAction,
7 errorElement: <ErrorPage />,
8 children: [
9 {
10 errorElement: <ErrorPage />,
11 children: [
12 { index: true, element: <Index /> },
13 {
14 path: "contacts/:contactId",
15 element: <Contact />,
16 loader: contactLoader,
17 action: contactAction,
18 },
19 /* the rest of the routes */
20 ],
21 },
22 ],
23 },
24 ]);
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.
1 import {
2 createRoutesFromElements,
3 createBrowserRouter,
4 Route,
5 } from "react-router-dom";
6
7 const router = createBrowserRouter(
8 createRoutesFromElements(
9 <Route
10 path="/"
11 element={<Root />}
12 loader={rootLoader}
13 action={rootAction}
14 errorElement={<ErrorPage />}
15 >
16 <Route errorElement={<ErrorPage />}>
17 <Route index element={<Index />} />
18 <Route
19 path="contacts/:contactId"
20 element={<Contact />}
21 loader={contactLoader}
22 action={contactAction}
23 />
24 <Route
25 path="contacts/:contactId/edit"
26 element={<EditContact />}
27 loader={contactLoader}
28 action={editAction}
29 />
30 <Route
31 path="contacts/:contactId/destroy"
32 action={destroyAction}
33 />
34 </Route>
35 </Route>
36 )
37 );
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 😀
© Remix Software, Inc. Edit
Brand
Docs and examples CC 4.0