React Router v7 已發布。 檢視文件
資料寫入
本頁內容

資料寫入

Remix 中的資料寫入(有些人稱之為變更)是建立在兩個基本網路 API 之上:<form> 和 HTTP。然後,我們使用漸進增強來啟用樂觀 UI、載入指示器和驗證回饋 - 但程式設計模型仍然是建立在 HTML 表單之上。

當使用者提交表單時,Remix 會

  1. 呼叫表單的 action
  2. 重新載入頁面上所有路由的所有資料

許多時候,人們會使用 React 中的全域狀態管理函式庫(如 redux)、資料函式庫(如 apollo)和 fetch 包裝器(如 React Query),以協助管理將伺服器狀態載入元件並在使用者變更時保持 UI 與之同步。當您使用標準 HTML API 時,Remix 基於 HTML 的 API 取代了這些工具的大部分使用案例。Remix 知道如何載入資料,以及如何在資料變更後重新驗證資料。

有幾種方法可以呼叫 action 並讓路由重新驗證

本指南僅涵蓋 <Form>。我們建議您閱讀本指南後,閱讀其他兩個的文檔,以了解如何使用它們。本指南的大部分內容適用於 useSubmit,但 useFetcher 有點不同。

純 HTML 表單

在我們公司 React Training 舉辦多年的研討會後,我們了解到許多較新的網路開發人員(雖然不是他們自己的錯)實際上並不知道 <form> 的運作方式!

由於 Remix <Form> 的運作方式與 <form> 完全相同(具有一些用於樂觀 UI 等的額外好處),我們將複習一下普通的 HTML 表單,以便您可以同時學習 HTML 和 Remix。

HTML 表單 HTTP 動詞

原生表單支援兩種 HTTP 動詞:GETPOST。Remix 使用這些動詞來了解您的意圖。如果是 GET,Remix 將會找出頁面的哪些部分正在變更,並且只會擷取變更的版面配置的資料,並使用未變更的版面配置的快取資料。如果是 POST,Remix 將會重新載入所有資料,以確保它能擷取伺服器的更新。讓我們看看這兩種情況。

HTML 表單 GET

GET 就像正常的導覽,表單資料會傳遞在 URL 搜尋參數中。您可以將其用於正常的導覽,就像 <a> 一樣,只是使用者可以透過表單提供搜尋參數中的資料。除了搜尋頁面之外,它與 <form> 一起使用的情況非常罕見。

考慮一下這個表單

<form method="get" action="/search">
  <label>Search <input name="term" type="text" /></label>
  <button type="submit">Search</button>
</form>

當使用者填寫表單並點擊提交時,瀏覽器會自動將表單值序列化為 URL 搜尋參數字串,並使用附加的查詢字串導覽至表單的 action。假設使用者輸入了「remix」。瀏覽器會導覽至 /search?term=remix。如果我們將輸入變更為 <input name="q"/>,則表單會導覽至 /search?q=remix

這與我們建立此連結的行為相同

<a href="/search?term=remix">Search for "remix"</a>

唯一不同之處在於,使用者提供了資訊。

如果您有更多欄位,瀏覽器將會新增它們

<form method="get" action="/search">
  <fieldset>
    <legend>Brand</legend>
    <label>
      <input name="brand" value="nike" type="checkbox" />
      Nike
    </label>
    <label>
      <input name="brand" value="reebok" type="checkbox" />
      Reebok
    </label>
    <label>
      <input name="color" value="white" type="checkbox" />
      White
    </label>
    <label>
      <input name="color" value="black" type="checkbox" />
      Black
    </label>
    <button type="submit">Search</button>
  </fieldset>
</form>

根據使用者點擊的核取方塊,瀏覽器將會導覽至類似以下的 URL

/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white

HTML 表單 POST

當您想要在您的網站上建立、刪除或更新資料時,表單 post 是最佳選擇。而且我們不僅僅是指像是使用者個人資料編輯頁面之類的大型表單。即使是「讚」按鈕也可以使用表單來處理。

讓我們考慮一個「新增專案」表單。

