狀態管理
本頁面內容

狀態管理

React 中的狀態管理通常涉及在客戶端維護伺服器資料的同步快取。然而,當使用 React Router 作為框架時,由於其內在處理資料同步的方式,大多數傳統的快取解決方案變得多餘。

了解 React 中的狀態管理

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

React 中流行的快取解決方案包括

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

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

React Router 如何簡化狀態

React Router 透過載入器、動作和具有透過重新驗證自動同步的表單等機制,無縫橋接後端和前端之間的差距。這使開發人員能夠在元件中直接使用伺服器狀態,而無需管理快取、網路通訊或資料重新驗證,從而使大多數客戶端快取變得多餘。

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

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

    • useNavigation:此 Hook 讓您可以存取 navigation.statenavigation.formDatanavigation.location 等。
    • useFetcher:這有助於與 fetcher.statefetcher.formDatafetcher.data 等互動。
    • loaderData:存取路由的資料。
    • actionData:存取來自最新動作的資料。
  2. 將資料儲存在 React Router 中: 開發人員可能想要儲存在 React 狀態中的許多資料,在 React Router 中有更自然的位置,例如

    • URL 搜尋參數: URL 中包含狀態的參數。
    • Cookie 儲存在使用者裝置上的小資料片段。
    • 伺服器工作階段 伺服器管理的使用者工作階段。
    • 伺服器快取: 伺服器端快取的資料,以便更快地檢索。
  3. 效能考量: 有時,會利用客戶端狀態來避免多餘的資料擷取。使用 React Router,您可以在 loader 中使用 Cache-Control 標頭,讓您可以利用瀏覽器的原生快取。然而,這種方法有其限制,應謹慎使用。優化後端查詢或實作伺服器快取通常更有益。這是因為這些變更使所有使用者受益,並消除了對個別瀏覽器快取的需求。

作為過渡到 React Router 的開發人員,必須認識並接受其固有的效率,而不是應用傳統的 React 模式。React Router 提供簡化的狀態管理解決方案,從而減少程式碼、提供新鮮資料,且沒有狀態同步錯誤。

範例

如需使用 React Router 的內部狀態來管理網路相關狀態的範例,請參閱待處理 UI

URL 搜尋參數

考慮一個 UI,讓使用者自訂列表檢視或詳細檢視。您的直覺可能是使用 React 狀態

export function List() {
  const [view, setView] = 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 "react-router";

export function List() {
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const [view, setView] = 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>
  );
}

您可以直接在 URL 中使用無聊的舊 HTML 表單讀取和設定狀態,而無需同步狀態

import { Form, useSearchParams } from "react-router";

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. Cookie

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

React 狀態

React 狀態為暫時性狀態儲存提供簡單的解決方案。

優點:

  • 簡單:易於實作和理解。
  • 封裝:狀態的範圍限定於元件。

缺點:

  • 暫時性:無法在頁面重新整理、稍後返回頁面或卸載和重新掛載元件後繼續存在。

實作:

function Sidebar() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen((open) => !open)}>
        {isOpen ? "Close" : "Open"}
      </button>
      <aside hidden={!isOpen}>
        <Outlet />
      </aside>
    </div>
  );
}

本機儲存

為了在元件生命週期之外持續保存狀態,瀏覽器本機儲存更進一步。請參閱我們關於客戶端資料的文件,以取得更進階的範例。

優點:

  • 持久性:跨頁面重新整理和元件掛載/卸載維護狀態。
  • 封裝:狀態的範圍限定於元件。

缺點:

  • 需要同步:React 元件必須與本機儲存同步,以初始化和儲存目前狀態。
  • 伺服器渲染限制:在伺服器端渲染期間無法存取 windowlocalStorage 物件,因此必須在瀏覽器中使用效果初始化狀態。
  • UI 閃爍:在初始頁面載入時,本機儲存中的狀態可能與伺服器渲染的狀態不符,且當 JavaScript 載入時,UI 會閃爍。

實作:

function Sidebar() {
  const [isOpen, setIsOpen] = 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}>
        <Outlet />
      </aside>
    </div>
  );
}

