昨天介紹了如何在 <form>
的表單提交後,透過 action
prop 觸發 Server Actions。但假如今天表單中不只一個按鈕,我希望 type='submit'
以外的按鈕能觸發其他 actions 呢?又甚至是在表單以外使用 Server Actions 呢?
假如還不知道 Server Actions 是什麼的讀者,建議先閱讀昨天的文章:Day 23 - 再多利用 Server 一點點:Route Handler & Server Actions
今天就來探索不同情境呼叫 Server Actions 的方法吧!
假如表單中不只一個按鈕,例如:
export default function ToDo() {
const addTask = async () => {
'use server';
...
};
const onSubmit = async () => {
'use server';
...
};
return (
<form action={onSubmit}>
<button>Add a Task</button>
<button type='submit'>Submit</button>
</form>
);
}
我們希望 Add a Task 按鈕能觸發另個 Server Action addTasks()
,則可以使用 formAction
prop:
export default function ToDo() {
const addTasks = async () => {
'use server';
...
};
const onSubmit = async () => {
'use server';
...
};
return (
<form action={onSubmit}>
<button formAction={addTask}>Add a Task</button>
<button type='submit'>Submit</button>
</form>
);
}
完成後,按下 Add a Task 按鈕後就會呼叫 addTask()
,而按下 Submit 則會呼叫 onSubmit()
。
補充:
formAction
除了可以用在<button>
以外,也可以用在<input type='submit'>
和<input type='image'>
上。
這邊要注意,假如要使用 formAction
來呼叫 Server Actions,按鈕必須包在 <form>
裡面。假如我把上面的 JSX 改成:
export default function ToDo() {
const addTasks = async () => {
'use server';
...
};
// 點擊按鈕後不會觸發 addTasks()
return (
<button formAction={addTasks}>Add a Task</button>
);
}
這樣點擊 Add a Task 不會有效果,必須在按鈕外包一層 <form>
:
export default function ToDo() {
const addTasks = async () => {
'use server';
...
};
return (
<form>
<button formAction={addTasks}>Add a Task</button>
</form>
);
}
但假如我想在 <form>
以外的地方使用呢?
我們也可以直接透過 onClick 事件來呼叫 Server Actions。
比方說,上述的例子,我們希望在表單下加一顆按鈕,點擊後會觸發 Server Action addData()
,寫一筆固定的資料進 DB,並更新畫面:
/* utils/actions.ts */
export const addData = async (data: UserData) => {
try {
await addDoc(collection(db, 'users'), data);
revalidatePath('/users');
} catch (error) {
console.log(error);
}
};
我們可以直接在按鈕的 onClick 事件呼叫 addData()
:
/* app/users/page.tsx */
'use client';
import { addData } from '../utils/action';
export default function Form() {
const data = {
name: 'test',
email: 'test@gmail.com',
age: 20,
};
return (
<>
...
<button onClick={() => addData(data)}>Add Data</button>
</>
);
}
這樣點擊 add Data 時,就會增加一筆資料進 DB,完成後也會更新畫面:
但在點擊 Add Data 後,到畫面更新仍然有個時間差。因為不是在 <form>
中觸發 Server Actions,無法透過 useFormStatus 的 pending
來判斷 actions 狀態,這樣有辦法在表單更新前,製造 loading 效果嗎?
有的!可以使用 React 的 useTransition
hook。
useTransition 也是 React 18 正式推出的 hook 之一,主要功能是劃分 state 更新的優先順位,讓某個 state 更新時,不要凍結 UI。
當處理順位較低的 state 觸發的 re-render 時,假如過程中有順位較高的 state 更新,則會中斷順位較低的 state 更新,先行更新順位較高的 state 與處理 re-render。
舉例來說,今天頁面包含一個用戶清單 <UserList>
和一個計數器 <Counter>
。<UserList>
會在使用這按下 Show Users 後觸發 state 更新,來顯示用戶名單。但因為用戶數量很多,state 更新和 re-render 需要一點時間:
'use client';
import { useState, useTransition } from 'react';
const UserList = memo(function UserList() {
// 怕 code 太長,故省略 UserList 邏輯
...
});
export default function Page() {
const [isDisplay, setIsDisplay] = useState(false);
const [count, setCount] = useState(0);
const showUser = () => {
setIsDisplay(true);
};
return (
<div className='...'>
<div className='...'>
<button className='...' onClick={showUser}>
Show Users
</button>
<div className='...'>
{isDisplay && <UserList />}
</div>
</div>
<Counter count={count} setCount={setCount} />
</div>
);
}
一般狀況,re-render 完成前,畫面會凍結,所以 re-render 完成前我點擊計數器的 +1 按鈕,計數器會等到 <UserList>
re-render 完成後才會接著 re-render:
為了不讓 <UserList>
的 re-render 讓畫面其他部分無法運作,這時候就可以使用 useTransition 中的 startTransition
包住 setIsDisplay
來降低它的更新排序。假如更新過程有觸發其他 state 更新,比方說點擊 +1 後要更新 count
,會優先更新 count
並 re-render:
export default function Page() {
...
const [isPending, startTransition] = useTransition();
const showUser = () => {
// 加入 startTransition
startTransition(() => {
setIsDisplay(true);
});
};
return (
<div className='...'>
<div className='...'>
<button className='...' onClick={showUser}>
Show Users
</button>
<div className='...'>
{isDisplay && <UserList />}
</div>
</div>
<Counter count={count} setCount={setCount} />
</div>
);
}
從上方程式碼可以發現,useTransition return 的 array 中還有一個 isPending
。顧名思義,可以讓我們判斷 transition 是否完成了。所以再提升一點 UX,我們可以用 isPending
來讓 <UserList>
re-render 完成前先顯示 Loading...:
export default function Page() {
...
const [isPending, startTransition] = useTransition();
const showUser = () => {
startTransition(() => {
setIsDisplay(true);
});
};
return (
<div className='...'>
<div className='...'>
<button className={buttonStyle} onClick={showUser}>
Show Users
</button>
<div className='h-[150px] overflow-auto'>
{isDisplay && <UserList />}
// 加入 Loading 特效
{isPending && <div>Loading...</div>}
</div>
</div>
<Counter count={count} setCount={setCount} />
</div>
);
}
完成後,當點擊 Show Users 時,會觸發<UserList>
re-render,完成前先會顯示 Loading...,同時間點擊 +1 一樣可以正常更新計數:
所以假如想在 <form>
以外呼叫 Server Actions,又希望畫面更新前有 loading 提示,就可以透過 useTransition。
只需要把 addData 包進 startTransition
,再讓isPending
為 true 時顯示 loading UI:
/* app/users/page.tsx */
'use client';
import { useTransition } from 'react';
import { addData} from '../utils/action';
export default function Form() {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
// 用 startTransition 包住 addData
startTransition(() =>
addData({
name: 'test',
email: 'test@gmail.com',
age: 20,
})
);
};
return (
<>
...
<button onClick={handleClick}>add Data</button>
// isPending 為 true 則顯示 Loading...
{isPending && <div>Loading...</div>}
</>
);
}
完成後,點擊 Add Data 就一樣可以觸發 Server Actions 來新增資料到 DB,並讓使用者列表重新渲染。除此之外,在畫面出現新增的用戶前,按鈕底下會有個 loading 提示:
學會怎麼使用 Server Actions 後,明天會帶大家嘗試理解 Server Actions 背後的原理,以及分享我在網路上看到一些針對 Server Actions 目前資安上的疑慮,和是否要使用的考慮因素。
謝謝大家耐心的閱讀,我們明天見!