<form method="post" action="/projects">
  <label><input name="name" type="text" /></label>
  <label><textarea name="description"></textarea></label>
  <button type="submit">Create</button>
</form>

當使用者提交此表單時,瀏覽器會將欄位序列化為請求「主體」(而不是 URL 搜尋參數)並將其「POST」到伺服器。這仍然是正常的導覽,就像使用者點擊連結一樣。不同之處有兩點:使用者提供了伺服器的資料,並且瀏覽器將請求作為「POST」而不是「GET」傳送。

伺服器的請求處理程式可以使用資料,因此您可以建立記錄。之後,您會傳回回應。在這種情況下,您可能會重新導向到新建立的專案。Remix action 看起來會像這樣

export async function action({
  request,
}: ActionFunctionArgs) {
  const body = await request.formData();
  const project = await createProject(body);
  return redirect(`/projects/${project.id}`);
}

瀏覽器從 /projects/new 開始,然後將表單資料發佈到 /projects 的請求中,然後伺服器將瀏覽器重新導向到 /projects/123。當這一切發生時,瀏覽器會進入其正常的「載入」狀態:網址進度列填滿、favicon 變成微調器等等。這實際上是不錯的使用者體驗。

如果您是剛接觸 Web 開發的新手,您可能從未使用過這種方式的表單。許多人一直都這樣做

<form onSubmit={(event) => { event.preventDefault(); // good
luck! }} />

如果您是這種情況,當您看到僅使用瀏覽器(和 Remix)內建的功能就能讓變更變得如此容易時,您會感到非常高興!

Remix 變更,從頭到尾

我們將從頭到尾建立一個變更,其中包含

  1. JavaScript 選用
  2. 驗證
  3. 錯誤處理
  4. 漸進增強的載入指示器
  5. 漸進增強的錯誤顯示

您使用 Remix <Form> 元件進行資料變更的方式,與使用 HTML 表單的方式相同。不同之處在於,現在您可以存取待處理的表單狀態,以建立更佳的使用者體驗:例如內容相關的載入指示器和「樂觀 UI」。

無論您是使用 <form> 還是 <Form>,您都會撰寫相同的程式碼。您可以從 <form> 開始,然後將其升級到 <Form> 而無需變更任何內容。之後,加入特殊的載入指示器和樂觀 UI。但是,如果您覺得不想這樣做,或截止期限很緊,只需使用 <form> 並讓瀏覽器處理使用者回饋!Remix <Form> 是變更「漸進增強」的實現。

建立表單

讓我們從先前的專案表單開始,但使其可用

假設您有路由 app/routes/projects.new.tsx,其中包含此表單

export default function NewProject() {
  return (
    <form method="post" action="/projects/new">
      <p>
        <label>
          Name: <input name="name" type="text" />
        </label>
      </p>
      <p>
        <label>
          Description:
          <br />
          <textarea name="description" />
        </label>
      </p>
      <p>
        <button type="submit">Create</button>
      </p>
    </form>
  );
}

現在新增路由 action。任何「post」的表單提交都會呼叫您的資料「action」。任何「get」的提交(<Form method="get">)都會由您的「loader」處理。

import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno

// Note the "action" export name, this will handle our form POST
export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const project = await createProject(formData);
  return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
  // ... same as before
}

就這樣!假設 createProject 函數的功能如我們預期,那你只需要做這些。請注意,無論你過去建立過什麼類型的 SPA,你總是需要一個伺服器端的動作和一個表單來獲取使用者資料。Remix 的不同之處在於,你只需要這些(這也是過去網路的運作方式)。

當然,我們開始將事情複雜化,試圖創造比預設瀏覽器行為更好的使用者體驗。繼續前進,我們會達到目標,但我們不需要更改任何已經編寫的程式碼來獲得核心功能。

表單驗證

在用戶端和伺服器端驗證表單是很常見的。很不幸的是,只在用戶端驗證也很常見,這會導致你的資料出現各種問題,我們現在沒有時間深入探討。重點是,如果你只在一個地方進行驗證,那就應該在伺服器端進行。你會發現使用 Remix 後,你只會關心伺服器端(發送到瀏覽器的東西越少越好!)。

我們知道,我們知道,你想要讓驗證錯誤的呈現有動畫效果等等。我們會處理的。但現在我們只是在建立一個基本的 HTML 表單和使用者流程。我們先保持簡單,然後再把它弄得花俏。

回到我們的 action 中,也許我們有一個 API 會返回像這樣的驗證錯誤。

const [errors, project] = await createProject(formData);

如果有驗證錯誤,我們希望回到表單並顯示它們。

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const [errors, project] = await createProject(formData);

  if (errors) {
    const values = Object.fromEntries(formData);
    return json({ errors, values });
  }

  return redirect(`/projects/${project.id}`);
};

就像 useLoaderData 會返回 loader 的值一樣,useActionData 會返回 action 的資料。它只會在導航是表單提交時才會存在,因此你總是需要檢查它是否存在。

import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData } from "@remix-run/react";

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  // ...
};

export default function NewProject() {
  const actionData = useActionData<typeof action>();

  return (
    <form method="post" action="/projects/new">
      <p>
        <label>
          Name:{" "}
          <input
            name="name"
            type="text"
            defaultValue={actionData?.values.name}
          />
        </label>
      </p>

      {actionData?.errors.name ? (
        <p style={{ color: "red" }}>
          {actionData.errors.name}
        </p>
      ) : null}

      <p>
        <label>
          Description:
          <br />
          <textarea
            name="description"
            defaultValue={actionData?.values.description}
          />
        </label>
      </p>

      {actionData?.errors.description ? (
        <p style={{ color: "red" }}>
          {actionData.errors.description}
        </p>
      ) : null}

      <p>
        <button type="submit">Create</button>
      </p>
    </form>
  );
}

請注意我們如何將 defaultValue 添加到所有輸入欄位。請記住,這是常規的 HTML <form>,所以這只是瀏覽器/伺服器之間的正常運作。我們從伺服器端取回值,這樣使用者就不必重新輸入他們之前輸入的內容。

你可以按原樣發布此程式碼。瀏覽器會為你處理掛起中的 UI 和中斷。享受你的週末,並在週一把它弄得花俏。

進階使用 <Form> 並新增掛起中的 UI

讓我們使用漸進式增強來讓這個 UX 更花俏一點。通過將 <form> 更改為 <Form>,Remix 將使用 fetch 來模擬瀏覽器的行為。它還會讓你存取掛起中的表單資料,以便你可以建立掛起中的 UI。

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData, Form } from "@remix-run/react";

// ...

export default function NewProject() {
  const actionData = useActionData<typeof action>();

  return (
    // note the capital "F" <Form> now
    <Form method="post">{/* ... */}</Form>
  );
}

等一下!如果你只是將表單更改為 Form,你反而讓 UX 變得更糟了!

如果你沒有時間或動力來完成這裡的其餘工作,請使用 <Form reloadDocument>。這讓瀏覽器繼續處理掛起中的 UI 狀態(標籤頁的 favicon 中的旋轉器、網址列中的進度條等)。如果你只是使用 <Form> 而沒有實作掛起中的 UI,使用者在提交表單時不會知道任何事情正在發生。

我們建議始終使用大寫的 Form,如果你想讓瀏覽器處理掛起中的 UI,請使用 <Form reloadDocument> prop。

現在讓我們新增一些掛起中的 UI,讓使用者在提交時知道發生了某些事情。有一個名為 useNavigation 的 hook。當有掛起中的表單提交時,Remix 會給你表單的序列化版本,它是一個 FormData 物件。你最感興趣的會是 formData.get() 方法。

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import {
  useActionData,
  Form,
  useNavigation,
} from "@remix-run/react";

// ...

