跳至主要内容

使用 useMutation 進行樂觀更新 (Optimistic Updates)

不喜歡看字的可以看影片:

簡介

在處理 API 新增、修改或刪除 操作時,使用者通常會遇到以下問題:

  • 等待 API 回應時,畫面沒有立即反應,導致體驗不流暢
  • 請求失敗時,UI 需要回滾 (Rollback),但手動處理較繁瑣
  • 在多個元件內部顯示相同的資料時,如何讓 UI 立即同步變更?

這時候,Vue Query 提供的 useMutation 搭配樂觀更新 (Optimistic Updates),可以讓 UI 先更新,再等待 API 回應。

依我工作的經驗蠻常遇到這個問題,因為我們後端的 node 還需要發送請求到 python 處理資料,所以會有蠻大的延遲,這時候如果可以先讓 UI 更新,再等待後端回應,可以提升使用者體驗。

要看到效果的話,建議開 F12 的 DevTools,把 Network 的 Throttling 調整為 3G,這樣可以看到明顯的延遲,測試完記得調回 No throttling

Image


設定 JSON Server 作為測試 API

如果還沒安裝 JSON Server 的,可以參考一下影片

npx json-server --watch db.json --port 3002

然後在 db.json 中新增幾筆 todos 資料:

{
"todos": [
{ "id": 1, "title": "待辦事項 1" },
{ "id": 2, "title": "待辦事項 2" },
{ "id": 3, "title": "待辦事項 3" }
]
}

Mutation 與 Query 在同元件

如果我們的 MutationQuery 都在同個元件的話,可以讓 UI 直接渲染 isPendingisError 來反應變更。

以下程式碼的說明:

  • 新增時 UI 立即顯示一筆新的項目 (opacity 0.5),等 API 回應後變為正式資料,這邊透過 isPending 來判斷是否顯示。
  • Mutation 失敗時 UI 自動刪除該項目,並提供「重試」按鈕。
  • 適用於單一元件內部處理 MutationQuery
App.vue
<script setup>
import { ref } from "vue";
import { useQuery, useMutation, useQueryClient } from "@tanstack/vue-query";
import axios from "axios";

const queryClient = useQueryClient();
const newTodoTitle = ref("");

const fetchTodos = async () => {
const { data } = await axios.get("http://localhost:3002/todos");
return data;
};

const { data: todos, isLoading } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});

const {mutate,isPending, isError, variables} = useMutation({
mutationFn: async (newTodo) => {
const { data } = await axios.post("http://localhost:3002/todos", newTodo);
return data;
},
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});

const addTodo = () => {
if (!newTodoTitle.value) return;
mutate({ title: newTodoTitle.value });
newTodoTitle.value = "";
};
</script>

<template>
<div>
<h2>待辦事項</h2>
<input v-model="newTodoTitle" placeholder="輸入新的待辦事項" />
<button @click="addTodo" :disabled="isPending">
{{ isPending ? "新增中..." : "新增" }}
</button>

<ul>
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
<li v-if="isPending" style="opacity: 0.5">{{ variables?.title }}</li>
</ul>

<p v-if="isError" style="color: red">新增失敗!<button @click="mutate(variables)">重試</button></p>
</div>
</template>

透過快取進行樂觀更新

這種方式適用於 MutationQuery 在不同元件內,但我們方便 Demo 所以這邊會在同個元件內進行。

以下程式碼的說明:

  • Mutation 發送時,先暫停 todos快取更新 (cancelQueries),防止 API 回應覆蓋 UI,詳細的說明可以看影片
  • Mutation 成功時,直接透過 setQueryData 更新快取,確保 UI 立即同步。
  • Mutation 失敗時,還原 (rollback) 原始資料,確保 UI 不顯示錯誤資訊。
App.vue
<script setup>
import { ref } from "vue";
import { useQuery, useMutation, useQueryClient } from "@tanstack/vue-query";
import axios from "axios";

const queryClient = useQueryClient();
const newTodoTitle = ref("");

const fetchTodos = async () => {
const { data } = await axios.get("http://localhost:3002/todos");
return data;
};

const { data: todos, isLoading } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});

const { mutate, isPending, isError } = useMutation({
mutationFn: async (newTodo) => {
const { data } = await axios.post("http://localhost:3002/todos", newTodo);
return data;
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });

const previousTodos = queryClient.getQueryData(["todos"]);

queryClient.setQueryData(["todos"], (old) => [...old, { id: Date.now(), ...newTodo }]);

return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});

const addTodo = () => {
if (!newTodoTitle.value) return;
mutate({ title: newTodoTitle.value });
newTodoTitle.value = "";
};
</script>

<template>
<div>
<h2>待辦事項</h2>
<input v-model="newTodoTitle" placeholder="輸入新的待辦事項" />
<button @click="addTodo" :disabled="isPending">
{{ isPending ? "新增中..." : "新增" }}
</button>

<ul>
<li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
</ul>

<p v-if="isError" style="color: red">新增失敗!</p>
</div>
</template>