React 中的狀態管理通常涉及在客戶端維護伺服器資料的同步快取。然而,當使用 React Router 作為框架時,由於其內在處理資料同步的方式,大多數傳統的快取解決方案變得多餘。
在典型的 React 情境中,當我們提到「狀態管理」時,我們主要討論的是如何將伺服器狀態與客戶端同步。更恰當的術語可能是「快取管理」,因為伺服器是真理的來源,而客戶端狀態主要充當快取。
React 中流行的快取解決方案包括
在某些情況下,使用這些函式庫可能是合理的。然而,由於 React Router 以伺服器為中心的獨特方法,它們的實用性變得較不普遍。事實上,大多數 React Router 應用程式完全放棄它們。
React Router 透過載入器、動作和具有透過重新驗證自動同步的表單等機制,無縫橋接後端和前端之間的差距。這使開發人員能夠在元件中直接使用伺服器狀態,而無需管理快取、網路通訊或資料重新驗證,從而使大多數客戶端快取變得多餘。
以下說明為何在 React Router 中使用典型的 React 狀態模式可能是一種反模式
網路相關狀態: 如果您的 React 狀態正在管理任何與網路相關的事物(例如來自載入器的資料、待處理的表單提交或導航狀態),則您很可能正在管理 React Router 已經管理的狀態
useNavigation
:此 Hook 讓您可以存取 navigation.state
、navigation.formData
、navigation.location
等。useFetcher
:這有助於與 fetcher.state
、fetcher.formData
、fetcher.data
等互動。loaderData
:存取路由的資料。actionData
:存取來自最新動作的資料。將資料儲存在 React Router 中: 開發人員可能想要儲存在 React 狀態中的許多資料,在 React Router 中有更自然的位置,例如
效能考量: 有時,會利用客戶端狀態來避免多餘的資料擷取。使用 React Router,您可以在 loader
中使用 Cache-Control
標頭,讓您可以利用瀏覽器的原生快取。然而,這種方法有其限制,應謹慎使用。優化後端查詢或實作伺服器快取通常更有益。這是因為這些變更使所有使用者受益,並消除了對個別瀏覽器快取的需求。
作為過渡到 React Router 的開發人員,必須認識並接受其固有的效率,而不是應用傳統的 React 模式。React Router 提供簡化的狀態管理解決方案,從而減少程式碼、提供新鮮資料,且沒有狀態同步錯誤。
如需使用 React Router 的內部狀態來管理網路相關狀態的範例,請參閱待處理 UI。
考慮一個 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,可切換側邊欄的可見性。我們有三種方法可以處理狀態
在本討論中,我們將分解與每種方法相關的權衡取捨。
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>
);
}
為了在元件生命週期之外持續保存狀態,瀏覽器本機儲存更進一步。請參閱我們關於客戶端資料的文件,以取得更進階的範例。
優點:
缺點:
window
和 localStorage
物件,因此必須在瀏覽器中使用效果初始化狀態。實作:
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 物件
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 已大大改善。此外,狀態來自單一真理來源,無需任何狀態同步。
總之,上述每種方法都提供了一組獨特的優點和挑戰
這些方法都沒有錯,但如果您想要跨訪問持續保存狀態,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 可能會提供更優雅的解決方案。