在此方法中,必須在效果內初始化狀態。這對於避免伺服器端渲染期間的複雜情況至關重要。直接從 localStorage 初始化 React 狀態會導致錯誤,因為在伺服器渲染期間 window.localStorage 無法使用。

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

  // ...
}

透過在效果內初始化狀態,伺服器渲染的狀態與本機儲存中儲存的狀態之間可能存在不符。這種差異將導致頁面渲染後不久出現短暫的 UI 閃爍,應避免這種情況。

Cookie

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

優點:

  • 伺服器渲染:狀態在伺服器上可用於渲染,甚至可用於伺服器動作。
  • 單一真理來源:消除狀態同步的麻煩。
  • 持久性:跨頁面載入和元件掛載/卸載維護狀態。如果您切換到資料庫支援的工作階段,狀態甚至可以在裝置之間持續存在。
  • 漸進式增強:甚至在 JavaScript 載入之前即可運作。

缺點:

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

實作:

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

import { createCookie } from "react-router";
export const prefs = createCookie("prefs");

接下來,我們設定伺服器動作和載入器以讀取和寫入 Cookie

import { data, Outlet } from "react-router";
import type { Route } from "./+types/sidebar";

import { prefs } from "./prefs-cookie";

// read the state from the cookie
export async function loader({
  request,
}: Route.LoaderArgs) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie = (await prefs.parse(cookieHeader)) || {};
  return data({ sidebarIsOpen: cookie.sidebarIsOpen });
}

// write the state to the cookie
export async function action({
  request,
}: Route.ActionArgs) {
  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 data(isOpen, {
    headers: {
      "Set-Cookie": await prefs.serialize(cookie),
    },
  });
}

在設定伺服器程式碼後,我們可以在 UI 中使用 Cookie 狀態

function Sidebar({ loaderData }: Route.ComponentProps) {
  const fetcher = useFetcher();
  let { sidebarIsOpen } = loaderData;

  // 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}>
        <Outlet />
      </aside>
    </div>
  );
}

雖然這肯定需要更多程式碼來觸及應用程式的更多部分,以考量網路請求和回應,但 UX 已大大改善。此外,狀態來自單一真理來源,無需任何狀態同步。

總之,上述每種方法都提供了一組獨特的優點和挑戰

  • React 狀態:提供簡單但暫時性的狀態管理。
  • 本機儲存:提供持久性,但有同步要求和 UI 閃爍。
  • Cookie:提供穩健、持久的狀態管理,但需要額外的樣板程式碼。

這些方法都沒有錯,但如果您想要跨訪問持續保存狀態,Cookie 提供最佳的使用者體驗。

表單驗證和動作資料

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

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

export function Signup() {
  // A multitude of React State declarations
  const [isSubmitting, setIsSubmitting] = useState(false);

  const [userName, setUserName] = useState("");
  const [userNameError, setUserNameError] = useState(null);

  const [password, setPassword] = useState(null);
  const [passwordError, setPasswordError] = 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 { ok: false, errors: errors };
  }
  await signupUser(request);
  return { ok: true, errors: null };
}

現在,讓我們將其與基於 React Router 的實作進行比較。動作保持不變,但由於直接使用透過 actionData 的伺服器狀態,以及利用 React Router 固有管理的網路狀態,元件已大幅簡化。

import { useNavigation } from "react-router";
import type { Route } from "./+types/signup";

export async function action({
  request,
}: ActionFunctionArgs) {
  const errors = await validateSignupRequest(request);
  if (errors) {
    return { ok: false, errors: errors };
  }
  await signupUser(request);
  return { ok: true, errors: null };
}

export function Signup({
  actionData,
}: Route.ComponentProps) {
  const navigation = useNavigation();

  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 狀態、變更事件監聽器、提交處理程序和用於此類網路互動的狀態管理函式庫的需求。

透過 actionData 可以直接存取伺服器狀態,而透過 useNavigation(或 useFetcher)可以存取網路狀態。

作為額外的額外技巧,表單甚至在 JavaScript 載入之前即可運作(請參閱漸進式增強)。預設瀏覽器行為介入,而不是 React Router 管理網路操作。

如果您發現自己陷入管理和同步網路操作的狀態,React Router 可能會提供更優雅的解決方案。

文件和範例 CC 4.0