Ви коли-небудь помічали, як працює лайк в Instagram або повідомлення в Telegram? Ви натискаєте кнопку, і серце стає червоним миттєво. Додаток не чекає, поки сервер скаже "ОК". Він оптимістично припускає, що все буде добре.
Якщо ж інтернет зникне або сервер поверне помилку, серце "відіжметься" назад або з'явиться значок помилки.
Це називається Optimistic UI. І TanStack Query робить його реалізацію стандартизованою.
Щоб реалізувати це безпечно, нам потрібно виконати 4 кроки в правильному порядку:
Все це відбувається в опціях 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Це критично. Уявіть сценарій:
refetchOnWindowFocus (старі дані летять з сервера).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 записів одразу — погана ідея. Нам потрібна пагінація або "нескінченна прокрутка".