Оптимістичні Оновлення: Швидше за Світло
Оптимістичні Оновлення: Швидше за Світло
Ви коли-небудь помічали, як працює лайк в Instagram або повідомлення в Telegram? Ви натискаєте кнопку, і серце стає червоним миттєво. Додаток не чекає, поки сервер скаже "ОК". Він оптимістично припускає, що все буде добре.
Якщо ж інтернет зникне або сервер поверне помилку, серце "відіжметься" назад або з'явиться значок помилки.
Це називається Optimistic UI. І TanStack Query робить його реалізацію стандартизованою.
Алгоритм Оптимізму
Щоб реалізувати це безпечно, нам потрібно виконати 4 кроки в правильному порядку:
- Cancel: Скасувати будь-які вихідні запити (refetch), які можуть перезаписати наше оптимістичне оновлення.
- Snapshot: Зберегти поточний стан даних (для можливого відкату).
- Update: Вручну оновити кеш новими (фейковими) даними.
- Rollback / Confirm:
- Якщо помилка: Повернути збережений Snapshot.
- Якщо успіх: Інвалідувати кеш, щоб отримати "справжні" дані з сервера (на всяк випадок).
Все це відбувається в опціях useMutation.
Приклад: Кнопка Лайка
Уявімо, що ми маємо пост.
interface Post {
id: number;
title: string;
likes: number;
isLiked: boolean;
}
Ось повна реалізація мутації з оптимістичним оновленням.
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).
- Одночасно спрацював
refetchOnWindowFocus(старі дані летять з сервера). - Якщо не скасувати 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).
onSuccess замінити тимчасовий елемент на справжній вручну (через setQueryData), щоб уникнути зайвого запиту invalidate. Але це складніше в реалізації.Мутації та Інвалідація: Зміна Даних
Досі ми лише читали дані (GET). Але веб-додатки — це про взаємодію. Ми хочемо створювати, оновлювати та видаляти дані (POST, PUT, DELETE).
Пагінація та Infinite Scroll
Списки даних — це хліб і масло веб-розробки. Але завантажувати 10,000 записів одразу — погана ідея. Нам потрібна пагінація або "нескінченна прокрутка".