React 中的狀態管理通常涉及在客戶端維護一個與伺服器資料同步的快取。然而,由於 Remix 本身處理資料同步的方式,大多數傳統的快取解決方案都變得多餘。
在典型的 React 環境中,當我們提到「狀態管理」時,我們主要是在討論如何將伺服器狀態與客戶端同步。更恰當的術語可能是「快取管理」,因為伺服器是真實來源,而客戶端狀態主要充當快取的功能。
React 中常用的快取解決方案包括
在某些情況下,使用這些函式庫可能是合理的。然而,由於 Remix 獨特的以伺服器為中心的做法,它們的實用性變得不太普遍。事實上,大多數 Remix 應用程式完全放棄它們。
正如在完整堆疊資料流中討論的那樣,Remix 通過載入器、操作和表單等機制,通過重新驗證自動同步,無縫地彌合了後端和前端之間的差距。這使開發人員能夠直接在元件中使用伺服器狀態,而無需管理快取、網路通訊或資料重新驗證,從而使大多數客戶端快取變得多餘。
以下說明為何在 Remix 中使用典型的 React 狀態模式可能是一種反模式
網路相關狀態:如果您的 React 狀態正在管理任何與網路相關的事物,例如來自載入器的資料、待處理的表單提交或導航狀態,那麼您很可能正在管理 Remix 已經在管理的狀態
useNavigation
:這個 Hook 讓您可以存取 navigation.state
、navigation.formData
、navigation.location
等等。useFetcher
:這有助於與 fetcher.state
、fetcher.formData
、fetcher.data
等互動。useLoaderData
:存取路由的資料。useActionData
:存取來自最新 action 的資料。在 Remix 中儲存資料: 許多開發人員可能會想儲存在 React state 中的資料,在 Remix 中有更自然的位置,例如:
效能考量: 有時,會利用客戶端狀態來避免多餘的資料獲取。 使用 Remix,您可以利用 loader
中的 Cache-Control
標頭,允許您使用瀏覽器內建的快取。 然而,這種方法有其局限性,應該謹慎使用。優化後端查詢或實作伺服器快取通常更有利。因為這樣的變更可以讓所有使用者受益,並免除個別瀏覽器快取的需要。
作為轉換到 Remix 的開發人員,必須認知並擁抱其內在的效率,而不是應用傳統的 React 模式。 Remix 提供了一個精簡的狀態管理解決方案,可以減少程式碼、取得最新資料,並且沒有狀態同步錯誤。
有關使用 Remix 內部狀態來管理網路相關狀態的範例,請參閱待處理的 UI。
假設有一個 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 可以切換側邊欄的顯示狀態。 我們有三種方法來處理狀態
在此討論中,我們將分析與每種方法相關的權衡取捨。
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 是一個進階的選擇。
優點:
缺點:
window
和 localStorage
物件在伺服器端呈現期間無法存取,因此必須使用 effect 在瀏覽器中初始化狀態。實作:
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 為此用例提供了一個全面的解決方案。然而,此方法在使狀態在元件中可存取之前,引入了額外的初步設定。
優點:
缺點:
實作:
首先,我們需要建立一個 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>
);
}
雖然這肯定需要更多程式碼來處理應用程式的更多部分,以考量網路請求和回應,但使用者體驗得到了極大的改善。 此外,狀態來自單一事實來源,無需任何狀態同步。
總之,所討論的每種方法都提供了一組獨特的優點和挑戰
這些都沒有錯,但如果您希望在瀏覽之間保持狀態,cookies 會提供最佳的使用者體驗。
客戶端驗證可以增強使用者體驗,但透過更多地傾向於伺服器端處理並讓其處理複雜性,也可以實現類似的增強功能。
以下範例說明了管理網路狀態、協調來自伺服器的狀態以及在客戶端和伺服器端冗餘實作驗證的內在複雜性。 這只是為了說明,所以請原諒您發現的任何明顯錯誤或問題。
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 很可能提供更優雅的解決方案。