React 中的状态管理通常涉及在客户端维护服务器数据的同步缓存。然而,当使用 React Router 作为您的框架时,由于其固有的数据同步处理方式,大多数传统的缓存解决方案变得多余。
在典型的 React 上下文中,当我们提到“状态管理”时,我们主要讨论的是如何将服务器状态与客户端同步。更合适的术语可能是“缓存管理”,因为服务器是数据的单一真相来源,而客户端状态主要充当缓存。
React 中流行的缓存解决方案包括:
在某些场景下,使用这些库可能是合理的。然而,鉴于 React Router 独特的以服务器为中心的方法,它们的作用变得不那么普遍。事实上,大多数 React Router 应用完全放弃了它们。
React Router 通过加载器 (loaders)、动作 (actions) 和表单 (forms) 等机制以及通过 revalidation 进行自动同步,无缝地连接了后端和前端。这使得开发者可以直接在组件中使用服务器状态,而无需管理缓存、网络通信或数据重新验证,从而使大多数客户端缓存变得多余。
以下是为什么在 React Router 中使用典型的 React 状态模式可能是反模式的原因:
网络相关状态: 如果您的 React 状态管理着任何与网络相关的东西——例如来自加载器的数据、待处理的表单提交或导航状态——那么您很可能正在管理 React Router 已经管理的状态。
useNavigation
: 这个 Hook 让您可以访问 navigation.state
、navigation.formData
、navigation.location
等。useFetcher
: 这有助于与 fetcher.state
、fetcher.formData
、fetcher.data
等进行交互。loaderData
: 访问路由的数据。actionData
: 访问最新动作的数据。在 React Router 中存储数据: 许多开发者可能倾向于存储在 React 状态中的数据,在 React Router 中有更自然的归宿,例如:
性能考虑: 有时,客户端状态被用于避免冗余的数据获取。使用 React Router,您可以在 loader
中使用 Cache-Control
头部,让您利用浏览器的原生缓存。然而,这种方法有其局限性,应谨慎使用。通常来说,优化后端查询或实现服务器缓存更为有益。这是因为这些更改能惠及所有用户,并省去了对个体浏览器缓存的需求。
作为过渡到 React Router 的开发者,认识并采纳其固有的效率而非应用传统的 React 模式至关重要。React Router 提供了一种精简的状态管理解决方案,带来更少的代码、更新的数据以及无状态同步错误。
有关使用 React Router 内部状态管理网络相关状态的示例,请参阅待处理 UI。
考虑一个允许用户自定义列表视图或详情视图的 UI。您的本能反应可能是使用 React 状态。
export function List() {
const [view, setView] = useState("list");
return (
<div>
<div>
<button onClick={() => setView("list")}>
View as List
</button>
<button onClick={() => setView("details")}>
View with Details
</button>
</div>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
现在考虑您希望用户更改视图时 URL 也随之更新。请注意这里的状态同步:
import { useNavigate, useSearchParams } from "react-router";
export function List() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [view, setView] = useState(
searchParams.get("view") || "list"
);
return (
<div>
<div>
<button
onClick={() => {
setView("list");
navigate(`?view=list`);
}}
>
View as List
</button>
<button
onClick={() => {
setView("details");
navigate(`?view=details`);
}}
>
View with Details
</button>
</div>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
您无需同步状态,而是可以直接使用无趣的旧式 HTML 表单直接在 URL 中读取和设置状态:
import { Form, useSearchParams } from "react-router";
export function List() {
const [searchParams] = useSearchParams();
const view = searchParams.get("view") || "list";
return (
<div>
<Form>
<button name="view" value="list">
View as List
</button>
<button name="view" value="details">
View with Details
</button>
</Form>
{view === "list" ? <ListView /> : <DetailView />}
</div>
);
}
考虑一个用于切换侧边栏可见性的 UI。我们有三种方法来处理状态:
在本次讨论中,我们将分析每种方法相关的权衡。
React 状态为临时状态存储提供了简单的解决方案。
优点:
缺点:
实现:
function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? "Close" : "Open"}
</button>
<aside hidden={!isOpen}>
<Outlet />
</aside>
</div>
);
}
要将状态持久化超出组件生命周期,浏览器 Local Storage 是一个进阶选择。请参阅我们的客户端数据文档以获取更高级的示例。
优点:
缺点:
window
和 localStorage
对象是不可访问的,因此必须在浏览器中使用 effect 初始化状态。实现:
function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
// synchronize initially
useLayoutEffect(() => {
const isOpen = window.localStorage.getItem("sidebar");
setIsOpen(isOpen);
}, []);
// synchronize on change
useEffect(() => {
window.localStorage.setItem("sidebar", isOpen);
}, [isOpen]);
return (
<div>
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? "Close" : "Open"}
</button>
<aside hidden={!isOpen}>
<Outlet />
</aside>
</div>
);
}
在这种方法中,状态必须在 effect 中初始化。这对于避免服务器端渲染期间的复杂性至关重要。直接从 localStorage
初始化 React 状态会导致错误,因为 window.localStorage
在服务器渲染期间不可用。
function Sidebar() {
const [isOpen, setIsOpen] = useState(
// error: window is not defined
window.localStorage.getItem("sidebar")
);
// ...
}
通过在 effect 中初始化状态,服务器渲染的状态与存储在 Local Storage 中的状态之间可能存在不匹配。这种差异将导致页面渲染后不久出现短暂的 UI 闪烁,应避免这种情况。
Cookies 为此用例提供了一个全面的解决方案。然而,这种方法在使状态在组件中可访问之前,引入了额外的初步设置。
优点:
缺点:
实现:
首先,我们需要创建一个 cookie 对象:
import { createCookie } from "react-router";
export const prefs = createCookie("prefs");
接下来,我们设置服务器动作和加载器来读取和写入 cookie:
import { data, Outlet } from "react-router";
import type { Route } from "./+types/sidebar";
import { prefs } from "./prefs-cookie";
// read the state from the cookie
export async function loader({
request,
}: Route.LoaderArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie = (await prefs.parse(cookieHeader)) || {};
return data({ sidebarIsOpen: cookie.sidebarIsOpen });
}
// write the state to the cookie
export async function action({
request,
}: Route.ActionArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie = (await prefs.parse(cookieHeader)) || {};
const formData = await request.formData();
const isOpen = formData.get("sidebar") === "open";
cookie.sidebarIsOpen = isOpen;
return data(isOpen, {
headers: {
"Set-Cookie": await prefs.serialize(cookie),
},
});
}
服务器代码设置完成后,我们可以在 UI 中使用 cookie 状态:
function Sidebar({ loaderData }: Route.ComponentProps) {
const fetcher = useFetcher();
let { sidebarIsOpen } = loaderData;
// use optimistic UI to immediately change the UI state
if (fetcher.formData?.has("sidebar")) {
sidebarIsOpen =
fetcher.formData.get("sidebar") === "open";
}
return (
<div>
<fetcher.Form method="post">
<button
name="sidebar"
value={sidebarIsOpen ? "closed" : "open"}
>
{sidebarIsOpen ? "Close" : "Open"}
</button>
</fetcher.Form>
<aside hidden={!sidebarIsOpen}>
<Outlet />
</aside>
</div>
);
}
虽然这确实需要更多代码来处理网络请求和响应,并触及应用程序的更多部分,但用户体验得到了极大的改善。此外,状态来自单一的真相来源,无需任何状态同步。
总而言之,所讨论的每种方法都提供了一系列独特的优点和挑战:
这些方法都没有错,但如果您希望状态在用户访问之间持久化,Cookies 提供了最佳的用户体验。
客户端验证可以增强用户体验,但通过更多地倾向于服务器端处理并让它处理复杂性,可以实现类似的增强。
以下示例展示了管理网络状态、协调来自服务器的状态以及在客户端和服务器端冗余实现验证的固有复杂性。这仅用于说明,因此请原谅您发现的任何明显错误或问题。
export function Signup() {
// A multitude of React State declarations
const [isSubmitting, setIsSubmitting] = useState(false);
const [userName, setUserName] = useState("");
const [userNameError, setUserNameError] = useState(null);
const [password, setPassword] = useState(null);
const [passwordError, setPasswordError] = useState("");
// Replicating server-side logic in the client
function validateForm() {
setUserNameError(null);
setPasswordError(null);
const errors = validateSignupForm(userName, password);
if (errors) {
if (errors.userName) {
setUserNameError(errors.userName);
}
if (errors.password) {
setPasswordError(errors.password);
}
}
return Boolean(errors);
}
// Manual network interaction handling
async function handleSubmit() {
if (validateForm()) {
setSubmitting(true);
const res = await postJSON("/api/signup", {
userName,
password,
});
const json = await res.json();
setIsSubmitting(false);
// Server state synchronization to the client
if (json.errors) {
if (json.errors.userName) {
setUserNameError(json.errors.userName);
}
if (json.errors.password) {
setPasswordError(json.errors.password);
}
}
}
}
return (
<form
onSubmit={(event) => {
event.preventDefault();
handleSubmit();
}}
>
<p>
<input
type="text"
name="username"
value={userName}
onChange={() => {
// Synchronizing form state for the fetch
setUserName(event.target.value);
}}
/>
{userNameError ? <i>{userNameError}</i> : null}
</p>
<p>
<input
type="password"
name="password"
onChange={(event) => {
// Synchronizing form state for the fetch
setPassword(event.target.value);
}}
/>
{passwordError ? <i>{passwordError}</i> : null}
</p>
<button disabled={isSubmitting} type="submit">
Sign Up
</button>
{isSubmitting ? <BusyIndicator /> : null}
</form>
);
}
后端端点 /api/signup
也执行验证并发送错误反馈。请注意,一些必要的验证,例如检测重复用户名,只能在服务器端完成,因为客户端无法访问这些信息。
export async function signupHandler(request: Request) {
const errors = await validateSignupRequest(request);
if (errors) {
return { ok: false, errors: errors };
}
await signupUser(request);
return { ok: true, errors: null };
}
现在,让我们将此与基于 React Router 的实现进行对比。动作保持一致,但由于直接利用 actionData
的服务器状态,以及利用 React Router 固有的网络状态管理,组件大大简化。
import { useNavigation } from "react-router";
import type { Route } from "./+types/signup";
export async function action({
request,
}: ActionFunctionArgs) {
const errors = await validateSignupRequest(request);
if (errors) {
return { ok: false, errors: errors };
}
await signupUser(request);
return { ok: true, errors: null };
}
export function Signup({
actionData,
}: Route.ComponentProps) {
const navigation = useNavigation();
const userNameError = actionData?.errors?.userName;
const passwordError = actionData?.errors?.password;
const isSubmitting = navigation.formAction === "/signup";
return (
<Form method="post">
<p>
<input type="text" name="username" />
{userNameError ? <i>{userNameError}</i> : null}
</p>
<p>
<input type="password" name="password" />
{passwordError ? <i>{passwordError}</i> : null}
</p>
<button disabled={isSubmitting} type="submit">
Sign Up
</button>
{isSubmitting ? <BusyIndicator /> : null}
</Form>
);
}
我们之前示例中广泛的状态管理被精简到仅仅三行代码。对于此类网络交互,我们无需使用 React 状态、变更事件监听器、提交处理程序和状态管理库。
通过 actionData
可以直接访问服务器状态,通过 useNavigation
(或 useFetcher
)可以访问网络状态。
作为一个额外的亮点,即使在 JavaScript 加载之前,表单也是可用的(参见渐进增强)。默认的浏览器行为会介入,而不是 React Router 管理网络操作。
如果您发现自己陷入管理和同步网络操作状态的困境,React Router 很可能提供了一个更优雅的解决方案。