【TanStack Query + Zustand 教學】#03 TanStack Query 進階:useMutation 與樂觀更新

測驗:TanStack Query 進階 – useMutation 與樂觀更新

共 5 題,點選答案後會立即顯示結果

1. 在 TanStack Query 中,Query 和 Mutation 分別對應什麼類型的操作?

  • A. Query 用於變更資料,Mutation 用於讀取資料
  • B. Query 用於讀取資料(GET),Mutation 用於變更資料(POST/PUT/DELETE)
  • C. Query 和 Mutation 都可以用於讀取和變更資料
  • D. Query 用於前端快取,Mutation 用於後端快取

2. useMutation 的回調函數執行順序是什麼?

  • A. mutationFn → onMutate → onSuccess/onError → onSettled
  • B. onSuccess → mutationFn → onMutate → onSettled
  • C. onMutate → mutationFn → onSuccess/onError → onSettled
  • D. onSettled → onMutate → mutationFn → onSuccess/onError

3. 當 mutation 操作成功後,要讓畫面上的資料保持最新,最簡單的方式是使用哪個方法?

  • A. queryClient.invalidateQueries()
  • B. queryClient.refetchQueries()
  • C. queryClient.setQueryData()
  • D. queryClient.removeQueries()

4. 樂觀更新(Optimistic Updates)的主要特點是什麼?

  • A. 等待 API 回應後才更新 UI
  • B. 先更新 UI,再等待 API 確認,如果失敗則回復原狀態
  • C. 只在本地更新資料,不呼叫 API
  • D. 同時發送多個 API 請求以提高速度

5. 在實作樂觀更新時,onMutate 回調函數需要回傳什麼?

onMutate: async (todoId) => { await queryClient.cancelQueries({ queryKey: [‘todos’] }); const previousTodos = queryClient.getQueryData([‘todos’]); queryClient.setQueryData([‘todos’], (old) => old.filter(todo => todo.id !== todoId) ); return { previousTodos }; // 這裡回傳什麼? }
  • A. 新的 todo 資料
  • B. API 請求的結果
  • C. 錯誤訊息
  • D. 舊資料(previousTodos),供 onError 失敗時回復使用

前言

在上一篇文章中,我們學會了使用 useQuery 來取得資料。但實際的應用程式不只需要「讀取」資料,還需要「新增」、「更新」、「刪除」資料。這時候就需要使用 useMutation

本篇將帶你學會:

  • 使用 useMutation 處理資料變更操作
  • 在操作成功後自動重新取得資料
  • 實作樂觀更新,讓使用者體驗更流暢

useMutation 基本用法

什麼是 Mutation?

在 TanStack Query 的世界裡:

  • Query = 讀取資料(GET 請求)
  • Mutation = 變更資料(POST、PUT、DELETE 請求)

最小範例:新增 Todo

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodoForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newTodo: { title: string }) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(res => res.json());
    },
    onSuccess: () => {
      // 新增成功後,重新取得 todos 列表
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ title: '買牛奶' });
  };

  return (
    <form onSubmit={handleSubmit}>
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '新增中...' : '新增 Todo'}
      </button>
    </form>
  );
}
Code language: JavaScript (javascript)

讀懂 useMutation 的回傳值

const mutation = useMutation({ mutationFn: ... });

// 常用屬性
mutation.mutate(variables);  // 執行 mutation
mutation.isPending;          // 是否正在執行
mutation.isSuccess;          // 是否成功
mutation.isError;            // 是否失敗
mutation.error;              // 錯誤物件
mutation.data;               // 成功回傳的資料
Code language: JavaScript (javascript)

useMutation 的回調函數

useMutation 提供四個重要的回調函數:

const mutation = useMutation({
  mutationFn: async (newTodo) => {
    const res = await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(newTodo),
    });
    return res.json();
  },

  // mutation 執行前
  onMutate: (variables) => {
    console.log('準備新增:', variables);
  },

  // mutation 成功時
  onSuccess: (data, variables) => {
    console.log('新增成功:', data);
  },

  // mutation 失敗時
  onError: (error, variables) => {
    console.log('新增失敗:', error);
  },

  // mutation 結束時(不論成功或失敗)
  onSettled: (data, error, variables) => {
    console.log('操作結束');
  },
});
Code language: JavaScript (javascript)

回調執行順序

onMutate → mutationFn → onSuccess/onError → onSettled

使用 invalidateQueries 同步資料

當我們新增、更新或刪除資料後,需要讓畫面上顯示的資料保持最新。最簡單的方式是使用 invalidateQueries

