12
章節12
資料異動
在上一章中,您使用 URL 搜尋參數和 Next.js API 實作了搜尋和分頁功能。讓我們繼續處理發票頁面,加入建立、更新和刪除發票的功能!
本章內容…
以下是我們將涵蓋的主題
什麼是 React 伺服器動作以及如何使用它們來異動資料。
如何使用表單和伺服器元件。
使用原生 formData
物件的最佳實務,包括類型驗證。
如何使用 revalidatePath
API 重新驗證客戶端快取。
如何使用特定 ID 建立動態路由區段。
什麼是伺服器動作?
React 伺服器動作允許您直接在伺服器上執行非同步程式碼。它們消除了建立 API 端點來異動資料的需求。您可以改為編寫在伺服器上執行的非同步函式,並可從您的客戶端或伺服器元件呼叫這些函式。
安全性是網頁應用程式的首要任務,因為它們容易受到各種威脅。這就是伺服器動作的用武之地。它們提供有效的安全解決方案,可防禦不同類型的攻擊,保護您的資料並確保授權存取。伺服器動作透過 POST 請求、加密閉包、嚴格的輸入檢查、錯誤訊息雜湊和主機限制等技術來實現此目標,所有這些技術共同作用,可顯著提升應用程式的安全性。
使用伺服器動作搭配表單
在 React 中,您可以使用 <form>
元素中的 action
屬性來呼叫動作。該動作會自動接收原生 FormData 物件,其中包含擷取的資料。
例如:
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
在伺服器元件中呼叫伺服器動作的優點是漸進式增強 - 即使客戶端停用了 JavaScript,表單也能正常運作。
Next.js 與伺服器動作
伺服器動作也與 Next.js 的快取機制深度整合。當透過伺服器動作提交表單時,您不僅可以使用該動作來變更資料,還可以利用 revalidatePath
和 revalidateTag
等 API 重新驗證相關的快取。
讓我們看看這一切是如何協同工作的!
建立發票
以下是建立新發票的步驟:
- 建立一個表單來擷取使用者的輸入。
- 建立一個伺服器動作並從表單中呼叫它。
- 在您的伺服器動作內,從
formData
物件中提取資料。 - 驗證並準備要插入資料庫的資料。
- 插入資料並處理任何錯誤。
- 重新驗證快取並將使用者重新導向回發票頁面。
1. 建立新的路由和表單
首先,在 /invoices
資料夾內,新增一個名為 /create
的路由區段,並建立一個 page.tsx
檔案。


您將使用此路由來建立新的發票。在 page.tsx
檔案內,貼上以下程式碼,並花些時間研究它。
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
const customers = await fetchCustomers();
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Create Invoice',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
);
}
您的頁面是一個伺服器元件,它會擷取 customers
並將其傳遞給 <Form>
元件。為了節省時間,我們已經為您建立了 <Form>
元件。
瀏覽至 <Form>
元件,您會看到該表單
- 有一個包含**客戶**清單的
<select>
(下拉式選單)元素。 - 有一個用於輸入**金額**的
<input>
元素,其type="number"
。 - 有兩個用於設定狀態的
<input>
元素,其type="radio"
。 - 有一個
type="submit"
的按鈕。
在 https://127.0.0.1:3000/dashboard/invoices/create 上,您應該會看到以下 UI