export default function NewProject() {
  // when the form is being processed on the server, this returns different
  // navigation states to help us build pending and optimistic UI.
  const navigation = useNavigation();
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <fieldset
        disabled={navigation.state === "submitting"}
      >
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={
                actionData
                  ? actionData.values.name
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.name ? (
          <p style={{ color: "red" }}>
            {actionData.errors.name}
          </p>
        ) : null}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={
                actionData
                  ? actionData.values.description
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.description ? (
          <p style={{ color: "red" }}>
            {actionData.errors.description}
          </p>
        ) : null}

        <p>
          <button type="submit">
            {navigation.state === "submitting"
              ? "Creating..."
              : "Create"}
          </button>
        </p>
      </fieldset>
    </Form>
  );
}

非常棒!現在,當使用者點擊「建立」時,輸入欄位會被停用,並且提交按鈕的文字會更改。整個操作現在也應該更快了,因為只有一個網路請求發生,而不是完整的頁面重新載入(這可能涉及更多網路請求、從瀏覽器快取讀取資源、解析 JavaScript、解析 CSS 等)。

我們在此頁面上沒有對 navigation 做太多處理,但它包含了有關提交的所有資訊 (navigation.formMethod, navigation.formAction, navigation.formEncType),以及在伺服器上處理的所有值,都存在於 navigation.formData 中。

驗證錯誤的動畫效果

既然我們使用 JavaScript 來提交此頁面,我們的驗證錯誤就可以通過動畫呈現,因為該頁面是有狀態的。首先,我們將建立一個花俏的元件,該元件會為高度和不透明度製作動畫效果。

function ValidationMessage({ error, isSubmitting }) {
  const [show, setShow] = useState(!!error);

  useEffect(() => {
    const id = setTimeout(() => {
      const hasError = !!error;
      setShow(hasError && !isSubmitting);
    });
    return () => clearTimeout(id);
  }, [error, isSubmitting]);

  return (
    <div
      style={{
        opacity: show ? 1 : 0,
        height: show ? "1em" : 0,
        color: "red",
        transition: "all 300ms ease-in-out",
      }}
    >
      {error}
    </div>
  );
}

現在,我們可以用這個新的花俏元件包裝我們舊的錯誤訊息,甚至將有錯誤的欄位的邊框變為紅色。

export default function NewProject() {
  const navigation = useNavigation();
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <fieldset
        disabled={navigation.state === "submitting"}
      >
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={
                actionData
                  ? actionData.values.name
                  : undefined
              }
              style={{
                borderColor: actionData?.errors.name
                  ? "red"
                  : "",
              }}
            />
          </label>
        </p>

        {actionData?.errors.name ? (
          <ValidationMessage
            isSubmitting={navigation.state === "submitting"}
            error={actionData?.errors?.name}
          />
        ) : null}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={actionData?.values.description}
              style={{
                borderColor: actionData?.errors.description
                  ? "red"
                  : "",
              }}
            />
          </label>
        </p>

        <ValidationMessage
          isSubmitting={navigation.state === "submitting"}
          error={actionData?.errors.description}
        />

        <p>
          <button type="submit">
            {navigation.state === "submitting"
              ? "Creating..."
              : "Create"}
          </button>
        </p>
      </fieldset>
    </Form>
  );
}

太棒了!無需更改我們與伺服器通信的方式即可實現花俏的 UI。它還能適應阻止 JS 載入的網路狀況。

回顧

  • 首先,我們建立專案表單時沒有考慮 JavaScript。一個簡單的表單,提交到伺服器端的 action。歡迎來到 1998 年。

  • 一旦它開始運作,我們就使用 JavaScript 通過將 <form> 更改為 <Form> 來提交表單,但我們不必做任何其他事情!

  • 現在有一個包含 React 的有狀態頁面,我們通過簡單地向 Remix 詢問導航狀態,為驗證錯誤新增了載入指示器和動畫。

從你的元件的角度來看,所有發生的事情都是 useNavigation hook 在提交表單時觸發了狀態更新,然後在資料返回時觸發了另一個狀態更新。當然,在 Remix 內部發生了很多事情,但就你的元件而言,就這樣了。只有幾個狀態更新。這使得裝飾任何使用者流程變得非常容易。

參見

文件和範例授權於 MIT