import { useQueryClient } from '@tanstack/react-query';

function TodoActions() {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: (todoId: number) =>
      fetch(`/api/todos/${todoId}`, { method: 'DELETE' }),
    onSuccess: () => {
      // 刪除成功後,讓 todos 的快取失效
      // TanStack Query 會自動重新 fetch
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <button onClick={() => deleteMutation.mutate(1)}>
      刪除 Todo #1
    </button>
  );
}
Code language: JavaScript (javascript)

invalidateQueries 的運作原理

使用者點擊刪除 → mutation 執行 → API 回應成功
→ invalidateQueries 標記快取為過期 → useQuery 自動重新 fetch → UI 更新

這種方式簡單可靠,但有一個小缺點:使用者需要等待 API 回應後才能看到變化。

樂觀更新(Optimistic Updates)

什麼是樂觀更新?

樂觀更新是一種「先更新 UI,再等 API 回應」的策略:

傳統做法:點擊 → 等待 API → 更新 UI(使用者等待)
樂觀更新:點擊 → 立即更新 UI → 等待 API 確認(使用者不等待)

如果 API 失敗,就把 UI 回復到原本的狀態。

實作樂觀更新:刪除 Todo

function TodoList() {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: (todoId: number) =>
      fetch(`/api/todos/${todoId}`, { method: 'DELETE' }),

    // 步驟 1:在 mutation 執行前,先樂觀更新 UI
    onMutate: async (todoId) => {
      // 取消正在進行的 queries,避免覆蓋我們的樂觀更新
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // 保存目前的資料,以便失敗時回復
      const previousTodos = queryClient.getQueryData(['todos']);

      // 樂觀更新:立即從快取中移除被刪除的 todo
      queryClient.setQueryData(['todos'], (old: Todo[]) =>
        old.filter(todo => todo.id !== todoId)
      );

      // 回傳舊資料,供 onError 使用
      return { previousTodos };
    },

    // 步驟 2:如果 mutation 失敗,回復到原本的狀態
    onError: (err, todoId, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },

    // 步驟 3:不論成功或失敗,都重新取得最新資料確保同步
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // ...
}
Code language: JavaScript (javascript)

樂觀更新流程圖解

使用者點擊刪除
    │
    ▼
onMutate 執行
    ├── 取消進行中的 queries
    ├── 保存舊資料 (previousTodos)
    └── 立即更新 UI(移除該 todo)
    │
    ▼
mutationFn 執行(呼叫 API)
    │
    ├── 成功 → onSuccess → onSettled → invalidateQueries
    │
    └── 失敗 → onError(回復 previousTodos)→ onSettled

完整範例:Todo App CRUD

讓我們把學到的知識整合成一個完整的範例:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// API 函數
const api = {
  getTodos: (): Promise<Todo[]> =>
    fetch('/api/todos').then(res => res.json()),

  addTodo: (title: string): Promise<Todo> =>
    fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify({ title, completed: false }),
    }).then(res => res.json()),

  updateTodo: (todo: Todo): Promise<Todo> =>
    fetch(`/api/todos/${todo.id}`, {
      method: 'PUT',
      body: JSON.stringify(todo),
    }).then(res => res.json()),

  deleteTodo: (id: number): Promise<void> =>
    fetch(`/api/todos/${id}`, { method: 'DELETE' }).then(() => {}),
};

