TanStack Query: Майстерність Керування Станом Сервера

Оптимістичні Оновлення: Швидше за Світло

Ви коли-небудь помічали, як працює лайк в Instagram або повідомлення в Telegram? Ви натискаєте кнопку, і серце стає червоним миттєво. Додаток не чекає, поки сервер скаже "ОК". Він оптимістично припускає, що все буде добре.

Оптимістичні Оновлення: Швидше за Світло

Ви коли-небудь помічали, як працює лайк в Instagram або повідомлення в Telegram? Ви натискаєте кнопку, і серце стає червоним миттєво. Додаток не чекає, поки сервер скаже "ОК". Він оптимістично припускає, що все буде добре.

Якщо ж інтернет зникне або сервер поверне помилку, серце "відіжметься" назад або з'явиться значок помилки.

Це називається Optimistic UI. І TanStack Query робить його реалізацію стандартизованою.

Алгоритм Оптимізму

Щоб реалізувати це безпечно, нам потрібно виконати 4 кроки в правильному порядку:

  1. Cancel: Скасувати будь-які вихідні запити (refetch), які можуть перезаписати наше оптимістичне оновлення.
  2. Snapshot: Зберегти поточний стан даних (для можливого відкату).
  3. Update: Вручну оновити кеш новими (фейковими) даними.
  4. Rollback / Confirm:
    • Якщо помилка: Повернути збережений Snapshot.
    • Якщо успіх: Інвалідувати кеш, щоб отримати "справжні" дані з сервера (на всяк випадок).

Все це відбувається в опціях useMutation.

Приклад: Кнопка Лайка

Уявімо, що ми маємо пост.

interface Post {
  id: number;
  title: string;
  likes: number;
  isLiked: boolean;
}

Ось повна реалізація мутації з оптимістичним оновленням.

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

function LikeButton({ post }: { post: Post }) {
  const queryClient = useQueryClient();
  const queryKey = ['posts', post.id];

  const { mutate } = useMutation({
    // 1. Функція мутації (Server Side)
    mutationFn: (newIsLiked: boolean) => {
      return axios.post(`/api/posts/${post.id}/like`, { isLiked: newIsLiked });
    },

    // 2. Виконується ДО того, як запит полетить (Client Side)
    onMutate: async (newIsLiked) => {
      // A. Скасовуємо всі запити до цього поста, щоб вони не перебили наш апдейт
      await queryClient.cancelQueries({ queryKey });

      // B. Зберігаємо старі дані (Snapshot)
      const previousPost = queryClient.getQueryData<Post>(queryKey);

      // C. Оптимістично оновлюємо кеш
      queryClient.setQueryData<Post>(queryKey, (old) => {
        if (!old) return old;
        return {
          ...old,
          likes: newIsLiked ? old.likes + 1 : old.likes - 1,
          isLiked: newIsLiked,
        };
      });

      // D. Повертаємо контекст (те, що нам знадобиться в onError)
      return { previousPost };
    },

    // 3. Якщо сталася помилка
    onError: (err, newIsLiked, context) => {
      // Відкочуємося до збереженого стану
      if (context?.previousPost) {
        queryClient.setQueryData(queryKey, context.previousPost);
      }
      alert('Не вдалося поставити лайк :(');
    },

    // 4. Завжди (успіх чи помилка)
    onSettled: () => {
      // Інвалідуємо, щоб синхронізуватися з сервером
      // Це гарантує, що ми маємо "чесні" дані в кінці
      queryClient.invalidateQueries({ queryKey });
    },
  });

  return (
    <button onClick={() => mutate(!post.isLiked)}>
      {post.isLiked ? '❤️' : '🤍'} {post.likes}
    </button>
  );
}

Розбір Ключових Моментів

queryClient.cancelQueries

Це критично. Уявіть сценарій:

  1. Користувач натиснув лайк (оптимістично +1).
  2. Одночасно спрацював refetchOnWindowFocus (старі дані летять з сервера).
  3. Якщо не скасувати refetch, старі дані прийдуть з сервера і перезапишуть наш лайк на -1, поки мутація ще йде. Користувач побачить "блимання".

queryClient.setQueryData

Ми не чекаємо відповіді сервера. Ми прямо ліземо в мозок TanStack Query і кажемо: "Вважай, що дані тепер такі". Функція оновлення працює як в useState: (oldData) => newData.

setQueryData не викликає новий запит. Вона просто змінює дані в пам'яті.

Оптимістичне додавання в список

Складніший приклад: додавання нового Todo в список. Тут проблема в тому, що у нас ще немає справжнього id від сервера (він згенериться в БД).

Ми використовуємо тимчасовий ID.

onMutate: async (newTodo) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] });
  const previousTodos = queryClient.getQueryData(['todos']);

  queryClient.setQueryData(['todos'], (old) => [
    ...old,
    { ...newTodo, id: Math.random(), isOptimistic: true }, // Тимчасовий ID
  ]);

  return { previousTodos };
},

Коли прийде успішна відповідь (onSuccess), invalidateQueries зробить новий запит, і наш тимчасовий елемент заміниться на справжній з сервера (з правильним ID).

Якщо ви хочете ідеального UX, ви можете в onSuccess замінити тимчасовий елемент на справжній вручну (через setQueryData), щоб уникнути зайвого запиту invalidate. Але це складніше в реалізації.

Далі: Пагінація та Infinite Scroll

Copyright © 2026