2. 建立伺服器動作
很好,現在讓我們建立一個將在提交表單時呼叫的伺服器動作。
瀏覽至您的 lib
目錄,並建立一個名為 actions.ts
的新檔案。在此檔案的頂部,新增 React use server
指令
'use server';
透過新增 'use server'
,您可以將檔案內所有匯出的函式標記為伺服器動作。然後,這些伺服器函式就可以在客戶端和伺服器元件中匯入和使用。
您也可以直接在伺服器元件內撰寫伺服器動作,方法是在動作內新增 "use server"
。但在本課程中,我們會將它們全部整理在一個單獨的檔案中。
在您的 actions.ts
檔案中,建立一個接受 formData
的新的非同步函式。
'use server';
export async function createInvoice(formData: FormData) {}
接著,在您的 <Form>
元件中,從 actions.ts
檔案匯入 createInvoice
。在 <form>
元素中新增一個 action
屬性,並呼叫 createInvoice
動作。
import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
export default function Form({
customers,
}: {
customers: customerField[];
}) {
return (
<form action={createInvoice}>
// ...
)
}
注意事項:在 HTML 中,您會將網址傳遞給
action
屬性。這個網址將會是表單資料提交的目的地(通常是一個 API 端點)。然而,在 React 中,
action
屬性被視為一個特殊的 prop - 意味著 React 在其基礎上建構,允許呼叫動作。在幕後,伺服器動作會建立一個
POST
API 端點。這就是為什麼使用伺服器動作時,您不需要手動建立 API 端點。
3. 從 formData
中提取資料
回到您的 actions.ts
檔案,您需要提取 formData
的值,您可以使用幾種方法。在此範例中,讓我們使用 .get(name)
方法。
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Test it out:
console.log(rawFormData);
}
提示:如果您正在處理具有許多欄位的表單,您可能會考慮使用
entries()
方法搭配 JavaScript 的Object.fromEntries()
。例如:
const rawFormData = Object.fromEntries(formData.entries())
要檢查所有內容是否已正確連接,請嘗試使用該表單。提交後,您應該會在終端機中看到您剛輸入表單的資料記錄。
現在您的資料已是物件的形狀,將更容易處理。
4. 驗證並準備資料
在將表單資料傳送到資料庫之前,您需要確保資料格式和類型正確。如果您還記得在本課程的前面部分,您的 invoices 資料表預期資料的格式如下:
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
到目前為止,您只有來自表單的 customer_id
、amount
和 status
。
類型驗證和強制轉換
驗證表單資料與資料庫中預期的類型一致非常重要。例如,如果您在 action 裡面加入 console.log
console.log(typeof rawFormData.amount);
您會注意到 amount
的類型是 string
而不是 number
。這是因為 type="number"
的 input
元素實際上返回的是字串,而不是數字!
要處理類型驗證,您有幾種選擇。雖然您可以手動驗證類型,但使用類型驗證函式庫可以節省您的時間和精力。在您的範例中,我們將使用 Zod,這是一個以 TypeScript 優先的驗證函式庫,可以簡化您的這項任務。
在您的 actions.ts
檔案中,導入 Zod 並定義一個與表單物件形狀相符的 schema。這個 schema 將在儲存到資料庫之前驗證 formData
。
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
amount
欄位特別設定為從字串強制轉換(更改)為數字,同時也驗證其類型。
然後您可以將 rawFormData
傳遞給 CreateInvoice
來驗證類型。
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
以分為單位儲存值
通常,在資料庫中以分為單位儲存貨幣值是一個很好的做法,這樣可以消除 JavaScript 浮點數錯誤並確保更高的準確性。
讓我們將金額轉換成分。
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
}
建立新的日期
最後,讓我們以「YYYY-MM-DD」的格式為發票建立日期建立一個新的日期。
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
}
5. 將資料插入資料庫
現在您已具備資料庫所需的所有值,您可以建立一個 SQL 查詢,將新發票插入資料庫並傳入變數。
import { z } from 'zod';
import { sql } from '@vercel/postgres';
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
目前,我們沒有處理任何錯誤。我們將在下一章處理。現在,讓我們繼續下一步。
6. 重新驗證並重新導向
Next.js 有一個客戶端路由器快取,會將路由區段儲存在使用者瀏覽器中一段時間。搭配預取,這個快取可確保使用者在路由之間快速導航,同時減少向伺服器發出的請求次數。
由於您正在更新發票路由中顯示的資料,因此您需要清除此快取並觸發對伺服器的新請求。您可以使用 Next.js 的 revalidatePath
函式來執行此操作。
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
}
更新資料庫後,將重新驗證 /dashboard/invoices
路徑,並從伺服器擷取新的資料。
此時,您也需要將使用者重新導向回 /dashboard/invoices
頁面。您可以使用 Next.js 的 redirect
函式來執行此操作。
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// ...
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
恭喜!您剛剛實作了您的第一個伺服器動作。如果一切正常,請透過新增發票來測試它。
- 您應該在提交時被重新導向到
/dashboard/invoices
路由。 - 您應該在表格頂部看到新發票。
更新發票
更新發票的表單與建立發票的表單類似,除了您需要傳遞發票 id
以更新資料庫中的記錄。讓我們看看如何取得和傳遞發票 id
。
以下是更新發票的步驟:
- 使用發票
id
建立新的動態路由區段。 - 從頁面參數中讀取發票
id
。 - 從資料庫中擷取特定的發票。
- 使用發票資料預先填寫表單。
- 更新資料庫中的發票資料。
1. 使用發票 id
建立動態路由區段
當您不知道確切的區段名稱,並且想要根據資料建立路由時,Next.js 允許您建立動態路由區段。這可以是部落格文章標題、產品頁面等等。您可以透過將資料夾名稱用方括號括起來來建立動態路由區段。例如,[id]
、[post]
或 [slug]
。
在您的 /invoices
資料夾中,建立一個名為 [id]
的新動態路由,然後建立一個名為 edit
的新路由,並包含一個 page.tsx
檔案。您的檔案結構應該如下所示
![Invoices folder with a nested [id] folder, and an edit folder inside it](/_next/image?url=%2Flearn%2Flight%2Fedit-invoice-route.png&w=3840&q=75&dpl=dpl_4FFcrev3cFP2zwnf13Q6F7Kw9i3v)
![Invoices folder with a nested [id] folder, and an edit folder inside it](/_next/image?url=%2Flearn%2Fdark%2Fedit-invoice-route.png&w=3840&q=75&dpl=dpl_4FFcrev3cFP2zwnf13Q6F7Kw9i3v)
在您的 <Table>
元件中,請注意有一個 <UpdateInvoice />
按鈕,它會從表格記錄中接收發票的 id
。
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
return (
// ...
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</td>
// ...
);
}
導覽至您的 <UpdateInvoice />
元件,並更新 Link
的 href
以接受 id
屬性。您可以使用樣板字面值來連結到動態路由區段
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
// ...
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
2. 從頁面 params
讀取發票 id
回到您的 <Page>
元件,貼上以下程式碼
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
請注意它與您的 /create
發票頁面類似,除了它導入了不同的表單(來自 edit-form.tsx
檔案)。此表單應該使用客戶名稱、發票金額和狀態的 defaultValue
預先填寫。要預先填寫表單欄位,您需要使用 id
擷取特定的發票。
除了 searchParams
之外,頁面元件也接受一個名為 params
的屬性,您可以使用它來存取 id
。更新您的 <Page>
元件以接收該屬性
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
// ...
}
3. 擷取特定發票
然後
- 導入一個名為
fetchInvoiceById
的新函式,並將id
作為參數傳遞。 - 導入
fetchCustomers
以擷取下拉式選單的客戶名稱。
您可以使用 Promise.all
同時擷取發票和客戶
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
您會在終端機中看到 invoice
屬性的暫時 TS 錯誤,因為 invoice
可能會是 undefined
。現在不用擔心,您將在下一章新增錯誤處理時解決它。
太棒了!現在,測試一下所有東西是否都正確連接。請訪問 https://127.0.0.1:3000/dashboard/invoices 並點擊鉛筆圖示以編輯發票。導覽後,您應該會看到一個預先填入發票詳細資訊的表單。


