Form 与 fetcher
本页内容

Form 与 fetcher

概述

在 React Router 中开发时,会提供一套丰富的工具,这些工具的功能有时会重叠,给新手带来一种模糊感。在 React Router 中进行高效开发的关键是理解每种工具的细微差别和适当的用例。本文档旨在阐明何时以及为何使用特定的 API。

重点 API

理解这些 API 的区别和交集对于高效、有效地进行 React Router 开发至关重要。

URL 的考量

在这些工具之间进行选择时的主要标准是,您是否希望 URL 发生变化。

  • 希望 URL 改变:在页面之间导航或转换时,或者在某些操作(如创建或删除记录)之后。这确保了用户的浏览器历史记录能准确反映他们在您应用程序中的浏览路径。

    • 预期行为:在许多情况下,当用户点击后退按钮时,他们应该被带到前一个页面。其他时候,历史记录条目可能会被替换,但 URL 的变化仍然很重要。
  • 不希望 URL 改变:对于那些不会显著改变当前视图上下文或主要内容的操作。这可能包括更新单个字段或轻微的数据操作,这些操作不值得拥有一个新的 URL 或页面重载。这也适用于使用 fetcher 加载数据的情况,例如用于弹出框、组合框等。

何时应该更改 URL

这些操作通常反映了用户上下文或状态的重大变化。

  • 创建新记录:创建新记录后,通常会将用户重定向到专门用于该新记录的页面,在那里他们可以查看或进一步修改它。

  • 删除记录:如果用户在专门用于特定记录的页面上决定删除它,逻辑上的下一步是将他们重定向到一个通用页面,例如所有记录的列表。

对于这些情况,开发者应考虑结合使用 <Form>useNavigation。这些工具可以协调处理表单提交、调用特定 action、通过组件 props 检索与 action 相关的数据,并分别管理导航。

何时不应该更改 URL

这些操作通常更微妙,不需要用户切换上下文。

  • 更新单个字段:也许用户想要更改列表中某个项目的名称或更新记录的特定属性。这个操作很小,不需要新的页面或 URL。

  • 从列表中删除记录:在列表视图中,如果用户删除一个项目,他们很可能希望留在列表视图中,只是该项目不再显示在列表中。

  • 在列表视图中创建记录:当向列表中添加新项目时,用户通常希望留在该上下文中,看到他们的新项目被添加到列表中,而无需进行完整的页面转换。

  • 为弹出框或组合框加载数据:当为弹出框或组合框加载数据时,用户的上下文保持不变。数据在后台加载并显示在一个小型的、自包含的 UI 元素中。

对于此类操作,useFetcher 是首选 API。它功能多样,结合了这些 API 的功能,非常适合 URL 应保持不变的任务。

API 比较

如您所见,这两组 API 有很多相似之处

导航/URL API Fetcher API
<Form> <fetcher.Form>
actionData (组件 prop) fetcher.data
navigation.state fetcher.state
navigation.formAction fetcher.formAction
navigation.formData fetcher.formData

示例

创建新记录

import {
  Form,
  redirect,
  useNavigation,
} from "react-router";
import type { Route } from "./+types/new-recipe";

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();
  const errors = await validateRecipeFormData(formData);
  if (errors) {
    return { errors };
  }
  const recipe = await db.recipes.create(formData);
  return redirect(`/recipes/${recipe.id}`);
}

export function NewRecipe({
  actionData,
}: Route.ComponentProps) {
  const { errors } = actionData || {};
  const navigation = useNavigation();
  const isSubmitting =
    navigation.formAction === "/recipes/new";

  return (
    <Form method="post">
      <label>
        Title: <input name="title" />
        {errors?.title ? <span>{errors.title}</span> : null}
      </label>
      <label>
        Ingredients: <textarea name="ingredients" />
        {errors?.ingredients ? (
          <span>{errors.ingredients}</span>
        ) : null}
      </label>
      <label>
        Directions: <textarea name="directions" />
        {errors?.directions ? (
          <span>{errors.directions}</span>
        ) : null}
      </label>
      <button type="submit">
        {isSubmitting ? "Saving..." : "Create Recipe"}
      </button>
    </Form>
  );
}

该示例利用 <Form>、组件 props 和 useNavigation 来促进直观的记录创建过程。

使用 <Form> 确保了直接且合乎逻辑的导航。创建记录后,用户会自然地被引导到新食谱的唯一 URL,这加强了他们操作的结果。

组件 props 连接了服务器和客户端,提供了关于提交问题的即时反馈。这种快速响应使用户能够无障碍地纠正任何错误。

最后,useNavigation 动态地反映了表单的提交状态。这种微妙的 UI 变化,比如切换按钮的标签,向用户保证他们的操作正在被处理。

综合来看,这些 API 提供了结构化导航和反馈的均衡组合。

