React Router@6 Tutorial
React Router@6 教程。
Setup 启动
执行下面的命令:
npm create vite@latest <项目名> -- --template react然后 cd 到项目目录,再执行下面的命令:
# 下载 react-router-dom、localforage、match-sorter、sort-by
# 不用过多关注除了 react-router-dom 之外的包
npm install react-router-dom localforage match-sorter sort-by
# 启动项目
npm run dev- 复制粘贴 CSS 到 src/common.css 中
然后在入口文件中引入。
import './common.css'- 复制粘贴 js 到 src/contacts.js 中
然后把项目的多余文件都删掉,尽量保持简单。
添加一个 Router
在 main.jsx 中:
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import "./index.css";
const router = createBrowserRouter([
{
path: "/",
element: <div>Hello world!</div>,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} /> // [!code hl]
</React.StrictMode>
);根路由
创建文件 src/routes/root.jsx,将下面的内容复制进去:
export default function Root() {
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"
/>
<div
id="search-spinner"
aria-hidden
hidden={true}
/>
<div
className="sr-only"
aria-live="polite"
></div>
</form>
<form method="post">
<button type="submit">New</button>
</form>
</div>
<nav>
<ul>
<li>
<a href={`/contacts/1`}>Your Name</a>
</li>
<li>
<a href={`/contacts/2`}>Your Friend</a>
</li>
</ul>
</nav>
</div>
<div id="detail"></div>
</>
);
}将组件 <Root /> 设置为根路由的 element。
/* existing imports */
import Root from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);处理 404 路由
touch src/error-page.jsximport { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
return (
<div id="error-page">
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
);
}然后将 <ErrorPage /> 组件设置为根路由的 errorElement 属性值。
/* previous imports */
import ErrorPage from "./error-page";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);The Contact Route UI 联系人组件 UI
touch src/routes/contact.jsx复制以下内容进去:
import { Form } from "react-router-dom";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://placekitten.com/g/200/200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
key={contact.avatar}
src={contact.avatar || null}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a
target="_blank"
href={`https://twitter.com/${contact.twitter}`}
>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
}
function Favorite({ contact }) {
// yes, this is a `let` for later
let favorite = contact.favorite;
return (
<Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</Form>
);
}注册路由:
/* existing imports */
import Contact from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
{
path: "contacts/:contactId",
element: <Contact />,
},
]);
/* existing code */如果我们现在点击链接 url 会访问 /contacts/1,并一个新的组件。但是,这个新组件并没有在根组件内部,而是把根组件给替换掉了。
Nested Routes 嵌套路由
我们可以让联系人组件作为根组件的子路由组件。
移动 contacts route 到 root route 的 children 属性中:
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);告诉根路由在哪里渲染它的子路由:
root.jsx:
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
{/* all the other elements */}
<div id="detail">
<Outlet /> // [!code hl]
</div>
</>
);
}Client Side Routing 客户端路由导航
现在如果我们在侧边栏点击链接,浏览器会发送一个完整的 html 请求。
客户端路由导航允许我们更新 url,但是不会向服务器请求一个新的 hmtl 请求。
我们通过 <Link /> 组件实现。
将 <a href> 改成 <Link to>
import { Outlet, Link } from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* other elements */}
<nav>
<ul>
<li>
<Link to={`contacts/1`}>Your Name</Link> // [!code hl]
</li>
<li>
<Link to={`contacts/2`}>Your Friend</Link> // [!code hl]
</li>
</ul>
</nav>
{/* other elements */}
</div>
</>
);
}加载数据
url 片段,页面布局,和数据加载这三者经常耦合在一起。我们现在已经能从这个 app 中看出来了。
| url 片段 | 页面布局 | 数据加载 |
|---|---|---|
| / | <Root> | 联系人列表 |
| contacts/:id | <Contact> | 单个联系人 |
react-router 有两个 api 用来加载数据。loader 和 useLoaderData。
在 root.jsx 中导出一个 loader 函数。
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
} 在路由中配置 loader。
/* other imports */
import Root, { loader as rootLoader } from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);获取渲染 loader 数据。如果 loader 是一个 promise,那么会等待它成功。在成功之前都不会往下执行代码,所以首页可能出现白屏情况。
每次进入该路由都会重新调用该 loader 获取数据。
import {
Outlet,
Link,
useLoaderData,
} from "react-router-dom";
import { getContacts } from "../contacts";
/* other code */
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
{/* other code */}
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<Link to={`contacts/${contact.id}`}>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite && <span>★</span>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
{/* other code */}
</div>
</>
);
}Data Writes + HTML Forms
React Router 模拟表单的导航来提交数据改动。
原始的 form 标签表单可以发送完整的服务请求,通过 method 属性设置 get 请求或 post 请求,通过 action 属性设置服务请求地址。
React Router 模拟原始 form 表单的行为,但不会向服务器发送请求。
Creating Contacts 创建联系人
让我们在根路由导出一个 action 方法,并将 <form></form> 改为 React Router 的 <Form></Form>。
root.jsx
import {
Outlet,
Link,
useLoaderData,
Form,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return { contact };
}
/* other code */
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
{/* other code */}
<Form method="post"> // [!code hl]
<button type="submit">New</button> // [!code hl]
</Form> // [!code hl]
</div>
{/* other code */}
</div>
</>
);
}在路由中设置 action。当 <Form></Form> 表单提交时,就会调用 action 方法。
main.jsx
/* other imports */
import Root, {
loader as rootLoader,
action as rootAction,
} from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);在 loader 中获取 url params
重新检查下路由配置,路由看起来像这样子:
[
{
path: "contacts/:contactId",
element: <Contact />,
},
];在 url 片段中的 : 有这特殊的意义,我们叫它动态参数。动态参数会动态地匹配在这个位置上的值。
动态参数会以 key 的形式传递给 loader,键名就是 url 片段的命名。
下面,在联系人详情页添加一个 loader,并使用 useLoaderData 获取数据。
src/routes/contact.js
import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";
export async function loader({ params }) {
const contact = await getContact(params.contactId);
return { contact };
}
export default function Contact() {
const { contact } = useLoaderData();
// existing code
}然后在路由中配置 loader。
/* existing code */
import Contact, {
loader as contactLoader,
} from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
],
},
]);
/* existing code */更新数据
就像新增联系人一样,你可以使用 <Form></Form> 更新数据。让我们创建一个新路由 contacts/:contactId/edit。然后创建 edit 组件。
touch src/routes/edit.jsxedit.jsx 的内容(复制即可):
import { Form, useLoaderData } from "react-router-dom";
export default function EditContact() {
// 已经在路由配置文件里注册过了,复用的联系人详情的 loader
const { contact } = useLoaderData();
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>
<input
placeholder="Last"
aria-label="Last name"
type="text"
name="last"
defaultValue={contact.last}
/>
</p>
<label>
<span>Twitter</span>
<input
type="text"
name="twitter"
placeholder="@jack"
defaultValue={contact.twitter}
/>
</label>
<label>
<span>Avatar URL</span>
<input
placeholder="https://example.com/avatar.jpg"
aria-label="Avatar URL"
type="text"
name="avatar"
defaultValue={contact.avatar}
/>
</label>
<label>
<span>Notes</span>
<textarea
name="notes"
defaultValue={contact.notes}
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}添加新路由。
/* existing code */
import EditContact from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
},
],
},
]);
/* existing code */现在点击 Edit 按钮,就会调到编辑页了。
Updating Contacts with FormData 用表单数据更新联系人
在编辑页我们使用了 React Router 的表单。现在我们只需要定义一个 action,当表单提交时,会触发 action。
edit.jsx
import {
Form,
useLoaderData,
redirect,
} from "react-router-dom";
import { updateContact } from "../contacts";
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}`);
}
/* existing code */将 action 连接到路由。
/* existing code */
import EditContact, {
action as editAction,
} from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
action: editAction,
},
],
},
]);
/* existing code */数据更新讨论
打开 src/routes/edit.jsx,注意这里有一个 name 属性。
<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"除了 action,这些 api 没有一个是 React Router 提供的,都是 web 平台提供的:request、request.formData、Object.fromEntries。
新增联系人时重定向到编辑页
我们现在已经知道了如何重定向,现在我们修改下新增联系人的 action,让它重定向到编辑页。
root.jsx
import {
Outlet,
Link,
useLoaderData,
Form,
redirect,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return redirect(`/contacts/${contact.id}/edit`);
}Now when we click "New", we should end up on the edit page。
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.
在 sidebar 中使用 NavLink:
import {
Outlet,
NavLink,
useLoaderData,
Form,
redirect,
} from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* other code */}
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
to={`contacts/${contact.id}`}
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
>
{/* other code */}
</NavLink>
</li>
))}
</ul>
) : (
<p>{/* other code */}</p>
)}
</nav>
</div>
</>
);
}当用户的 url 和列表中的某个 navlink 的 url 一样时,这个 navlink 的 isActive 为 true,否则为 false。
当用户的 url 将要和列表中的某个 navlink 的 url 一样时,这个 navlink 的 isPending 为 true,否则为 false。
全局加载 UI
当用户在 app 里导航时,React Router 将在加载下一页的数据时保留旧页。
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.
use useNavigation to add global pending UI.
root.jsx
import {
// existing code
useNavigation,
} from "react-router-dom";
// existing code
export default function Root() {
const { contacts } = useLoaderData();
const navigation = useNavigation();
return (
<>
<div id="sidebar">{/* existing code */}</div>
<div
id="detail"
className={
navigation.state === "loading" ? "loading" : ""
}
>
<Outlet />
</div>
</>
);
}useNavigation returns the current navigation state: it can be one of "idle" | "submitting" | "loading".
- idle - 没有待处理的导航。
- submitting - 由于使用 POST、PUT、PATCH 或 DELETE 提交表单而调用路由操作。
- loading - 下一个路由的加载器正在调用以呈现下一页。
Deleting Records 删除联系人
contact.jsx
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>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:
- A new route
- An action at that route
- deleteContact from src/contacts.js
Create the "destroy" route module
touch src/routes/destroy.jsxAdd the destroy action
destroy.jsx
import { redirect } from "react-router-dom";
import { deleteContact } from "../contacts";
export async function action({ params }) {
await deleteContact(params.contactId);
return redirect("/");
}Add the destroy route to the route config
main.jsx
/* existing code */
import { action as destroyAction } from "./routes/destroy";
const router = createBrowserRouter([
{
path: "/",
/* existing root route props */
children: [
/* existing routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
},
],
},
]);
/* existing code */Alright, navigate to a record and click the "Delete" button. It works!
When the user clicks the submit button:
<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- The
<Form action="destroy">matches the new route at "contacts/:contactId/destroy" and sends it the request - 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.
全局 Errors 捕获
[
/* other routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
errorElement: <div>Oops! There was an error.</div>,
},
];路由首页
// existing code
import Index from "./routes/index";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{ index: true, element: <Index /> },
/* existing routes */
],
},
]);Cancel Button
edit.js
import {
Form,
useLoaderData,
redirect,
useNavigate,
} from "react-router-dom";
export default function EditContact() {
const { contact } = useLoaderData();
const navigate = useNavigate();
return (
<Form method="post" id="contact-form">
{/* existing code */}
<p>
<button type="submit">Save</button>
<button
type="button"
onClick={() => {
navigate(-1);
}}
>
Cancel
</button>
</p>
</Form>
);
}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.
重点是 type="button",表现只是按钮,默认不会触发提交表单。
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=ryanIf we review the search form, it looks like this:
<form id="search-form" role="search"> // [!code hl]
<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>
<Form id="search-form" role="search"> // [!code hl]
<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> 👉 Filter the list if there are URLSearchParams
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return { contacts };
}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
这里有几个问题我们能快速的发现。
- 如果你在搜索过后点击返回,表单的输入框中依然有你 enter 回车时的值,即使这时候你的列表没有被过滤了。
- 如果你在搜索过后刷新页面,表单字段将不会有值,然而列表是被过滤了的。
换句话说,这两个问题的出现原因是 url 和表单状态没有同步。
1
// 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 };
}
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
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"
defaultValue={q}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}2
import { useEffect } from "react";
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);
// existing code
}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.
// existing code
import {
// existing code
useSubmit,
} from "react-router-dom";
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();
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"
defaultValue={q}
onChange={(event) => {
submit(event.currentTarget.form);
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}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
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has(
"q"
);
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
className={searching ? "loading" : ""}
// existing code
/>
<div
id="search-spinner"
aria-hidden
hidden={!searching}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}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
// existing code
export default function Root() {
// existing code
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
// existing code
onChange={(event) => {
const isFirstSearch = q == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}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
contact.jsx
import {
useLoaderData,
Form,
useFetcher,
} from "react-router-dom";
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
return (
<fetcher.Form method="post"> // [!code hl]
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</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
contact.jsx
// existing code
import { getContact, updateContact } from "../contacts";
export async function action({ request, params }) {
let formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
}
export default function Contact() {
// existing code
}Pretty simple. Pull the form data off the request and send it to the data model.
👉 Configure the route's new action
// existing code
import Contact, {
loader as contactLoader,
action as contactAction,
} from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{ index: true, element: <Index /> },
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
/* existing code */
],
},
]);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
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
if (fetcher.formData) {
favorite = fetcher.formData.get("favorite") === "true";
}
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}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
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
export async function loader({ params }) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
return { contact };
}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
createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader,
action: rootAction,
errorElement: <ErrorPage />,
children: [
{
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Index /> },
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
/* the rest of the routes */
],
},
],
},
]);contacts.js
import localforage from "localforage";
import { matchSorter } from "match-sorter";
import sortBy from "sort-by";
export async function getContacts(query) {
await fakeNetwork(`getContacts:${query}`);
let contacts = await localforage.getItem("contacts");
if (!contacts) contacts = [];
if (query) {
contacts = matchSorter(contacts, query, { keys: ["first", "last"] });
}
return contacts.sort(sortBy("last", "createdAt"));
}
export async function createContact() {
await fakeNetwork();
let id = Math.random().toString(36).substring(2, 9);
let contact = { id, createdAt: Date.now() };
let contacts = await getContacts();
contacts.unshift(contact);
await set(contacts);
return contact;
}
export async function getContact(id) {
await fakeNetwork(`contact:${id}`);
let contacts = await localforage.getItem("contacts");
let contact = contacts.find(contact => contact.id === id);
return contact ?? null;
}
export async function updateContact(id, updates) {
await fakeNetwork();
let contacts = await localforage.getItem("contacts");
let contact = contacts.find(contact => contact.id === id);
if (!contact) throw new Error("No contact found for", id);
Object.assign(contact, updates);
await set(contacts);
return contact;
}
export async function deleteContact(id) {
let contacts = await localforage.getItem("contacts");
let index = contacts.findIndex(contact => contact.id === id);
if (index > -1) {
contacts.splice(index, 1);
await set(contacts);
return true;
}
return false;
}
function set(contacts) {
return localforage.setItem("contacts", contacts);
}
// fake a cache so we don't slow down stuff we've already seen
let fakeCache = {};
async function fakeNetwork(key) {
if (!key) {
fakeCache = {};
}
if (fakeCache[key]) {
return;
}
fakeCache[key] = true;
return new Promise(res => {
setTimeout(res, Math.random() * 800);
});
}common.css
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
html,
body {
height: 100%;
margin: 0;
line-height: 1.5;
color: #121212;
}
textarea,
input,
button {
font-size: 1rem;
font-family: inherit;
border: none;
border-radius: 8px;
padding: 0.5rem 0.75rem;
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.2), 0 1px 2px hsla(0, 0%, 0%, 0.2);
background-color: white;
line-height: 1.5;
margin: 0;
}
button {
color: #3992ff;
font-weight: 500;
}
textarea:hover,
input:hover,
button:hover {
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.6), 0 1px 2px hsla(0, 0%, 0%, 0.2);
}
button:active {
box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.4);
transform: translateY(1px);
}
#contact h1 {
display: flex;
align-items: flex-start;
gap: 1rem;
}
#contact h1 form {
display: flex;
align-items: center;
margin-top: 0.25rem;
}
#contact h1 form button {
box-shadow: none;
font-size: 1.5rem;
font-weight: 400;
padding: 0;
}
#contact h1 form button[value="true"] {
color: #a4a4a4;
}
#contact h1 form button[value="true"]:hover,
#contact h1 form button[value="false"] {
color: #eeb004;
}
form[action$="destroy"] button {
color: #f44250;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
#root {
display: flex;
height: 100%;
width: 100%;
}
#sidebar {
width: 22rem;
background-color: #f7f7f7;
border-right: solid 1px #e3e3e3;
display: flex;
flex-direction: column;
}
#sidebar > * {
padding-left: 2rem;
padding-right: 2rem;
}
#sidebar h1 {
font-size: 1rem;
font-weight: 500;
display: flex;
align-items: center;
margin: 0;
padding: 1rem 2rem;
border-top: 1px solid #e3e3e3;
order: 1;
line-height: 1;
}
#sidebar h1::before {
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");
margin-right: 0.5rem;
position: relative;
top: 1px;
}
#sidebar > div {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e3e3e3;
}
#sidebar > div form {
position: relative;
}
#sidebar > div form input[type="search"] {
width: 100%;
padding-left: 2rem;
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");
background-repeat: no-repeat;
background-position: 0.625rem 0.75rem;
background-size: 1rem;
position: relative;
}
#sidebar > div form input[type="search"].loading {
background-image: none;
}
#search-spinner {
width: 1rem;
height: 1rem;
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");
animation: spin 1s infinite linear;
position: absolute;
left: 0.625rem;
top: 0.75rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#sidebar nav {
flex: 1;
overflow: auto;
padding-top: 1rem;
}
#sidebar nav a span {
float: right;
color: #eeb004;
}
#sidebar nav a.active span {
color: inherit;
}
i {
color: #818181;
}
#sidebar nav .active i {
color: inherit;
}
#sidebar ul {
padding: 0;
margin: 0;
list-style: none;
}
#sidebar li {
margin: 0.25rem 0;
}
#sidebar nav a {
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
white-space: pre;
padding: 0.5rem;
border-radius: 8px;
color: inherit;
text-decoration: none;
gap: 1rem;
}
#sidebar nav a:hover {
background: #e3e3e3;
}
#sidebar nav a.active {
background: hsl(224, 98%, 58%);
color: white;
}
#sidebar nav a.pending {
color: hsl(224, 98%, 58%);
}
#detail {
flex: 1;
padding: 2rem 4rem;
width: 100%;
}
#detail.loading {
opacity: 0.25;
transition: opacity 200ms;
transition-delay: 200ms;
}
#contact {
max-width: 40rem;
display: flex;
}
#contact h1 {
font-size: 2rem;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
#contact h1 + p {
margin: 0;
}
#contact h1 + p + p {
white-space: break-spaces;
}
#contact h1:focus {
outline: none;
color: hsl(224, 98%, 58%);
}
#contact a[href*="twitter"] {
display: flex;
font-size: 1.5rem;
color: #3992ff;
text-decoration: none;
}
#contact a[href*="twitter"]:hover {
text-decoration: underline;
}
#contact img {
width: 12rem;
height: 12rem;
background: #c8c8c8;
margin-right: 2rem;
border-radius: 1.5rem;
object-fit: cover;
}
#contact h1 ~ div {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
#contact-form {
display: flex;
max-width: 40rem;
flex-direction: column;
gap: 1rem;
}
#contact-form > p:first-child {
margin: 0;
padding: 0;
}
#contact-form > p:first-child > :nth-child(2) {
margin-right: 1rem;
}
#contact-form > p:first-child,
#contact-form label {
display: flex;
}
#contact-form p:first-child span,
#contact-form label span {
width: 8rem;
}
#contact-form p:first-child input,
#contact-form label input,
#contact-form label textarea {
flex-grow: 2;
}
#contact-form-avatar {
margin-right: 2rem;
}
#contact-form-avatar img {
width: 12rem;
height: 12rem;
background: hsla(0, 0%, 0%, 0.2);
border-radius: 1rem;
}
#contact-form-avatar input {
box-sizing: border-box;
width: 100%;
}
#contact-form p:last-child {
display: flex;
gap: 0.5rem;
margin: 0 0 0 8rem;
}
#contact-form p:last-child button[type="button"] {
color: inherit;
}
#zero-state {
margin: 2rem auto;
text-align: center;
color: #818181;
}
#zero-state a {
color: inherit;
}
#zero-state a:hover {
color: #121212;
}
#zero-state:before {
display: block;
margin-bottom: 0.5rem;
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");
}
#error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}