React Router v7 已发布。 查看文档
状态管理
本页内容

状态管理

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

理解 React 中的状态管理

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

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

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

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

Remix 如何简化状态

正如在全栈数据流中所讨论的,Remix 通过诸如加载器、actions 和表单等机制无缝地桥接了后端和前端,并通过重新验证实现自动同步。这使开发人员能够在组件中直接使用服务器状态,而无需管理缓存、网络通信或数据重新验证,从而使大多数客户端缓存变得多余。

以下是在 Remix 中使用典型的 React 状态模式可能是一种反模式的原因

  1. 网络相关状态: 如果您的 React 状态正在管理任何与网络相关的内容,例如来自加载器的数据、待处理的表单提交或导航状态,那么您可能正在管理 Remix 已经管理的状态

    • useNavigation:此 Hook 使您可以访问 navigation.statenavigation.formDatanavigation.location 等。
    • useFetcher:这有助于与 fetcher.statefetcher.formDatafetcher.data 等进行交互。
    • useLoaderData:访问路由的数据。
    • useActionData:访问来自最新 action 的数据。
  2. 在 Remix 中存储数据: 许多开发人员可能倾向于存储在 React 状态中的数据在 Remix 中有一个更自然的位置,例如

    • URL 查询参数: URL 中包含状态的参数。
    • Cookies: 存储在用户设备上的小块数据。
    • 服务器会话: 服务器管理的用户会话。
    • 服务器缓存: 服务器端缓存的数据,以便更快地检索。
  3. 性能考虑: 有时,利用客户端状态来避免冗余的数据获取。使用 Remix,您可以使用 Cache-Control 标头在 loader 中,从而利用浏览器的原生缓存。但是,这种方法有其局限性,应谨慎使用。优化后端查询或实现服务器缓存通常更有益。这是因为此类更改使所有用户受益,并消除了对各个浏览器缓存的需求。

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

示例

有关使用 Remix 的内部状态来管理网络相关状态的示例,请参阅待定 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 状态

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

优点:

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

缺点:

  • 瞬态:在页面刷新、稍后返回页面或卸载和重新挂载组件后不会保留。

实现:

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>
  );
}

本地存储

要使状态在组件生命周期之外持久化,浏览器本地存储是更进一步的选择。

优点:

  • 持久化:跨页面刷新和组件挂载/卸载维护状态。
  • 封装:状态的作用域限定为组件。

缺点:

  • 需要同步:React 组件必须与本地存储同步以初始化和保存当前状态。
  • 服务器渲染限制:在服务器端渲染期间无法访问 windowlocalStorage 对象,因此必须使用 effect 在浏览器中初始化状态。
  • 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>
  );
}

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

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

  // ...
}

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

Cookies

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>
  );
}

虽然这确实需要更多代码来处理更多的应用程序以考虑网络请求和响应,但 UX 得到了极大的改进。此外,状态来自单一数据源,无需任何状态同步。

总之,讨论的每种方法都提供了一组独特的优点和挑战

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

这些方法都没有错,但如果您希望在访问之间保持状态,那么 cookies 提供了最佳的用户体验。

表单验证和操作数据

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

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

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 固有的网络状态,组件得到了极大的简化。

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 可能会提供更优雅的解决方案。

文档和示例在以下许可下发布 MIT