更新记录

现在考虑我们正在查看一个食谱列表,每个项目上都有删除按钮。当用户点击删除按钮时,我们希望从数据库中删除该食谱并将其从列表中移除,而无需离开列表页面。

首先,考虑获取页面上食谱列表的基本路由设置。

import type { Route } from "./+types/recipes";

export async function loader({
  request,
}: Route.LoaderArgs) {
  return {
    recipes: await db.recipes.findAll({ limit: 30 }),
  };
}

export default function Recipes({
  loaderData,
}: Route.ComponentProps) {
  const { recipes } = loaderData;
  return (
    <ul>
      {recipes.map((recipe) => (
        <RecipeListItem key={recipe.id} recipe={recipe} />
      ))}
    </ul>
  );
}

现在我们来看看删除食谱的 action 以及渲染列表中每个食谱的组件。

import { useFetcher } from "react-router";
import type { Recipe } from "./recipe.server";
import type { Route } from "./+types/recipes";

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();
  const id = formData.get("id");
  await db.recipes.delete(id);
  return { ok: true };
}

export default function Recipes() {
  return (
    // ...
    // doesn't matter, somewhere it's using <RecipeListItem />
  )
}

function RecipeListItem({ recipe }: { recipe: Recipe }) {
  const fetcher = useFetcher();
  const isDeleting = fetcher.state !== "idle";

  return (
    <li>
      <h2>{recipe.title}</h2>
      <fetcher.Form method="post">
        <input type="hidden" name="id" value={recipe.id} />
        <button disabled={isDeleting} type="submit">
          {isDeleting ? "Deleting..." : "Delete"}
        </button>
      </fetcher.Form>
    </li>
  );
}

在这种情况下使用 useFetcher 非常完美。我们想要的是原地更新,而不是导航离开或刷新整个页面。当用户删除一个食谱时,action 被调用,fetcher 管理相应的状态转换。

这里的关键优势是保持了上下文。删除完成后,用户仍停留在列表页面。我们利用 fetcher 的状态管理能力来提供实时反馈:它在“正在删除...”和“删除”之间切换,清晰地指示了正在进行的过程。

此外,由于每个 fetcher 都可以自主管理自己的状态,对单个列表项的操作变得独立,确保对一个项的操作不会影响其他项(尽管页面数据的重新验证是一个共同关注的问题,在 网络并发管理 中有涉及)。

本质上,useFetcher 为那些不需要更改 URL 或导航的操作提供了一个无缝的机制,通过提供实时反馈和上下文保留来增强用户体验。

将文章标记为已读

想象一下,当用户在页面上停留了一段时间并滚动到底部后,您想标记该文章已被当前用户阅读。您可以创建一个像下面这样的 hook:

import { useFetcher } from "react-router";

function useMarkAsRead({ articleId, userId }) {
  const marker = useFetcher();

  useSpentSomeTimeHereAndScrolledToTheBottom(() => {
    marker.submit(
      { userId },
      {
        action: `/article/${articleId}/mark-as-read`,
        method: "post",
      },
    );
  });
}

用户头像详情弹出窗口

每当您显示用户头像时,都可以添加一个悬停效果,从 loader 中获取数据并在弹出窗口中显示。

import { useState, useEffect } from "react";
import { useFetcher } from "react-router";
import type { Route } from "./+types/user-details";

export async function loader({ params }: Route.LoaderArgs) {
  return await fakeDb.user.find({
    where: { id: params.id },
  });
}

type LoaderData = Route.ComponentProps["loaderData"];

function UserAvatar({ partialUser }) {
  const userDetails = useFetcher<LoaderData>();
  const [showDetails, setShowDetails] = useState(false);

  useEffect(() => {
    if (
      showDetails &&
      userDetails.state === "idle" &&
      !userDetails.data
    ) {
      userDetails.load(`/user-details/${partialUser.id}`);
    }
  }, [showDetails, userDetails, partialUser.id]);

  return (
    <div
      onMouseEnter={() => setShowDetails(true)}
      onMouseLeave={() => setShowDetails(false)}
    >
      <img src={partialUser.profileImageUrl} />
      {showDetails ? (
        userDetails.state === "idle" && userDetails.data ? (
          <UserPopup user={userDetails.data} />
        ) : (
          <UserPopupLoading />
        )
      ) : null}
    </div>
  );
}

总结

React Router 提供了一系列工具来满足不同的开发需求。虽然某些功能可能看起来重叠,但每个工具都是针对特定场景精心设计的。通过理解 <Form>useFetcheruseNavigation 的复杂性和理想应用场景,以及数据如何通过组件 props 流动,开发者可以创建出更直观、响应更快、用户体验更友好的 Web 应用程序。

文档和示例 CC 4.0
编辑