# server action ## server action 簡介 `server action` 是一個只能執行在` server side` 的 `js asynchronus function`,其目的是希望跟 `client side` 的互動無需定義 `API` 直接處理 `server side` 的內容,他可以透過 `button` 觸發或是 `form` 的 `submit` 去執行例如 `db` 的造作或是 `file upload to server` 等等的事情。 ## 開始 Server Actions 前先談談 Server Side Render ( SSR ) 吧 早期的 `SSR` 開發方式跟現今的 `SSR` 如 `NextJS` 不太一樣,早期是透過例如 `PHP` 等框架,在 `server side` 負責處理不管是 `html` 內容,資料的 `fetching` 與 `mutation`, `client side` 只是一個載體負責 `render` `server` 端整理好的 `html` 。 ![image](https://hackmd.io/_uploads/HkCegyIHR.png) 但這樣顯然會有一個問題,每當有 `user` 重新造訪 `page` 或是換頁, `server` 就要重新發送 `request` 給 `client` 端去 `render`,這樣流量只要一大整體網站的效能會有明顯的短板,對 `user` 來說每次都需要等待很長一段時間等待頁面 `loading` 好才能互動,顯然這不是我們預期的結果,所以這時候就要提出 `client side render` 的解決方案。 ## 既然 Server Side 負擔太重有沒有辦法減輕負擔? 沒錯答案是有的,這個概念就叫做 `Client Side Rendering` ,跟 `SSR` 不一樣的地方在於,我們將 `page` 需要 `render` 的內容,不管是 `data fetching` 或是 `html layout` 通通都在 `client side` 完成, `server` 端只負責傳輸對應頁需要 `generate` 的 `code` 就好。 ![image](https://hackmd.io/_uploads/rkOZbJ8B0.png) 顯然透過 `CSR` 解決了傳統 `SSR` 所遇到的問題,從 `server` 負責所有 `user` 的 `loading` 壓力,轉移到各自的 `user` 上,使用上大大加速 `user` 的使用者體驗。 但儘管我們解決上述問題, `CSR` 卻有延伸另外的隱患。 * `SEO` 因為 `html` 的內容都需要透過 `client side` 去 `generate` 對於 `search engines ` 來說可能無法辨識網站的內容。 * `search engines ` 要如何爬到對應的頁面資料,是需要透過 `server side` 已經預先 `generate` 好的 `HTML template` 讓 `browser` 可以去下載,並在 `client side` 去 `hydrate` `page` 中需要互動的所有 `element`,這邊就提到另外一個重要的概念 `SSG`。 ## static site generation (SSG) 很多時候對於第一次學習 `SSR` 的人肯定都會聽過,因為要做 `SEO` 所以才需要用 `SSR` 框架,這樣說只能對一半,就上面敘述 `search engines` 的方式來說,如果只是單純讓 `page` 可以有 `SEO` ,你可能只需要 `SSG` 的而已,而不是 `SSR` 的完整內容,`SSR` 主要目的是優化 `page` 整體的效能,不管是 `pre-load` 或是 `pre fetch`甚至是優化 `LCP` 等等可以提升 `webvital` 指標的目的。 **總結 :** `SSG` 就是提前在 `server side` 先處理好 `user` 可能會造訪的頁面,`user` 點擊對應頁面,`server` 只需要 `response` 已經處理好的 `html layout` 給 `browser` 下載就好。 ![image](https://hackmd.io/_uploads/HkD8HkIH0.png) ## 簡單比較 `SSR` VS `SSG` | | 傳統 SSR | SSG | | -------- | -------- | -------- | | html 處理時間 | runtime | build time | 但一樣使用 `SSG` 還是會有一些問題 * 因為 `SSG` 需要在 `build time` 先 `generate` `html layout` 所以如果單純修改小地方,假如你有 100 頁共用,就要重新 `build` 一次。 * 無法做 ` dynamic content` 和 `real time data`。 ## React Server Components (RSC) and Actions 初探 儘管上面介紹 `SSR` 、`CSR`、`SSG` 等使用方式,彼此都還是有一些缺點,所以這時候 `RSC` 就等場拉~ 使用 `React Server Components` 搭配 `Server Action` 的好處就是,可以定義例如 `XYZ` 是 `Server component` ,`ABC` 是 `client component` ,可以根據不同性質選擇適合的 `component render` 方式。 * 因為不是所有的 `component` 都需要 `Server Side` 幫忙。 * 也不是所有頁面都需要 `SSG` `pre-build`。 * 甚至只需要簡單的 `CSR` 輔助就好,也不是所有的 `page` 都需要 `SEO` 。 ![image](https://hackmd.io/_uploads/H1X8sy8S0.png) ### Server Actions #### 什麼是 Server Actions? - **定義**:Server Actions是Next.js 14中引入的非同步函數,可在伺服器上執行,主要用於處理表單提交和資料變更。 - **應用場景**:適用於伺服器元件和用戶端元件中的資料取得和變更操作 **備註:** server action 不只是可以透過 `server component` 觸發,`client component` 也可以執行。 ### 優點 #### 減少客戶端JavaScript - **定義和運行環境**:伺服器操作僅在伺服器上定義和執行,不包含在客戶端套件中,減少了瀏覽器所需的JavaScript程式碼量。 - **效能提升**:減少初始頁面載入時間,提高整體效能。 #### 伺服器端資料變更 - **直接資料變更**:伺服器操作支援伺服器端直接進行資料變更,避免了建立和管理單獨的API端點。 - **效能最佳化**:透過減少不必要的網路往返,簡化資料管理,提高效能。 #### 增強訪問性 - **消耗了JavaScript即可運作**:由於Server Actions的JS程式碼無法傳送到客戶端,即使停用了JavaScript,表單和互動元素也能正常運作,確保了更多使用者群體的覆蓋範圍。 #### 效能改進 - **頁面載入速度和回應性**:透過減少客戶端JavaScript和支援伺服器端資料變更,提高了頁面載入速度和整個應用程式的回應性。 #### 更大的靈活性 - **廣泛的任務處理能力**:從簡單的資料檢索複雜的業務邏輯,伺服器操作能夠處理各種任務,成為網頁開發的強大工具。 #### 儲存資料的重新驗證 - **確保數據最新**:伺服器操作可以用於重新驗證伺服器數據,確保數據始終保持最新。 #### 使用者重定向 - **執行操作特定後重定向使用者**:例如,在成功登入後將使用者重新導向至主頁。 **總結:**`Server Actions ` 透過減少客戶端JavaScript、伺服器支援端資料變更、增強存取性、改進效能、提供更大的靈活性,移除 `API layout` 簡化複雜性, 直接資料操作可以專注在應用程面上提高開發效率。 ### 缺點 #### 不同的心智模型 - **學習曲線**:對於長期使用Next.js的開發者來說,Next.js 14的伺服器操作可能會感覺有些陌生,可能會引起一定的學習障礙。 #### component 切分 - **明顯的好處**:但第一次嘗試將客戶端邏輯與伺服器端邏輯分開可能會有些戰術。 你需要指定哪些元件應該在哪裡執行,哪些是客戶端元件,哪些是伺服器元件,以及它們如何良好運作。 #### data mutation and revalidate cache data - **cache 問題**:雖然這個模式讓一些人感到困惑,但如果你習慣了這種編寫邏輯的方式,一旦熟悉起來就沒有問題了。 ```typescript import prisma from "@/lib/db"; import { revalidatePath } from "next/cache"; export default function ContactForm() { async function addContact(formData: FormData) { "use server"; await prisma.contact.create({ data: { name: formData.get("name") as string, phone: formData.get("phone") as string, city: formData.get("city") as string, }, }); revalidatePath("/"); } return ( <form className="flex flex-col gap-2" action={addContact} > <input type="text" name="name" placeholder="Contact Name" /> <input type="text" name="phone" placeholder="Contact Phone Number" /> <input type="text" name="city" placeholder="Enter City" /> <button type="submit" className="bg-blue-500 text-white rounded-md p-1"> Add Contact </button> </form> ); } ``` 甚至你可以額外定義 `server aciotn` 到特定的檔案。 **備註:** 所有標注 `use server` 的 `file` 只能 `export async function` 否則 `nextjs` 會跟你吵架喔~ ```typescript // action.ts "use server"; export async function addContact(formData: FormData) { "use server"; await prisma.contact.create({ data: { name: formData.get("name") as string, phone: formData.get("phone") as string, city: formData.get("city") as string, }, }); revalidatePath("/"); } ``` 以上就是單純 `server action` 的由來與如何使用,那為了讓各位可以減少學習負擔外,筆者額外找了一個 `lib` 可以輕鬆上手 `server action` ,各位觀眾讓我們熱烈歡迎~ `ZSA` !!! ## ZSA 初探 `ZSA` 是一套為了 `server action` 而生的 `lib`。 * 擁有 `type safe` 保護的 `validation`,提供 `input` 以及 `output` `validate` 功能可以更清楚定義每個 `server action` 會使用到的 `schema`。 * 支援 `Procedures` 用法,類似於 `middleware` 可以共享每個 `function` 中會用到的 `context` 等等。 * 可以完整結合 `react query` ,找回是曾相似的感動。 ## 安裝 ```typescript > npm i zsa zsa-react zod ``` ## 定義 action ```typescript "use server" import { createServerAction } from "zsa" import z from "zod" export const incrementNumberAction = createServerAction() .input(z.object({ number: z.number() })) .handler(async ({ input }) => { // Sleep for .5 seconds await new Promise((resolve) => setTimeout(resolve, 500)) // Increment the input number by 1 return input.number + 1; }); ``` * createServerAction 去定義每個 `server action` 內容。 * input 去管理每個 `action` 會用到的參數。 * handler 則是你 `action` 中會執行的邏輯。 ## Calling from the server 使用時你可以直接呼叫就行。 ```typescript "use server" const [data, err] = await incrementNumberAction({ number: 24 }); if (err) { return; } else { console.log(data); // 25 } ``` ## Calling from the client 也可以在 `client component` 中呼叫,透過 `button` 去執行 `server action` 的內容~ ```typescript "use client" import { incrementNumberAction } from "./actions"; import { useState } from "react"; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui"; export default function IncrementExample() { const [counter, setCounter] = useState(0); return ( <Card> <CardHeader> <CardTitle>Increment Number</CardTitle> </CardHeader> <CardContent className="flex flex-col gap-4"> <Button onClick={async () => { const [data, err] = await incrementNumberAction({ number: counter, }) if (err) { // handle error return } setCounter(data); }} > Invoke action </Button> <p>Count:</p> </CardContent> </Card> ); } ``` 到這邊你一定有個疑問,如果我希望有 `loading` 判斷怎麼做,`zsa` 很貼心的幫你提供 `useServerAction` `hook` ~ ```typescript "use client" import { incrementNumberAction } from "./actions"; import { useServerAction } from "zsa-react"; import { useState } from "react"; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui"; export default function IncrementExample() { const [counter, setCounter] = useState(0); const { isPending, execute, data } = useServerAction(incrementNumberAction); return ( <Card> <CardHeader> <CardTitle>Increment Number</CardTitle> </CardHeader> <CardContent className="flex flex-col gap-4"> <Button disabled={isPending} onClick={async () => { const [data, err] = await execute({ number: counter, }) if (err) { // handle error return } setCounter(data); }} > Invoke action </Button> <p>Count:</p> <div>{isPending ? "saving..." : data}</div> </CardContent> </Card> ); } ``` ## Procedures 有時候我們需要做一些權限判斷去驗證說哪些行為是需要驗證過全現在執行的,這時就會需要用到 `Procedures` 的概念,類似於 `middleware` 在執行 `action` 前先做的 `callback function` 。 這邊就可以先定義一個 `authedProcedure` 給其他的 `server action` 共用,這時 `updateEmail` 因為繼承了 `authedProcedure` 的內容,所以無需在 `updateEmail` `handler` 中重新判斷 `user` 身份,有了 `Procedures` 幫助可以方便將共用的驗證邏輯抽離出來,並減少重複的程式碼。 ```typescript "use server" import { createServerActionProcedure } from "zsa" const authedProcedure = createServerActionProcedure() .handler(async () => { try { const { email, id } = await getUser(); return { user: { email, id, } } } catch { throw new Error("User not authenticated") } }) export const updateEmail = authedProcedure .createServerAction() .input(z.object({ newEmail: z.string() })).handler(async ({input, ctx}) => { const {user} = ctx // Update user's email in the database await db.update(users).set({ email: newEmail, }).where(eq(users.id, user.id)) return input.newEmail }) ``` ## Callbacks 甚至有提供完整的 `callback function` 去管理一切 `server action` 會遇到的生命週期~ ```typescript const exampleAction = createServerAction() .input(z.object({message: z.string()})) .onStart(async () => { console.log('onStart') }) .onSuccess(async () => { console.log('onSuccess') }) .onComplete(async () => { console.log('onComplete') }) .onError(async () => { console.log('onError') }) .onInputParseError(async () => { console.log('onInputParseError') }) .handler(async ({input}) => { console.log(input.message) }) ``` ## integration with React Query `ZSA` 還有提供更完善 `integration` ` React Query` 的方式 ### install ```typescript > npm i zsa-react-query @tanstack/react-query ``` ### QueryClient provider ```typescript // providers/react-query.ts "use client" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { useState } from "react" function ReactQueryProvider({ children }: React.PropsWithChildren) { const [client] = useState(new QueryClient()) return <QueryClientProvider client={client}>{children}</QueryClientProvider> } export default ReactQueryProvider ``` 然後包在 `layout` 中 ```typescript // app/layout.tsx export default function RootLayout({ children, }: { children: React.ReactNode }): JSX.Element { return ( <html lang="en"> <body> <ReactQueryProvider> {children} </ReactQueryProvider> </body> </html> ) } ``` ## client API 甚至 `client api` `zsa` 也幫你準備好。 ```typescript // lib/hooks/server-action-hooks.ts import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import { setupServerActionHooks } from "zsa-react-query" const { useServerActionQuery, useServerActionMutation, useServerActionInfiniteQuery, } = setupServerActionHooks({ hooks: { useQuery: useQuery, useMutation: useMutation, useInfiniteQuery: useInfiniteQuery, }, }) export { useServerActionInfiniteQuery, useServerActionMutation, useServerActionQuery, } ``` ## demo 一個簡單的 `server action` ```typescript "use server" import { createServerAction } from "zsa"; import z from "zod"; export const helloWorldAction = createServerAction() .input( z.object({ message: z.string(), }) ) .handler(async ({ input }) => { // sleep for .5 seconds await new Promise((resolve) => setTimeout(resolve, 500)) // update the message return { result: "Hello World: " + (input.message || "N/A"), } }) ``` `query in client` ```typescript "use client" import { helloWorldAction } from "./actions"; import { useServerActionQuery } from "@/lib/hooks/server-action-hooks"; export default function HelloWorld() { const [input, setInput] = useState("") const { isLoading, data } = useServerActionQuery(helloWorldAction, { input: { message: input, }, queryKey: [input], }) return ( <Card className="not-prose"> <CardHeader> <CardTitle>Say hello</CardTitle> <CardDescription> This card refetches your server action as you type </CardDescription> </CardHeader> <CardContent className="flex flex-col gap-4"> <Input placeholder="Message..." value={input} onChange={(e) => setInput(e.target.value)} /> {isLoading ? 'loading...' : data?.result} </CardContent> </Card> ) } ``` ## Query Key Factory `zsa` 也提供完整的 `query key` 整理。 ```typescript import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import { createServerActionsKeyFactory, setupServerActionHooks, } from "zsa-react-query" export const QueryKeyFactory = createServerActionsKeyFactory({ getPosts: () => ["getPosts"], getFriends: () => ["getFriends"], getPostsAndFriends: () => ["getPosts", "getFriends"], somethingElse: (id: string) => ["somethingElse", id], getRandomNumber: () => ["getRandomNumber"], }) const { useServerActionQuery, useServerActionMutation, useServerActionInfiniteQuery, } = setupServerActionHooks({ hooks: { useQuery: useQuery, useMutation: useMutation, useInfiniteQuery: useInfiniteQuery, }, queryKeyFactory: QueryKeyFactory, }) export { useServerActionInfiniteQuery, useServerActionMutation, useServerActionQuery, } ``` ```typescript "use server" import { createServerAction } from "zsa" import z from "zod" export const getRandomNumber = createServerAction() .input( z .object({ min: z.number(), max: z.number(), }) ) .handler(async ({ input, ctx }) => { await new Promise((r) => setTimeout(r, 500)) return { number: Math.floor(Math.random() * (input.max - input.min)) + input.min, } }) ``` 很神奇的是 `QueryKeyFactory` 就會提供良好的 `type infer` 目前 `factory` 中會用到的所有 `key` ```typescript "use client" import { useServerActionQuery } from "@/lib/hooks/server-action-hooks" import { getRandomNumber } from "./actions" export default function RandomNumberDisplay() { const { isLoading, isRefetching, isSuccess, data } = useServerActionQuery(getRandomNumber, { input: { min: 0, max: 100, }, queryKey: ['getRandomNumber'], //this is now typesafe due to our QueryKeyFactory }) return ( <Card className="not-prose"> <CardHeader> <CardTitle>Random number</CardTitle> <CardDescription> This fetches a random number upon mounting </CardDescription> </CardHeader> <CardContent className="flex flex-col gap-4"> <p>Random number:</p> {isSuccess && ( <>{JSON.stringify(data.number)}</> )} {isLoading ? " loading..." : ""} {isRefetching ? " refetching..." : ""} </CardContent> </Card> ) } ``` ```typescript "use client" import { QueryKeyFactory } from "@/lib/hooks/server-action-hooks" import { useQueryClient } from "@tanstack/react-query" export default function RandomNumberRefetch() { const queryClient = useQueryClient() return ( <Card className="p-4 w-full "> <Button onClick={() => { queryClient.refetchQueries({ queryKey: QueryKeyFactory.getRandomNumber(), //return the same query key as defined in our factory }) }} className="w-full" > refetch </Button> </Card> ) } ``` **總結:** 但筆者目前使用 `ZSA` 提供的 `queryKey factory` 有遇到不少 `type` 問題,例如 `key` 只能傳 `string` ,其餘會報 `type error` ,所以可以思考一下是不是直接使用 `reqct query v5` 提供的 `query option` 就好。