¥State Management
React 中的状态管理通常涉及在客户端维护服务器数据的同步缓存。然而,Remix 本身就处理数据同步的方式,使得大多数传统的缓存解决方案变得多余。
¥State management in React typically involves maintaining a synchronized cache of server data on the client side. However, with Remix, most of the traditional caching solutions become redundant because of how it inherently handles data synchronization.
¥Understanding State Management in React
在典型的 React 上下文中,当我们提到 "状态管理" 时,我们主要讨论如何将服务器状态与客户端同步。更恰当的术语可能是 "缓存管理",因为服务器是事实来源,而客户端状态主要用作缓存。
¥In a typical React context, when we refer to "state management", we're primarily discussing how we synchronize server state with the client. A more apt term could be "cache management" because the server is the source of truth and the client state is mostly functioning as a cache.
React 中流行的缓存解决方案包括:
¥Popular caching solutions in React include:
Redux:JavaScript 应用的可预测状态容器。
¥Redux: A predictable state container for JavaScript apps.
React 查询:React 中用于获取、缓存和更新异步数据的钩子。
¥React Query: Hooks for fetching, caching, and updating asynchronous data in React.
Apollo:一个全面的 JavaScript 状态管理库,与 GraphQL 集成。
¥Apollo: A comprehensive state management library for JavaScript that integrates with GraphQL.
在某些情况下,使用这些库可能是合理的。不过,由于 Remix 独特的以服务器为中心的方法,它们的实用性变得不那么普遍了。事实上,大多数 Remix 应用完全放弃了它们。
¥In certain scenarios, using these libraries may be warranted. However, with Remix's unique server-focused approach, their utility becomes less prevalent. In fact, most Remix applications forgo them entirely.
¥How Remix Simplifies State
正如 全栈数据流 中所述,Remix 通过加载器、操作和表单等机制无缝地连接了后端和前端,并通过重新验证实现自动同步。这使开发者能够直接在组件内使用服务器状态,而无需管理缓存、网络通信或数据重新验证,从而使大多数客户端缓存变得多余。
¥As discussed in Fullstack Data Flow Remix seamlessly bridges the gap between the backend and frontend via mechanisms like loaders, actions, and forms with automatic synchronization through revalidation. This offers developers the ability to directly use server state within components without managing a cache, the network communication, or data revalidation, making most client-side caching redundant.
以下是为什么在 Remix 中使用典型的 React 状态模式可能是反模式:
¥Here's why using typical React state patterns might be an antipattern in Remix:
网络相关状态:如果你的 React 状态正在管理与网络相关的任何内容(例如来自加载器的数据、待处理的表单提交或导航状态),则很可能你正在管理 Remix 已经管理的状态:
¥Network-related State: If your React state is managing anything related to the network —such as data from loaders, pending form submissions, or navigational states— it's likely that you're managing state that Remix already manages:
useNavigation
:此钩子允许你访问 navigation.state
、navigation.formData
、navigation.location
等。
¥useNavigation
: This hook gives you access to navigation.state
, navigation.formData
, navigation.location
, etc.
useFetcher
:这有利于与 fetcher.state
、fetcher.formData
、fetcher.data
等进行交互。
¥useFetcher
: This facilitates interaction with fetcher.state
, fetcher.formData
, fetcher.data
etc.
useLoaderData
:访问路由数据。
¥useLoaderData
: Access the data for a route.
useActionData
:访问最新操作的数据。
¥useActionData
: Access the data from the latest action.
在 Remix 中存储数据:许多开发者可能倾向于存储在 React 状态中的数据在 Remix 中都有更自然的归宿,例如:
¥Storing Data in Remix: A lot of data that developers might be tempted to store in React state has a more natural home in Remix, such as:
URL 搜索参数:URL 中用于保存状态的参数。
¥URL Search Params: Parameters within the URL that hold state.
Cookies:小块数据存储在用户的设备上。
¥Cookies: Small pieces of data stored on the user's device.
服务器会话:服务器管理的用户会话。
¥Server Sessions: Server-managed user sessions.
服务器缓存:在服务器端缓存数据,以便更快地检索。
¥Server Caches: Cached data on the server side for quicker retrieval.
性能考量:有时,会利用客户端状态来避免重复的数据获取。使用 Remix,你可以在 loader
中使用 Cache-Control
标头,从而利用浏览器的原生缓存。然而,这种方法有其局限性,应谨慎使用。优化后端查询或实现服务器缓存通常更有利。这是因为这样的变化使所有用户受益,并且不再需要单独的浏览器缓存。
¥Performance Considerations: At times, client state is leveraged to avoid redundant data fetching. With Remix, you can use the Cache-Control
headers within loader
s, allowing you to tap into the browser's native cache. However, this approach has its limitations and should be used judiciously. It's usually more beneficial to optimize backend queries or implement a server cache. This is because such changes benefit all users and do away with the need for individual browser caches.
作为一名正在过渡到 Remix 的开发者,认识并接受其固有的效率至关重要,而不是应用传统的 React 模式。Remix 提供了一种简化的状态管理解决方案,从而减少代码量、获取最新数据,并且避免状态同步错误。
¥As a developer transitioning to Remix, it's essential to recognize and embrace its inherent efficiencies rather than applying traditional React patterns. Remix offers a streamlined solution to state management leading to less code, fresh data, and no state synchronization bugs.
¥Examples
¥Network Related State
有关使用 Remix 内部状态管理网络相关状态的示例,请参阅 待处理 UI。
¥For examples on using Remix's internal state to manage network-related state, refer to Pending UI.
¥URL Search Params
考虑一个允许用户在列表视图和详细信息视图之间进行自定义的 UI。你的本能可能是使用 React 状态:
¥Consider a UI that lets the user customize between list view or detail view. Your instinct might be to reach for React state:
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 在用户更改视图时更新。注意状态同步:
¥Now consider you want the URL to update when the user changes the view. Note the state synchronization:
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 中读取和设置状态。
¥Instead of synchronizing state, you can read and set the state in the URL directly with boring old HTML forms.
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>
);
}
¥Persistent UI State
考虑一个可以切换侧边栏可见性的 UI。我们有三种处理状态的方法:
¥Consider a UI that toggles a sidebar's visibility. We have three ways to handle the state:
React 状态
¥React state
浏览器本地存储
¥Browser local storage
Cookies
在本讨论中,我们将分析与每种方法相关的权衡。
¥In this discussion, we'll break down the trade-offs associated with each method.
¥React State
React 状态为临时状态存储提供了一个简单的解决方案。
¥React state provides a simple solution for temporary state storage.
优点:
¥Pros:
简单:易于实现和理解。
¥Simple: Easy to implement and understand.
封装:状态的作用域限定于组件。
¥Encapsulated: State is scoped to the component.
缺点:
¥Cons:
瞬态:页面刷新、稍后返回页面或卸载并重新挂载组件后,组件将无法继续存在。
¥Transient: Doesn't survive page refreshes, returning to the page later, or unmounting and remounting the component.
实现:
¥Implementation:
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
为了在组件生命周期之外保持状态,浏览器本地存储是一个不错的选择。
¥To persist state beyond the component lifecycle, browser local storage is a step-up.
优点:
¥Pros:
持久性:在页面刷新和组件挂载/卸载时保持状态。
¥Persistent: Maintains state across page refreshes and component mounts/unmounts.
封装:状态的作用域限定于组件。
¥Encapsulated: State is scoped to the component.
缺点:
¥Cons:
需要同步:React 组件必须与本地存储同步才能初始化并保存当前状态。
¥Requires Synchronization: React components must sync up with local storage to initialize and save the current state.
服务器渲染限制:window
和 localStorage
对象在服务器端渲染期间无法访问,因此必须在浏览器中使用效果初始化状态。
¥Server Rendering Limitation: The window
and localStorage
objects are not accessible during server-side rendering, so state must be initialized in the browser with an effect.
UI 闪烁:在初始页面加载时,本地存储中的状态可能与服务器渲染的状态不匹配,并且 JavaScript 加载时 UI 会闪烁。
¥UI Flickering: On initial page loads, the state in local storage may not match what was rendered by the server and the UI will flicker when JavaScript loads.
实现:
¥Implementation:
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>
);
}
在这种方法中,状态必须在 effect 中初始化。这对于避免服务器端渲染期间出现复杂情况至关重要。直接从 localStorage
初始化 React 状态会导致错误,因为 window.localStorage
在服务器渲染期间不可用。此外,即使可以访问,它也不会镜像用户浏览器的本地存储。
¥In this approach, state must be initialized within an effect. This is crucial to avoid complications during server-side rendering. Directly initializing the React state from localStorage
will cause errors since window.localStorage
is unavailable during server rendering. Furthermore, even if it were accessible, it wouldn't mirror the user's browser local storage.
function Sidebar() {
const [isOpen, setIsOpen] = React.useState(
// error: window is not defined
window.localStorage.getItem("sidebar")
);
// ...
}
通过在效果中初始化状态,服务器渲染状态和本地存储状态之间可能会出现不匹配的情况。这种差异会导致页面渲染后不久出现短暂的 UI 闪烁,应避免这种情况。
¥By initializing the state within an effect, there's potential for a mismatch between the server-rendered state and the state stored in local storage. This discrepancy will lead to brief UI flickering shortly after the page renders and should be avoided.
Cookie 为此类用例提供了全面的解决方案。不过,此方法需要在组件内访问状态之前进行额外的初步设置。
¥Cookies offer a comprehensive solution for this use case. However, this method introduces added preliminary setup before making the state accessible within the component.
优点:
¥Pros:
服务器渲染:状态在服务器上可用,可用于渲染,甚至用于服务器操作。
¥Server Rendering: State is available on the server for rendering and even for server actions.
单一事实来源:消除状态同步的麻烦。
¥Single Source of Truth: Eliminates state synchronization hassles.
持久性:在页面加载和组件挂载/卸载时保持状态。即使切换到数据库支持的会话,状态甚至可以跨设备保留。
¥Persistence: Maintains state across page loads and component mounts/unmounts. State can even persist across devices if you switch to a database-backed session.
渐进式增强:甚至在 JavaScript 加载之前就已经运行函数了。
¥Progressive Enhancement: Functions even before JavaScript loads.
缺点:
¥Cons:
样板代码:由于网络原因,需要更多代码。
¥Boilerplate: Requires more code because of the network.
公开:状态未封装到单个组件中,应用的其他部分必须感知 cookie。
¥Exposed: The state is not encapsulated to a single component, other parts of the app must be aware of the cookie.
实现:
¥Implementation:
首先,我们需要创建一个 cookie 对象:
¥First, we'll need to create a cookie object:
import { createCookie } from "@remix-run/node";
export const prefs = createCookie("prefs");
接下来,我们设置服务器操作和加载器来读取和写入 Cookie:
¥Next we set up the server action and loader to read and write the 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 状态:
¥After the server code is set up, we can use the cookie state in our UI:
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>
);
}
虽然这肯定会涉及更多应用的代码以处理网络请求和响应,但用户体验得到了极大的改善。此外,状态来自单一真实来源,无需任何状态同步。
¥While this is certainly more code that touches more of the application to account for the network requests and responses, the UX is greatly improved. Additionally, state comes from a single source of truth without any state synchronization required.
总而言之,上面讨论的每种方法都提供了一系列独特的优势和挑战:
¥In summary, each of the discussed methods offers a unique set of benefits and challenges:
React 状态:提供简单但短暂的状态管理。
¥React state: Offers simple but transient state management.
本地存储:提供持久性,但需要同步,并且会导致 UI 闪烁。
¥Local Storage: Provides persistence but with synchronization requirements and UI flickering.
Cookies:以增加样板代码为代价,提供强大、持久的状态管理。
¥Cookies: Delivers robust, persistent state management at the cost of added boilerplate.
这些都没错,但如果你希望在访问期间保持状态,Cookie 能提供最佳的用户体验。
¥None of these are wrong, but if you want to persist the state across visits, cookies offer the best user experience.
¥Form Validation and Action Data
客户端验证可以增强用户体验,但类似的增强功能可以通过更多地倾向于服务器端处理并让其处理复杂性来实现。
¥Client-side validation can augment the user experience, but similar enhancements can be achieved by leaning more towards server-side processing and letting it handle the complexities.
以下示例说明了管理网络状态、协调服务器状态以及在客户端和服务器端冗余实现验证的固有复杂性。它仅用于说明,因此如果你发现任何明显的错误或问题,请谅解。
¥The following example illustrates the inherent complexities of managing network state, coordinating state from the server, and implementing validation redundantly on both the client and server sides. It's just for illustration, so forgive any obvious bugs or problems you find.
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
也执行验证并发送错误反馈。请注意,一些必要的验证(例如检测重复的用户名)只能在服务器端使用客户端无法访问的信息进行。
¥The backend endpoint, /api/signup
, also performs validation and sends error feedback. Note that some essential validation, like detecting duplicate usernames, can only be done server-side using information the client doesn't have access to.
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 固有管理的网络状态,组件得到了极大简化。
¥Now, let's contrast this with a Remix-based implementation. The action remains consistent, but the component is vastly simplified due to the direct utilization of server state via useActionData
, and leveraging the network state that Remix inherently manages.
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 状态、change 事件监听器、提交处理程序和状态管理库的必要性。
¥The extensive state management from our previous example is distilled into just three code lines. We eliminate the necessity for React state, change event listeners, submit handlers, and state management libraries for such network interactions.
可以通过 useActionData
直接访问服务器状态,通过 useNavigation
(或 useFetcher
)访问网络状态。
¥Direct access to the server state is made possible through useActionData
, and network state through useNavigation
(or useFetcher
).
作为一个额外的小技巧,表单甚至在 JavaScript 加载之前就可以使用。Remix 不再管理网络操作,而是使用默认浏览器行为。
¥As a bonus party trick, the form is functional even before JavaScript loads. Instead of Remix managing the network operations, the default browser behaviors step in.
如果你发现自己陷入了管理和同步网络操作状态的困境,Remix 可能会提供更优雅的解决方案。
¥If you ever find yourself entangled in managing and synchronizing state for network operations, Remix likely offers a more elegant solution.