網址也應該使用 id
更新,如下所示:https://127.0.0.1:3000/dashboard/invoice/uuid/edit
UUID 與自動遞增鍵值
我們使用 UUID,而不是遞增鍵值(例如 1、2、3 等)。雖然這會讓網址變長;然而,UUID 可消除 ID 衝突的風險,具有全域唯一性,並降低列舉攻擊的風險,使其成為大型資料庫的理想選擇。
但是,如果您偏好更簡潔的網址,您可能會選擇使用自動遞增鍵值。
4. 將 id
傳遞給伺服器動作
最後,您需要將 id
傳遞給伺服器動作,以便更新資料庫中的正確記錄。您**不能**像這樣將 id
作為參數傳遞
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
相反,您可以使用 JS bind
將 id
傳遞給伺服器動作。這將確保傳遞給伺服器動作的任何值都會被編碼。
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return (
<form action={updateInvoiceWithId}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
**注意:**在表單中使用隱藏的輸入欄位也可以(例如
<input type="hidden" name="id" value={invoice.id} />
)。但是,這些值將以純文字形式顯示在 HTML 原始碼中,這對於 ID 等敏感資料來說並不理想。
然後,在您的 actions.ts
檔案中,建立一個新的動作 updateInvoice
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
與 createInvoice
動作類似,這裡您正在
- 從
formData
中提取資料。 - 使用 Zod 驗證類型。
- 將金額轉換為分。
- 將變數傳遞給您的 SQL 查詢。
- 呼叫
revalidatePath
清除客戶端快取並發出新的伺服器請求。 - 呼叫
redirect
將使用者重新導向至發票頁面。
透過編輯發票來測試它。提交表單後,您應該會被重新導向至發票頁面,並且發票應該已更新。
刪除發票
要使用伺服器動作刪除發票,請將刪除按鈕包含在 <form>
元素中,並使用 bind
將 id
傳遞給伺服器動作。
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
在您的 actions.ts
檔案中,建立一個名為 deleteInvoice
的新動作。
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
由於此動作是在 /dashboard/invoices
路徑中呼叫的,因此您不需要呼叫 redirect
。呼叫 revalidatePath
將會觸發新的伺服器請求並重新渲染表格。
延伸閱讀
在本章中,您學習了如何使用伺服器動作 (Server Actions) 來變更資料。您也學習了如何使用 revalidatePath
API 來重新驗證 Next.js 快取,以及使用 redirect
將使用者重新導向到新的頁面。
您還可以閱讀更多關於 伺服器動作的安全性 以獲得更多學習資源。
這有幫助嗎?