React Router v7 已發布。 查看文件
狀態管理
本頁內容

狀態管理

React 中的狀態管理通常涉及在客戶端維護一個與伺服器資料同步的快取。然而,由於 Remix 本身處理資料同步的方式,大多數傳統的快取解決方案都變得多餘。

了解 React 中的狀態管理

在典型的 React 環境中,當我們提到「狀態管理」時,我們主要是在討論如何將伺服器狀態與客戶端同步。更恰當的術語可能是「快取管理」,因為伺服器是真實來源,而客戶端狀態主要充當快取的功能。

React 中常用的快取解決方案包括

  • Redux:用於 JavaScript 應用程式的可預測狀態容器。
  • React Query:用於在 React 中獲取、快取和更新非同步資料的 Hooks。
  • Apollo:一個綜合性的 JavaScript 狀態管理函式庫,與 GraphQL 整合。

在某些情況下,使用這些函式庫可能是合理的。然而,由於 Remix 獨特的以伺服器為中心的做法,它們的實用性變得不太普遍。事實上,大多數 Remix 應用程式完全放棄它們。

Remix 如何簡化狀態

正如在完整堆疊資料流中討論的那樣,Remix 通過載入器、操作和表單等機制,通過重新驗證自動同步,無縫地彌合了後端和前端之間的差距。這使開發人員能夠直接在元件中使用伺服器狀態,而無需管理快取、網路通訊或資料重新驗證,從而使大多數客戶端快取變得多餘。

以下說明為何在 Remix 中使用典型的 React 狀態模式可能是一種反模式

  1. 網路相關狀態:如果您的 React 狀態正在管理任何與網路相關的事物,例如來自載入器的資料、待處理的表單提交或導航狀態,那麼您很可能正在管理 Remix 已經在管理的狀態

    • useNavigation:這個 Hook 讓您可以存取 navigation.statenavigation.formDatanavigation.location 等等。
    • useFetcher:這有助於與 fetcher.statefetcher.formDatafetcher.data 等互動。
    • useLoaderData:存取路由的資料。
    • useActionData:存取來自最新 action 的資料。
  2. 在 Remix 中儲存資料: 許多開發人員可能會想儲存在 React state 中的資料,在 Remix 中有更自然的位置,例如:

    • URL 搜尋參數: URL 中保存狀態的參數。
    • Cookies: 儲存在使用者裝置上的小段資料。
    • 伺服器 Session: 由伺服器管理的用戶 Session。
    • 伺服器快取: 伺服器端快取的資料,以加快檢索速度。
  3. 效能考量: 有時,會利用客戶端狀態來避免多餘的資料獲取。 使用 Remix,您可以利用 loader 中的 Cache-Control 標頭,允許您使用瀏覽器內建的快取。 然而,這種方法有其局限性,應該謹慎使用。優化後端查詢或實作伺服器快取通常更有利。因為這樣的變更可以讓所有使用者受益,並免除個別瀏覽器快取的需要。

作為轉換到 Remix 的開發人員,必須認知並擁抱其內在的效率,而不是應用傳統的 React 模式。 Remix 提供了一個精簡的狀態管理解決方案,可以減少程式碼、取得最新資料,並且沒有狀態同步錯誤。

範例

有關使用 Remix 內部狀態來管理網路相關狀態的範例,請參閱待處理的 UI

URL 搜尋參數

假設有一個 UI 允許使用者在列表檢視或詳細檢視之間自訂。 您可能會本能地使用 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。請注意狀態同步

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 state
  2. 瀏覽器 local storage
  3. Cookies

在此討論中,我們將分析與每種方法相關的權衡取捨。

React State

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

若要將狀態持久化到元件生命週期之外,瀏覽器 local storage 是一個進階的選擇。

優點:

  • 持久性:在頁面重新整理和元件掛載/卸載之間保持狀態。
  • 封裝性:狀態限定於元件。

缺點:

  • 需要同步:React 元件必須與 local storage 同步,才能初始化和儲存目前的狀態。
  • 伺服器端呈現限制windowlocalStorage 物件在伺服器端呈現期間無法存取,因此必須使用 effect 在瀏覽器中初始化狀態。
  • UI 閃爍:在初始頁面載入時,local storage 中的狀態可能與伺服器呈現的狀態不符,並且當 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。此外,即使它可以存取,它也不會反映使用者的瀏覽器 local storage。

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

  // ...
}

透過在 effect 中初始化狀態,伺服器呈現的狀態與 local storage 中儲存的狀態之間可能會不符。這種差異將導致頁面呈現後不久出現短暫的 UI 閃爍,應避免這種情況。

Cookies

Cookies 為此用例提供了一個全面的解決方案。然而,此方法在使狀態在元件中可存取之前,引入了額外的初步設定。

優點:

  • 伺服器端呈現:狀態可在伺服器上用於呈現,甚至用於伺服器 action。
  • 單一事實來源:消除了狀態同步的麻煩。
  • 持久性:在頁面載入和元件掛載/卸載之間保持狀態。如果您切換到資料庫支援的 Session,狀態甚至可以在裝置之間保持。
  • 漸進式增強:即使在 JavaScript 載入之前也能正常運作。

缺點:

  • 樣板程式碼:由於網路的關係,需要更多程式碼。
  • 公開:狀態並未封裝到單一元件,應用程式的其他部分必須知道 cookie。

實作:

首先,我們需要建立一個 cookie 物件

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

接下來,我們設定伺服器 action 和 loader 來讀取和寫入 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 state:提供簡單但暫時的狀態管理。
  • Local Storage:提供持久性,但有同步要求和 UI 閃爍。
  • Cookies:以額外的樣板程式碼為代價,提供強大且持久的狀態管理。

這些都沒有錯,但如果您希望在瀏覽之間保持狀態,cookies 會提供最佳的使用者體驗。

表單驗證和 Action 資料

客戶端驗證可以增強使用者體驗,但透過更多地傾向於伺服器端處理並讓其處理複雜性,也可以實現類似的增強功能。

以下範例說明了管理網路狀態、協調來自伺服器的狀態以及在客戶端和伺服器端冗餘實作驗證的內在複雜性。 這只是為了說明,所以請原諒您發現的任何明顯錯誤或問題。

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 的實作進行比較。 Action 保持一致,但由於直接透過 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