function TodoApp() {
  const queryClient = useQueryClient();

  // 讀取 todos
  const { data: todos = [], isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: api.getTodos,
  });

  // 新增 todo
  const addMutation = useMutation({
    mutationFn: api.addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  // 更新 todo(使用樂觀更新)
  const updateMutation = useMutation({
    mutationFn: api.updateTodo,
    onMutate: async (updatedTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map(todo =>
          todo.id === updatedTodo.id ? updatedTodo : todo
        )
      );

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

  // 刪除 todo(使用樂觀更新)
  const deleteMutation = useMutation({
    mutationFn: api.deleteTodo,
    onMutate: async (todoId) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.filter(todo => todo.id !== todoId)
      );

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

  if (isLoading) return <div>載入中...</div>;

  return (
    <div>
      <button onClick={() => addMutation.mutate('新的待辦事項')}>
        新增 Todo
      </button>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() =>
                updateMutation.mutate({
                  ...todo,
                  completed: !todo.completed,
                })
              }
            />
            <span>{todo.title}</span>
            <button onClick={() => deleteMutation.mutate(todo.id)}>
              刪除
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
Code language: JavaScript (javascript)

何時該用樂觀更新?

情境 建議
操作成功率高(如勾選 checkbox) 使用樂觀更新
操作可能失敗(如需要驗證) 不使用樂觀更新
回復操作成本高 不使用樂觀更新
使用者體驗優先 使用樂觀更新

本篇重點整理

  1. useMutation 用於處理資料變更操作(POST/PUT/DELETE)
  2. 四個回調函數onMutateonSuccess/onErroronSettled
  3. invalidateQueries 讓快取失效,觸發重新取得資料
  4. 樂觀更新的三步驟:
    • onMutate:保存舊資料、立即更新 UI
    • onError:失敗時回復舊資料
    • onSettled:重新取得最新資料

下一篇預告

在下一篇文章中,我們將學習如何使用 Zustand 管理本地狀態,以及如何將 TanStack Query 與 Zustand 整合,建立完整的狀態管理架構。

進階測驗:TanStack Query 進階 – useMutation 與樂觀更新

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在開發一個 Todo App,需要在使用者點擊「新增」按鈕後將新的 Todo 送到後端,並在成功後更新列表。 情境題

你應該如何設計這個功能?

  • A. 使用 useQuery 搭配 POST 方法發送請求
  • B. 使用 useMutation 發送請求,在 onSuccess 中呼叫 invalidateQueries
  • C. 直接使用 fetch 發送請求,然後手動呼叫 window.location.reload()
  • D. 使用 useMutation 但不需要任何回調函數

2. 你的 Todo App 有一個「勾選完成」的功能,使用者勾選後需要立即看到變化,不想等待 API 回應。如果 API 失敗,要能回復原狀。 情境題

這種情況最適合使用什麼技術?

  • A. 使用 invalidateQueries 並設定 staleTime: 0
  • B. 在 onSuccess 中立即更新本地狀態
  • C. 使用樂觀更新:在 onMutate 先更新 UI,onError 回復舊資料
  • D. 使用 setTimeout 延遲 UI 更新以等待 API

3. 你正在實作樂觀更新的刪除功能。在 onMutate 中,你需要先做什麼操作來避免快取被覆蓋? 情境題

onMutate: async (todoId) => { // 這裡應該先做什麼? const previousTodos = queryClient.getQueryData([‘todos’]); queryClient.setQueryData([‘todos’], (old) => old.filter(todo => todo.id !== todoId) ); return { previousTodos }; }
  • A. 呼叫 queryClient.cancelQueries({ queryKey: ['todos'] })
  • B. 呼叫 queryClient.resetQueries({ queryKey: ['todos'] })
  • C. 呼叫 queryClient.removeQueries({ queryKey: ['todos'] })
  • D. 不需要任何額外操作,直接更新即可

4. 小明實作了樂觀更新,但發現 API 失敗時資料沒有回復。請看以下程式碼,問題出在哪裡? 錯誤診斷

const deleteMutation = useMutation({ mutationFn: (todoId) => fetch(`/api/todos/${todoId}`, { method: ‘DELETE’ }), onMutate: async (todoId) => { await queryClient.cancelQueries({ queryKey: [‘todos’] }); const previousTodos = queryClient.getQueryData([‘todos’]); queryClient.setQueryData([‘todos’], (old) => old.filter(todo => todo.id !== todoId) ); // 忘記 return { previousTodos } }, onError: (err, todoId, context) => { queryClient.setQueryData([‘todos’], context?.previousTodos); }, });
  • A. cancelQueries 使用錯誤,應該用 invalidateQueries
  • B. onMutate 沒有 return { previousTodos },導致 onError 的 context 是 undefined
  • C. onError 的參數順序錯誤
  • D. setQueryData 的 filter 邏輯寫反了

5. 小華的新增 Todo 功能可以成功呼叫 API,但畫面上的列表沒有更新。請看以下程式碼,問題最可能是什麼? 錯誤診斷

const addMutation = useMutation({ mutationFn: (newTodo) => { return fetch(‘/api/todos’, { method: ‘POST’, body: JSON.stringify(newTodo), }).then(res => res.json()); }, // 沒有任何回調函數 }); // 使用 addMutation.mutate({ title: ‘買牛奶’ });
  • A. mutationFn 的 fetch 設定有誤
  • B. mutate 應該使用 mutateAsync
  • C. 缺少 onSuccess 回調來呼叫 invalidateQueries 使快取失效
  • D. 應該把 useMutation 換成 useQuery

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *