コンテンツにスキップ

State Management

このコンテンツはまだ日本語訳がありません。

状态管理

React 中的状态管理通常涉及在客户端维护服务器数据的同步缓存。然而,有了 Remix,大多数传统的缓存解决方案都变得多余,因为它本身就处理数据同步的方式。

理解 React 中的状态管理

在典型的 React 上下文中,当我们提到状态管理时,我们主要讨论如何将服务器状态与客户端同步。更恰当的术语可能是缓存管理,因为服务器是事实来源,而客户端状态主要用作缓存。

React 中流行的缓存解决方案包括:

  • Redux:JavaScript 应用程序的可预测状态容器。
  • React Query:用于在 React 中获取、缓存和更新异步数据的钩子。
  • Apollo:与 GraphQL 集成的综合 JavaScript 状态管理库。

在某些情况下,使用这些库可能是合理的。然而,由于 Remix 独特的以服务器为中心的方法,它们的实用性变得不那么普遍了。事实上,大多数 Remix 应用程序完全放弃了它们。

Remix 如何简化状态

正如 Fullstack Data Flow 中所述,Remix 通过加载器、操作和表单等机制无缝地弥合了后端和前端之间的鸿沟,并通过重新验证自动同步。这使开发人员能够直接在组件中使用服务器状态,而无需管理缓存、网络通信或数据重新验证,从而使大多数客户端缓存变得多余。

这就是为什么使用典型的 React 状态模式在 Remix 中可能是反模式的原因:

  1. 与网络相关的状态:如果你的 React 状态正在管理与网络相关的任何内容(例如来自加载器的数据、待处理的表单提交或导航状态),则很可能你正在管理 Remix 已经管理的状态:
  • useNavigation:此钩子允许您访问 navigation.statenavigation.formDatanavigation.location 等。
  • useFetcher:这有利于与 fetcher.statefetcher.formDatafetcher.data 等进行交互。
  • useLoaderData:访问路由的数据。
  • useActionData:访问最新操作的数据。
  1. 在 Remix 中存储数据:很多开发人员可能倾向于在 React 状态中存储的数据在 Remix 中有更自然的存放位置,例如:
  • URL 搜索参数:URL 中保存状态的参数。
  • Cookies:存储在用户设备上的小块数据。
  • 服务器会话:服务器管理的用户会话。
  • 服务器缓存:服务器端的缓存数据,以便更快地检索。
  1. 性能注意事项:有时,会利用客户端状态来避免冗余数据提取。使用 Remix,您可以在 loader 中使用 Cache-Control 标头,从而利用浏览器的本机缓存。但是,这种方法有其局限性,应谨慎使用。优化后端查询或实现服务器缓存通常更有益。这是因为此类更改有益于所有用户,并且无需单独设置浏览器缓存。

作为过渡到 Remix 的开发人员,必须认识到并接受其固有的效率,而不是应用传统的 React 模式。Remix 为状态管理提供了一种简化的解决方案,从而减少了代码量、更新了数据,并且没有状态同步错误。

示例

网络相关状态

有关使用 Remix 的内部状态来管理网络相关状态的示例,请参阅Pending UI

URL 搜索参数

考虑一个允许用户在列表视图或详细信息视图之间进行自定义的 UI。你的直觉可能是使用 React 状态:

export function List() {
const [view, setView] = React.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 "@remix-run/react";
export function List() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [view, setView] = React.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 "@remix-run/react";
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 状态

考虑一个切换侧边栏可见性的 UI。我们有三种方法来处理状态:

  1. React 状态
  2. 浏览器本地存储
  3. Cookies

在本次讨论中,我们将分析每种方法的利弊。

反应状态

React state 为临时状态存储提供了一个简单的解决方案。

优点

  • 简单:易于实现和理解。
  • 封装:状态作用域限定于组件。

缺点

  • 暂时:无法在页面刷新、稍后返回该页面或卸载并重新安装该组件后继续存在。

执行

function Sidebar({ children }) {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<button onClick={() => setIsOpen((open) => !open)}>
{isOpen ? "Close" : "Open"}
</button>
<aside hidden={!isOpen}>{children}</aside>
</div>
);
}

Local Storage

为了在组件生命周期之外保持状态,浏览器本地存储是一个进步。

优点

  • 持久:在页面刷新和组件挂载 / 卸载时保持状态。
  • 封装:状态范围限定于组件。

缺点

  • 需要同步:React 组件必须与本地存储同步才能初始化并保存当前状态。
  • 服务器渲染限制windowlocalStorage 对象在服务器端渲染期间无法访问,因此必须在浏览器中使用效果初始化状态。
  • UI 闪烁:在初始页面加载时,本地存储中的状态可能与服务器渲染的状态不匹配,并且 JavaScript 加载时 UI 会闪烁。

执行

function Sidebar({ children }) {
const [isOpen, setIsOpen] = React.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}>{children}</aside>
</div>
);
}

在这种方法中,必须在效果内初始化状态。这对于避免服务器端渲染期间的复杂性至关重要。直接从 localStorage 初始化 React 状态会导致错误,因为 window.localStorage 在服务器渲染期间不可用。此外,即使可以访问,它也不会镜像用户的浏览器本地存储。

function Sidebar() {
const [isOpen, setIsOpen] = React.useState(
// error: window is not defined
window.localStorage.getItem("sidebar")
);
// ...
}

通过在效果内初始化状态,服务器呈现的状态和本地存储中存储的状态可能会不匹配。这种差异会导致页面呈现后不久出现短暂的 UI 闪烁,应避免这种情况。

饼干

Cookies 为这种用例提供了全面的解决方案。但是,这种方法在使状态在组件内可访问之前引入了额外的初步设置。

优点

  • 服务器渲染:状态可在服务器上进行渲染,甚至可用于服务器操作。
  • 单一事实来源:消除状态同步麻烦。
  • 持久性:在页面加载和组件安装 / 卸载期间保持状态。如果切换到数据库支持的会话,状态甚至可以跨设备持久保存。
  • 渐进式增强:甚至在 JavaScript 加载之前即可运行。

缺点

  • 样板:由于网络原因,需要更多代码。
  • 暴露:状态未封装到单个组件,应用程序的其他部分必须知道 cookie。

执行

首先我们需要创建一个 cookie 对象:

import { createCookie } from "@remix-run/node";
export const prefs = createCookie("prefs");

接下来我们设置服务器操作和加载器来读取和写入 cookie:

import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { prefs } from "./prefs-cookie";
// read the state from the cookie
export async function loader({
request,
}: LoaderFunctionArgs) {
const cookieHeader = request.headers.get("Cookie");
const cookie = (await prefs.parse(cookieHeader)) || {};
return json({ sidebarIsOpen: cookie.sidebarIsOpen });
}
// write the state to the cookie
export async function action({
request,
}: ActionFunctionArgs) {
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 json(isOpen, {
headers: {
"Set-Cookie": await prefs.serialize(cookie),
},
});
}

服务器代码设置完成后,我们可以在 UI 中使用 cookie 状态:

function Sidebar({ children }) {
const fetcher = useFetcher();
let { sidebarIsOpen } = useLoaderData<typeof loader>();
// 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}>{children}</aside>
</div>
);
}

虽然这肯定会涉及更多应用程序的代码来处理网络请求和响应,但用户体验得到了极大改善。此外,状态来自单一事实来源,无需任何状态同步。

总而言之,所讨论的每种方法都有其独特的优势和挑战:

  • React 状态:提供简单但短暂的状态管理。
  • 本地存储:提供持久性,但需要同步且 UI 闪烁。
  • Cookies:以增加样板代码为代价,提供强大、持久的状态管理。

这些都没有错,但是如果您想在访问期间保持状态,cookie 可以提供最佳的用户体验。

表单验证和操作数据

客户端验证可以增强用户体验,但通过更多地倾向于服务器端处理并让其处理复杂性也可以实现类似的增强。

以下示例说明了管理网络状态、从服务器协调状态以及在客户端和服务器端冗余地实现验证的固有复杂性。这只是为了说明,所以请原谅您发现的任何明显的错误或问题。

export function Signup() {
// A multitude of React State declarations
const [isSubmitting, setIsSubmitting] =
React.useState(false);
const [userName, setUserName] = React.useState("");
const [userNameError, setUserNameError] =
React.useState(null);
const [password, setPassword] = React.useState(null);
const [passwordError, setPasswordError] =
React.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 json({ ok: false, errors: errors });
}
await signupUser(request);
return json({ ok: true, errors: null });
}

现在,让我们将其与基于 Remix 的实现进行对比。操作保持一致,但由于通过 useActionData 直接利用服务器状态,并利用 Remix 固有管理的网络状态,组件得到了极大简化。

app/routes/signup.tsx
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import {
useActionData,
useNavigation,
} from "@remix-run/react";
export async function action({
request,
}: ActionFunctionArgs) {
const errors = await validateSignupRequest(request);
if (errors) {
return json({ ok: false, errors: errors });
}
await signupUser(request);
return json({ ok: true, errors: null });
}
export function Signup() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
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 状态、更改事件监听器、提交处理程序和状态管理库的必要性。

通过 useActionData 可以直接访问服务器状态,通过 useNavigation(或 useFetcher)可以直接访问网络状态。

还有一个额外的技巧,表单在 JavaScript 加载之前就已经可以正常工作。网络操作不再由 Remix 管理,而是由默认浏览器行为来执行。

如果您发现自己陷入了管理和同步网络操作状态的困境,Remix 可能会提供更优雅的解决方案。