如何在客戶端更新 RSC (React Server Component) 的狀態

在 Next.js App Router 中,Client Component 不能直接匯入 Server Component;當你想把像「搜尋輸入」這種即時變動的客戶端狀態傳回 RSC 時,最實用的做法之一是把狀態寫進 URL 的 search params,並讓 page 透過 searchParams 取得值後再把它傳給 RSC。Next.js 也建議在 Server Component Page 使用 searchParams prop、在 Client 端用 useSearchParams/useRouter 更新查詢字串來觸發頁面拿到最新參數;若你想要更好用的 query string 狀態管理體驗,也可以搭配 nuqs。

CChia1104
文章8 分鐘閱讀

這一陣子的專案開發多半離不開 Next.js 的使用,其中 RSC 跟一般 Client Compnent 的交互使用也越來越頻繁。

其中有一個情境是當我在客戶端有個狀態會動態更新,例如使用者的搜尋 input,而這狀態需要更新到 RSC 的 props 中,而這時可以怎麼做呢?

什麼是 React Server Component (RSC)

React Server Components (RSC) 就是 在伺服器上執行的 React 元件,用來減少送到瀏覽器的 JavaScript,讓載入更快、體驗更順。

  • RSC 讓一部分 React 元件只在伺服器跑,不會被打包到瀏覽器,因此不會出現在 client bundle 裡。
  • 這些 Server Components 把畫面先算好,產生一份特殊格式的資料 (RSC payload),再傳給瀏覽器用來組成 HTML。

在 Next.js 使用 App Router 下 (app/),pagelayout 預設就是 Server Components,只有標記 "use client" 的檔案才會變成 Client Components。

這裡補充一下另一個指令 (Directives) - "use server",這指令是把該模組或方法標示成建置時需要在伺服器端生成的 Server Functions 而非 RSC,過去看過有人把這當成匯入 RSC 的依據 但這是錯誤的

若是在元件中寫這指令並寫在 render 方法中是會觸發 infinite loop 的!

DDoSMyself.tsx
"use server"; 

const DDoSMyself = async () => {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return <p>I'm a Server Function</p>
}
SharedComponent.tsx
import DDoSMySelf from "./DDoSMySelf"; 

const SharedComponent = () => {
  return <div>
    I'm a Shared Component
    <DDoSMySelf /> /* [!code --] */
  </div>
}

RSC 使用規則

由於 Server Component 會有不少跟伺服器端的互動,所以 RSC 在使用上是有一些明確規定的用法的。若是要避免這些元件或方法在錯誤的地方被引用的話可以用 React team 所做的這兩個 package:server-onlyclient-only

  • server-only:用於 Next.js 等框架中,以確保特定程式碼僅在 伺服器端運行,如果意外地將其匯入到客戶端程式碼中,則會拋出 建置時錯誤
  • client-only:用於 Next.js 等框架中,以確保特定程式碼僅在 客戶端運行,如果意外地將其匯入到伺服器端程式碼中,則會拋出 建置時錯誤

Server Component 不能做的事

  • 不能用 React hooks 做本地狀態或副作用:例如 useStateuseReduceruseEffect 等都是不允許的。
  • 不能用瀏覽器 API:像 windowdocumentlocalStorage 等任何只在瀏覽器存取的東西都不能用。
  • 不能直接掛事件處理:例如在 Server Component 裡下 onClickonChange 是不被允許的,需要把那一塊切成 Client Component。

Server Component 可以做的事

  • 可以是非同步的元件,例如在 render 階段直接 await DB 查詢或呼叫後端跟外部 API,很適合用來做資料抓取和組裝 UI。
  • 可以安全地存取 只在 server 有的東西,例如檔案系統、私密金鑰、環境變數、後端 SDK 等,不會被送到瀏覽器。
  • 可以把資料以可序列化 (serializable) 的 props 傳給 Client Components,例如字串、數字、物件、陣列、JSON 等,但不能傳 function、class instance、Date 等非序列化值。

Client / Server 互相引用的規則

  • Client Component 只能匯入 Client Component,不能直接 import Server Component,否則這檔案 會被視為 client bundle 被匯入
  • 反過來,Server Component 可以匯入 Client Component,然後用 JSX 去 它,把資料以 props 傳進去,形成 外層 server、內層 client 的結構。
  • 同理若是要在 Client Component 中使用 Server Component,也必須在 Client Component 中以 children 的形式去 Server Component。

RSC 狀態更新

前面稍微說明一下 RSC 的使用規範,有一點是 Client Component 無法匯入 Server Component。

那如果我們有一個課會端的狀態要跟新到 Server component 該如何做?

比如說我有一個搜尋的 input,這裡的狀態會去搜尋 DB 資料

SearchAction.tsx
"use client";

import { useState } from "react";

export const SearchAction = () => {
  const [search, setSearch] = useState("");

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
    </>
  );
};
ServerDataGrid.tsx
import "server-only";

const sql = async (args: TemplateStringsArray, ...values: any[]) => {
  const query = args.map((arg, i) => arg + (values[i] || "")).join("");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  return [];
};

export const ServerDataGrid = async ({ search }: { search: string }) => {
  const result = await sql`SELECT * FROM users WHERE name LIKE '%${search}%'`;
  return (
    <table>
      {result.map((row) => (
        <tr key={row.id}>{row.name}</tr>
      ))}
    </table>
  );
};

依照先前的規範,我們是無法在 SearchAction 直接 import ServerDataGrid 做使用的。

SearchAction.tsx
"use client";

import { useState } from "react";
import { ServerDataGrid } from "./ServerDataGrid"; 

export const SearchAction = () => {
  const [search, setSearch] = useState("");

  return (
    <>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <ServerDataGrid search={search} /> /* [!code --] */
    </>
  );
};

這時 React 就會透過 server-only 拋出這錯誤

server only error

You're importing a component that needs "server-only". That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component.
Learn more: https://nextjs.org/docs/app/building-your-application/rendering

透過 Search Params 更新狀態

前面有說 RSC 的生成是在伺服器端生成元件後再以 RSC Payload 的格式 streaming 到客戶端,這意味著我們今天要取得 RSC 一定是透過一個 fetch 的方式取得

fetch rsc

所以對於 React server 他也需要一個狀態來取得,意味著在伺服器端只要能接收到這些從客戶端發出的 incoming request 都可以,例如 Search Params 或 next/headersheaderscookies 都可以。

我們把剛剛的範例改一下:

SearchAction.tsx
"use client";

import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";

export const SearchAction = ({ children }: { children: React.ReactNode }) => {
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("search", e.target.value);
    router.push(`?${params.toString()}`);
  };

  return (
    <>
      <input value={searchParams.get("search") || ""} onChange={handleSearch} />
      {children}
    </>
  );
};

這時候我們改在最外層的 Server Component 做 import

app/page.tsx
import { SearchAction } from "./search-action";
import { ServerDataGrid } from "./server-data-grid";
import { Suspense } from "react";

export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string }> }) {
  const { search } = await searchParams;
  return (
    <SearchAction>
      <Suspense fallback={<div>Loading server data-grid...</div>}>
        <ServerDataGrid search={search} />
      </Suspense>
    </SearchAction>
  );
}

這樣就可以正常在 RSC 取得客戶端的狀態了。

另外要對 Search Params 做互動自己蠻推薦 nuqs 的,一個專門針對 Search Params 做狀態管理的套件,並可透過 parser 做驗證跟轉換,在 client side 跟 server side 都可做使用

nuqs

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104