跳至主要内容

React Query and Forms

表單是許多 web 應用程式中更新資料的主要方式。我們用 React Query 不只拿資料(react query as a state manager),也會用來修改資料,所以一定會遇到要把這個強大的 async 狀態管理工具和表單整合的情境。

好消息是,其實表單本身沒什麼特別的:它就是一堆 html 元素,用來顯示資料。但當我們想要修改這些資料時,server state 跟 client state 的界線就會變得模糊,這也是複雜度的來源。

Server State vs. Client State​

簡單回顧一下,Server State 是我們「不擁有」的狀態,通常是 async 的,只能看到上次抓到的快照。

Client State 則是前端完全掌控的狀態,通常是同步的,隨時都知道最新值。

如果我們顯示一個人員列表,這肯定是 server state。但如果點進去顯示細節、打算編輯,這時 server state 會變成 client state 嗎?還是混合體?

最簡單的做法​

我之前寫過,不太建議把 state 從一個 state manager 複製到另一個(像是 putting props to useState 或 React Query 複製到 local state)。

但表單是個例外,只要你知道 tradeoff 並有意識地這麼做。大多數情況下,表單只需要把 server state 當作初始值。我們抓到 firstName、lastName,放進 form state,然後讓使用者編輯。

來看個例子:

// simple-form
function PersonDetail({ id }) {
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
const { register, handleSubmit } = useForm()
const { mutate } = useMutation({
mutationFn: (values) => updatePerson(values),
})

if (data) {
return (
<form onSubmit={handleSubmit(mutate)}>
<div>
<label htmlFor="firstName">First Name</label>
<input
{...register('firstName')}
defaultValue={data.firstName}
/>
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input
{...register('lastName')}
defaultValue={data.lastName}
/>
</div>
<input type="submit" />
</form>
)
}

return 'loading...'
}

這樣其實很好用——但有什麼 tradeoff?

資料可能是 undefined​

你可能知道 useForm 也可以直接用 defaultValues 初始化整個表單,對大表單很方便。但因為不能條件式呼叫 hook,而 data 在第一次 render 時是 undefined,所以不能直接這樣寫:

// no-default-values
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
// 🚨 這樣 form 會用 undefined 初始化
const { register, handleSubmit } = useForm({ defaultValues: data })

用 useState 複製、或用 uncontrolled form(react-hook-form 其實底層就是這樣)也會遇到一樣的問題。最好的解法是把表單拆成獨立元件:

// separate-form
function PersonDetail({ id }) {
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
const { mutate } = useMutation({
mutationFn: (values) => updatePerson(values),
})

if (data) {
return <PersonForm person={data} onSubmit={mutate} />
}

return 'loading...'
}

function PersonForm({ person, onSubmit }) {
const { register, handleSubmit } = useForm({ defaultValues: person })
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="firstName">First Name</label>
<input {...register('firstName')} />
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input {...register('lastName')} />
</div>
<input type="submit" />
</form>
)
}

這樣其實不錯,資料抓取跟呈現分開。我個人不太愛這種拆法,但這裡確實很實用。

沒有背景更新​

React Query 的核心就是讓 UI 跟 server state 保持同步。但只要你把 state 複製到別的地方,React Query 就沒辦法自動更新。如果背景 refetch 拿到新資料,form state 不會自動跟著變。這通常沒差(像個人資料表單),但這種情境下建議把 staleTime 設大一點,避免不必要的背景更新:

// no-background-updates
// ✅ 不要背景自動更新
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
staleTime: Infinity,
})

這種做法在大表單或多人協作時會有問題。表單越大,填寫時間越長。如果多人同時編輯不同欄位,最後一個送出的人會蓋掉別人改的值,因為畫面上看到的資料可能已經過時。

react-hook-form 可以偵測哪些欄位被改過,只送「dirty」欄位到 server(範例),這很酷。但這還是沒辦法讓你看到別人剛剛改過的最新值。也許你會因為沒看到最新資料而做出不同的輸入。

那要怎麼讓表單編輯時還能反映背景更新?

保持背景更新​

一種做法是嚴格分開 state。Server State 交給 React Query,Client State 只記錄使用者改過的欄位。畫面上顯示的資料就是這兩者的衍生狀態(derived state):有改過就用 client state,沒改過就用 server state:

// separate-states
function PersonDetail({ id }) {
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
const { control, handleSubmit } = useForm()
const { mutate } = useMutation({
mutationFn: (values) => updatePerson(values),
})

if (data) {
return (
<form onSubmit={handleSubmit(mutate)}>
<div>
<label htmlFor="firstName">First Name</label>
<Controller
name="firstName"
control={control}
render={({ field }) => (
// ✅ client state 有值就用,否則用 server state
<input
{...field}
value={field.value ?? data.firstName}
/>
)}
/>
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<Controller
name="lastName"
control={control}
render={({ field }) => (
<input
{...field}
value={field.value ?? data.lastName}
/>
)}
/>
</div>
<input type="submit" />
</form>
)
}

return 'loading...'
}

這樣就能保留背景更新,沒改過的欄位還是會反映最新 server state。這種做法的 caveat:

你需要 controlled fields​

目前沒什麼好方法能用 uncontrolled fields 實作這種 pattern,所以上面才用 controlled fields。如果有更好的做法歡迎告訴我。

衍生狀態可能很難寫​

這種做法對淺層表單很適合,可以用 nullish coalesce fallback 到 server state,但如果是巢狀物件就比較難 merge。有時候背景自動改值 UX 也不一定好,可能只要標示「有欄位不同步」讓使用者自己決定要不要更新會更好。


不管你選哪種做法,都要清楚知道各自的優缺點。

Tips & Tricks​

除了上面兩種主要做法,這裡還有一些 React Query + 表單的小技巧:

防止重複送出​

可以用 useMutation 回傳的 isLoading,只要 mutation 還在跑就 disable 送出按鈕:

// disabled-submit
const { mutate, isLoading } = useMutation({
mutationFn: (values) => updatePerson(values)
})
<input type="submit" disabled={isLoading} />

mutation 後失效查詢並重設表單​

如果送出表單後沒有跳轉至不同頁面,建議在失效查詢後再 reset 表單。像這篇 (mastering-mutations-in-react-query#some-callbacks-might-not-fire) 說的,最好在 mutate 的 onSuccess callback 做這件事。如果 state 分開管理,只要 reset 成 undefined 就會自動用 server state:

// reset-form
function PersonDetail({ id }) {
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: ['person', id],
queryFn: () => fetchPerson(id),
})
const { control, handleSubmit, reset } = useForm()
const { mutate } = useMutation({
mutationFn: updatePerson,
// ✅ return Promise from invalidation
// so that it will be awaited
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['person', id] }),
})

if (data) {
return (
<form
onSubmit={handleSubmit((values) =>
// ✅ reset client state back to undefined
mutate(values, { onSuccess: () => reset() })
)}
>
<div>
<label htmlFor="firstName">First Name</label>
<Controller
name="firstName"
control={control}
render={({ field }) => (
<input
{...field}
value={field.value ?? data.firstName}
/>
)}
/>
</div>
<input type="submit" />
</form>
)
}

return 'loading...'
}

本文原始連結​

React Query and Forms