🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

簡而言之:在建立複雜的 RSC 應用程式之後,我一直遇到同一個難題:如何在不使用 props 傳遞或客戶端上下文的情況下共享伺服器端狀態。因此,我開發了rsc-state ,一個利用 React cache API 簡化伺服器端狀態管理的函式庫。


無人談及的問題

React 伺服器元件在效能方面表現出色。資料在伺服器端獲取,HTML 渲染完成,只傳送極少的 JavaScript 程式碼。這種理念極具吸引力。

但教程中沒有展示的是:當多個伺服器元件需要相同的資料時會發生什麼?

我當時正在開發一個多步驟應用程式(類似於精靈、配置器、多頁表單)。每個步驟都是一個伺服器元件,用於取得自己的資料。架構很清晰,對吧?

隨後需求發生了變化:

  • 標題列需要顯示使用者目前的選擇。

  • 側邊欄需要顯示累計總數

  • 頁腳需要知道哪個步驟處於活動狀態。

  • 驗證取決於先前步驟中的選擇。

突然間,我面臨三個選擇……而且全都很糟糕。


我不斷陷入的三種反模式

1.道具瀑布

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>>>;

操作符遍布各處。每個元件都成了屬性中繼站。新增的共享狀態意味著要修改數十個檔案。

2. “使用客戶端”感染

逃生方法:將所有內容封裝在客戶端上下文中。

"use client";

const AppContext = createContext(null);

export function AppProvider({ children, initialData }) {
    const [state, setState] = useState(initialData);
    // ...
}

這樣做確實可行,但看看會發生什麼:

  • 每個從上下文讀取資料的元件都需要"use client"

  • 這些元件不能再是伺服器元件了。

  • 捆綁包尺寸增大

  • 你一開始想要的SSR福利就沒了。

在一個專案中,我有100 多個檔案導入了同一個上下文鉤子。每個文件都需要客戶端指令。 RSC 架構因為採用了為客戶端 React 設計的模式而受到了損害。

3. 情境複雜性爆炸

真正的殺手鐧是:當你的客戶端上下文擴展到可以處理所有極端情況。

一個始於簡單的州:

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())

區別:

  • 大多數元件仍作為伺服器元件存在。

  • 只有互動式部分需要「使用客戶端」。

  • 沒有上下文提供者包裝器

  • 各個關卡都沒有道具流動


API

建立店鋪

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。


GitHub · npm


原文出處:https://dev.to/emiliodominguez/i-built-a-state-management-library-after-fighting-react-server-components-for-6-months-52k7


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝8   💬8   ❤️3
205
🥈
我愛JS
📝1   💬6   ❤️2
57
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付