簡而言之:在建立複雜的 RSC 應用程式之後,我一直遇到同一個難題:如何在不使用 props 傳遞或客戶端上下文的情況下共享伺服器端狀態。因此,我開發了rsc-state ,一個利用 React cache API 簡化伺服器端狀態管理的函式庫。
React 伺服器元件在效能方面表現出色。資料在伺服器端獲取,HTML 渲染完成,只傳送極少的 JavaScript 程式碼。這種理念極具吸引力。
但教程中沒有展示的是:當多個伺服器元件需要相同的資料時會發生什麼?
我當時正在開發一個多步驟應用程式(類似於精靈、配置器、多頁表單)。每個步驟都是一個伺服器元件,用於取得自己的資料。架構很清晰,對吧?
隨後需求發生了變化:
標題列需要顯示使用者目前的選擇。
側邊欄需要顯示累計總數
頁腳需要知道哪個步驟處於活動狀態。
驗證取決於先前步驟中的選擇。
突然間,我面臨三個選擇……而且全都很糟糕。
React 的「正確」做法:將所有內容作為 props 傳遞。
// layout.tsx
export default async function Layout({ children }) {
const user = await getUser();
const preferences = await getPreferences();
const config = await getConfig();
return (
<Wrapper user={user} preferences={preferences} config={config}>
{children}
</Wrapper>
);
}
然後每個孩子都需要這些道具。他們的孩子也需要這些道具。如此循環往復。
<StepContainer {...props}>
<StepHeader {...props} />
<StepContent {...props} />
<StepFooter {...props} />
</StepContainer>
我最終得到的屬性類型如下所示:
type StepProps = WithAuth<WithConfig<WithPreferences<BaseProps>>>;
操作符遍布各處。每個元件都成了屬性中繼站。新增的共享狀態意味著要修改數十個檔案。
逃生方法:將所有內容封裝在客戶端上下文中。
"use client";
const AppContext = createContext(null);
export function AppProvider({ children, initialData }) {
const [state, setState] = useState(initialData);
// ...
}
這樣做確實可行,但看看會發生什麼:
每個從上下文讀取資料的元件都需要"use client"
這些元件不能再是伺服器元件了。
捆綁包尺寸增大
你一開始想要的SSR福利就沒了。
在一個專案中,我有100 多個檔案導入了同一個上下文鉤子。每個文件都需要客戶端指令。 RSC 架構因為採用了為客戶端 React 設計的模式而受到了損害。
真正的殺手鐧是:當你的客戶端上下文擴展到可以處理所有極端情況。
一個始於簡單的州:
const [selections, setSelections] = useState({});
變成了這樣:
const [selections, setSelections] = useState({});
const [derived, setDerived] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [cache, setCache] = useState(new Map());
const [pending, setPending] = useState(false);
// ... and more
const selectionRef = useRef(null);
const cacheRef = useRef(new LRUCache());
const abortRef = useRef(null);
// ... and more
useEffect(() => {
/* sync to URL */
}, [selections]);
useEffect(() => {
/* validate */
}, [selections]);
useEffect(() => {
/* compute derived */
}, [selections]);
// ... and more
我見過上下文文件超過1000行。 15個useState鉤子,10個useRef鉤子,複雜的快取、重試邏輯、URL同步…所有這些都被塞進客戶端元件裡,因為伺服器端沒有好的狀態共用方式。
React 有cache() 。它是為伺服器元件中的請求範圍快取而設計的:
const getUser = cache(async (id: string) => {
return await db.user.findById(id);
});
// Call it anywhere in the same request with the same args
const user1 = await getUser("123");
const user2 = await getUser("123"); // Cache hit, no refetch
伺服器請求之間快取會自動失效,因此每個使用者都能獲得獨立的資料。但cache()的設計初衷是用於快取函數呼叫,而不是管理可變狀態。它沒有更新機制、沒有衍生狀態、也沒有生命週期鉤子。
所以我建構了抽象層。
// stores/user.ts
import { createServerStore } from "rsc-state";
export const userStore = createServerStore({
initial: {
userId: null as string | null,
name: "",
},
derive: (state) => ({
isAuthenticated: state.userId !== null,
}),
});
在佈局中初始化一次:
// app/layout.tsx
export default async function Layout({ children }) {
const session = await getSession();
userStore.initialize({
userId: session?.userId ?? null,
name: session?.name ?? ""
});
return <>{children}</>;
}
從任何伺服器元件讀取資料(無需 props、鉤子或客戶端指令):
// components/Header.tsx (Server Component!)
export function Header() {
const { name, isAuthenticated } = userStore.read();
return (
<header>
{isAuthenticated ? `Welcome, ${name}` : "Welcome, Guest"}
</header>
);
}
// components/Sidebar.tsx (Also a Server Component!)
export function Sidebar() {
const { isAuthenticated } = userStore.read();
if (!isAuthenticated) return null;
return <nav>...</nav>;
}
無需屬性鑽取。無需上下文提供者。無需“使用客戶端”來讀取共享狀態。
Layout (fetches user, passes to context)
├── StepsProvider ("use client", 800+ lines)
│ ├── Header ("use client", imports useStepsContext)
│ ├── Sidebar ("use client", imports useStepsContext)
│ ├── StepContent ("use client", imports useStepsContext)
│ │ ├── StepForm ("use client", imports useStepsContext)
│ │ └── StepPreview ("use client", imports useStepsContext)
│ └── Footer ("use client", imports useStepsContext)
Layout (fetches user, initializes store)
├── Header (Server Component, calls store.read())
├── Sidebar (Server Component, calls store.read())
├── StepContent (Server Component, calls store.read())
│ ├── StepForm (Client Component only where needed)
│ └── StepPreview (Server Component, calls store.read())
└── Footer (Server Component, calls store.read())
區別:
大多數元件仍作為伺服器元件存在。
只有互動式部分需要「使用客戶端」。
沒有上下文提供者包裝器
各個關卡都沒有道具流動
const store = createServerStore({
initial: { count: 0 },
// Optional: computed values (memoized)
derive: (state) => ({
doubled: state.count * 2,
isPositive: state.count > 0,
}),
// Optional: lifecycle hooks
onInitialize: (state) => console.log("Store ready:", state),
onUpdate: (prev, next) => console.log("Changed:", prev, "→", next),
});
// Get everything
const state = store.read();
// Select specific values
const count = store.select((s) => s.count);
// Replace entirely
store.set({ count: 5 });
// Update with reducer
store.update((prev) => ({ ...prev, count: prev.count + 1 }));
// Batch multiple updates (derived state computed once at the end)
store.batch((api) => {
api.update((s) => ({ ...s, count: s.count + 1 }));
api.update((s) => ({ ...s, count: s.count + 1 }));
});
請求儲存(預設):狀態按請求隔離。對使用者特定資料安全。
const userStore = createServerStore({
storage: "request", // default
initial: { userId: null },
});
持久化儲存:狀態在所有請求之間共用。可用於功能標誌、全域配置和演示。
const featureFlags = createServerStore({
storage: "persistent",
initial: { darkMode: false, betaFeatures: true },
});
關於“伺服器狀態”,一個常見的擔憂是將無狀態伺服器轉換為有狀態伺服器。這種擔憂不無道理……有狀態伺服器更難擴展、部署,而且容易出現記憶體洩漏。
但請求作用域儲存完全避免了這個問題。狀態僅在單一請求期間存在。回應發送後,狀態即被清除。無需清理,不會佔用內存,也不會造成跨請求污染。
您的伺服器仍然是無狀態的。每個請求都是獨立的。您仍然可以:
水平縮放,不考慮會話關聯性
部署時不會耗盡連線數
執行無伺服器模式,避免冷啟動狀態問題
「持久化」儲存模式確實引入了真正的伺服器狀態(儲存在模組級變數中),這也是為什麼文件中會對此發出警告的原因。但預設的請求作用域模式呢?它只不過是帶有友善 API 的記憶化而已。
非常合適:
伺服器端渲染的應用,共享請求資料(身份驗證、配置、偏好設定)
多步驟流程,其中狀態需要在各個元件之間可讀
在不犧牲RSC效益的前提下,消除螺旋槳鑽井
大多陣列件應保留為伺服器元件的應用程式
不合適:
具有頻繁客戶端更新的高度互動式使用者介面(使用 Zustand、Jotai)
需要在導航過程中保持的狀態(使用 cookie、資料庫)
簡單的應用程式,道具功能運作良好。
rsc-state底層使用 React 的cache()來建立請求作用域的單一例子。訣竅在於封裝一個傳回可變物件的工廠函數:
// Simplified internal implementation
const getRequestScopedInstance = cache(() => ({
state: initialState,
initialized: false,
derivedCache: null,
}));
// Every call within the same request returns the same object
function read() {
const instance = getRequestScopedInstance();
return instance.state;
}
由於cache()是按請求快取的,因此所有呼叫getRequestScopedInstance()的元件都會獲得同一個物件參考。對該物件的修改在請求內的所有位置都可見,但與其他請求隔離。
圖書館補充道:
類型安全的派生狀態,僅在依賴項變更時重新計算
用於日誌記錄、分析和副作用的生命週期鉤子
錯誤邊界,以防止因派生函數錯誤而導致應用程式崩潰
批量更新以最大程度減少重複計算
npm install rsc-state
import { createServerStore } from "rsc-state";
// Create
const store = createServerStore({
initial: { items: [] as string[] },
derive: (state) => ({
count: state.items.length,
isEmpty: state.items.length === 0,
}),
});
// Initialize (in layout)
store.initialize({ items: ["first"] });
// Read (in any server component)
const { items, count, isEmpty } = store.read();
倉庫中包含Next.js 14、15 和 16的工作範例。
RSC 狀態管理與用戶端狀態管理有著本質差異。我們已經內化的模式(上下文 API、自訂鉤子、客戶端儲存)無法直接移植。
React 提供了基本功能( cache 、伺服器元件、伺服器操作),但其使用者體驗仍有待改進。這個庫就是我嘗試彌補這一差距的成果。
如果您也遇到類似的問題,我很想聽聽您的解決方法。如果您嘗試使用這個庫,歡迎提交 issue……我會根據實際使用情況積極改進 API。