Пошук уроків, статей та іншого контенту
Підключите TanStack Query для кешування, синхронізації, повторних запитів і мутацій у Next.js.
TanStack Query керує серверним станом у React-застосунку:
кешує результати запитів;
не повторює однакові запити без потреби;
автоматично оновлює застарілі дані;
повторює невдалі запити;
відстежує стани loading, error і success;
підтримує мутації та інвалідацію кешу;
інтегрується із серверним рендерингом і гідрацією в Next.js.
TanStack Query не замінює useState для локального стану інтерфейсу. Його основне завдання — зберігати та синхронізувати дані, які надходять із сервера.
npm install @tanstack/react-queryДля гідрації серверних даних використовуються dehydrate і HydrationBoundary, які входять до основного пакета.
У Next.js з App Router провайдер потрібно позначити директивою 'use client', оскільки QueryClientProvider використовує клієнтський React-контекст.
Створимо app/providers.tsx:
'use client';
import { useState } from 'react';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
type Props = {
children: React.ReactNode;
};
export function Providers({ children }: Props) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 2,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 0,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Підключимо провайдер у app/layout.tsx:
import type { Metadata } from 'next';
import { Providers } from './providers';
export const metadata: Metadata = {
title: 'TanStack Query Demo',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="uk">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}useStateНе слід створювати QueryClient безпосередньо під час кожного рендеру:
// Неправильно
const queryClient = new QueryClient();У такому випадку кеш може створюватися заново під час рендерів компонента.
useState(() => new QueryClient()) гарантує, що в браузері буде створено один стабільний екземпляр QueryClient для всього дерева провайдера.
На сервері, навпаки, не можна використовувати один глобальний QueryClient для всіх запитів користувачів. Серверний клієнт повинен створюватися окремо для кожного рендеру.
Для прикладу використаємо просте сховище в пам’яті. У реальному застосунку тут буде база даних.
Файл lib/posts.ts:
export type Post = {
id: string;
title: string;
body: string;
createdAt: string;
};
let posts: Post[] = [
{
id: '1',
title: 'Перший допис',
body: 'Вміст першого допису',
createdAt: new Date().toISOString(),
},
{
id: '2',
title: 'Другий допис',
body: 'Вміст другого допису',
createdAt: new Date().toISOString(),
},
];
export async function getPosts(): Promise<Post[]> {
return posts;
}
export async function createPost(input: {
title: string;
body: string;
}): Promise<Post> {
const post: Post = {
id: crypto.randomUUID(),
title: input.title,
body: input.body,
createdAt: new Date().toISOString(),
};
posts = [post, ...posts];
return post;
}Створимо API у app/api/posts/route.ts:
import { createPost, getPosts } from '@/lib/posts';
export async function GET() {
const posts = await getPosts();
return Response.json(posts);
}
export async function POST(request: Request) {
const body = (await request.json()) as {
title?: string;
body?: string;
};
if (!body.title?.trim() || !body.body?.trim()) {
return Response.json(
{ message: 'Заголовок і текст є обов’язковими' },
{ status: 400 },
);
}
const post = await createPost({
title: body.title.trim(),
body: body.body.trim(),
});
return Response.json(post, { status: 201 });
}Кожен запит у TanStack Query описується щонайменше двома властивостями:
queryKey — унікальний ключ даних;
queryFn — асинхронна функція отримання даних.
Наприклад:
useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});queryKey має бути масивом. Якщо запит залежить від параметрів, вони повинні входити до ключа:
useQuery({
queryKey: ['posts', { page: 2, search: 'next' }],
queryFn: () => fetchPosts({ page: 2, search: 'next' }),
});Для списку дописів ключем буде ['posts']. Для окремого допису — наприклад, ['posts', postId].
Різні ключі означають різні записи кешу.
TanStack Query розрізняє два поняття:
staleTimestaleTime визначає, скільки часу дані вважаються свіжими.
staleTime: 30_000Протягом 30 секунд TanStack Query не буде виконувати повторний запит лише через монтування іншого компонента або повернення фокусу на вкладку.
gcTimegcTime визначає, скільки часу невикористані дані зберігаються в кеші.
gcTime: 5 * 60_000Якщо на запит більше ніхто не підписаний, його кеш може бути видалений після завершення цього часу.
У TanStack Query v5 використовується назва gcTime. У старіших версіях ця опція називалася cacheTime.
staleTime не видаляє дані. Він лише визначає, чи потрібно вважати їх застарілими.
gcTime не визначає свіжість даних. Він визначає тривалість зберігання даних, які більше не використовуються.
У Next.js сторінка може попередньо завантажити дані на сервері. Потім ці дані передаються до клієнтського кешу через dehydrate і HydrationBoundary.
Це дозволяє:
отримати дані під час серверного рендерингу;
віддати HTML, у якому вже є контент;
передати результат у кеш TanStack Query після гідрації;
уникнути першого дубльованого запиту в браузері, якщо дані ще свіжі.
Файл app/posts/page.tsx:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { getPosts } from '@/lib/posts';
import { PostsList } from './posts-list';
export default async function PostsPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
},
},
});
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<main>
<h1>Дописи</h1>
<PostsList />
</main>
</HydrationBoundary>
);
}page.tsx залишається серверним компонентом. Клієнтським буде лише компонент, який використовує хуки TanStack Query.
Файл app/posts/posts-list.tsx:
'use client';
import { useQuery } from '@tanstack/react-query';
import type { Post } from '@/lib/posts';
async function fetchPosts({
signal,
}: {
signal: AbortSignal;
}): Promise<Post[]> {
const response = await fetch('/api/posts', {
signal,
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Не вдалося завантажити дописи');
}
return response.json();
}
export function PostsList() {
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (postsQuery.isPending) {
return <p>Завантаження...</p>;
}
if (postsQuery.isError) {
return (
<div>
<p>Помилка: {postsQuery.error.message}</p>
<button type="button" onClick={() => postsQuery.refetch()}>
Спробувати ще раз
</button>
</div>
);
}
return (
<section>
<p>
Стан кешу:{' '}
{postsQuery.isFetching ? 'оновлення...' : 'актуальний'}
</p>
<ul>
{postsQuery.data.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
</section>
);
}TanStack Query передає AbortSignal у контекст queryFn. Його потрібно передавати до fetch:
async function queryFn({ signal }: { signal: AbortSignal }) {
const response = await fetch('/api/data', { signal });
return response.json();
}Коли компонент більше не потребує запиту або ключ змінюється, браузер може скасувати попередній запит. Це особливо важливо для пошуку, фільтрів і швидкого перемикання сторінок.
За замовчуванням TanStack Query може повторити невдалий запит. Кількість спроб можна налаштувати глобально або для конкретного запиту:
const postsQuery = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});Повторні спроби доречні для тимчасових помилок мережі. Вони не завжди доречні для помилок валідації або помилок авторизації.
Для запитів можна передати функцію:
const query = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('401')) {
return false;
}
return failureCount < 2;
},
});У виробничому коді краще використовувати власний тип помилки HTTP, який містить статус відповіді. Тоді можна відрізняти тимчасову помилку сервера від помилки клієнта.
TanStack Query може запускати повторні запити у відповідь на події:
повернення фокусу до вкладки;
відновлення мережевого з’єднання;
повторне монтування компонента;
завершення визначеного інтервалу.
Глобальні налаштування:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
},
},
});Для періодичного оновлення можна використати refetchInterval:
const statusQuery = useQuery({
queryKey: ['job-status', jobId],
queryFn: () => fetchJobStatus(jobId),
refetchInterval: 5_000,
});Не слід без потреби встановлювати малий інтервал для великих або дорогих запитів.
Для створення, оновлення або видалення даних використовується useMutation.
Файл app/posts/create-post-form.tsx:
'use client';
import { FormEvent, useState } from 'react';
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import type { Post } from '@/lib/posts';
type CreatePostInput = {
title: string;
body: string;
};
async function createPost(input: CreatePostInput): Promise<Post> {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
const error = (await response.json()) as { message?: string };
throw new Error(error.message ?? 'Не вдалося створити допис');
}
return response.json();
}
export function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const mutation = useMutation({
mutationFn: createPost,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['posts'],
});
setTitle('');
setBody('');
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
mutation.mutate({
title,
body,
});
}
return (
<form onSubmit={handleSubmit}>
<label>
Заголовок
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
disabled={mutation.isPending}
/>
</label>
<label>
Текст
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
disabled={mutation.isPending}
/>
</label>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Збереження...' : 'Створити'}
</button>
{mutation.isError && (
<p role="alert">{mutation.error.message}</p>
)}
{mutation.isSuccess && <p>Допис створено</p>}
</form>
);
}Підключимо форму до сторінки:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { getPosts } from '@/lib/posts';
import { CreatePostForm } from './create-post-form';
import { PostsList } from './posts-list';
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<main>
<h1>Дописи</h1>
<CreatePostForm />
<PostsList />
</main>
</HydrationBoundary>
);
}Після успішної мутації кеш списку стає потенційно застарілим. Для цього використовується:
await queryClient.invalidateQueries({
queryKey: ['posts'],
});Інвалідація:
позначає відповідні дані як застарілі;
запускає повторне завантаження активних запитів;
не видаляє дані миттєво з інтерфейсу.
Тому користувач зазвичай продовжує бачити старий список під час оновлення.
Ключі працюють за префіксом. Наприклад:
await queryClient.invalidateQueries({
queryKey: ['posts'],
});може інвалідовувати такі запити:
['posts']
['posts', 'recent']
['posts', { page: 2 }]Якщо потрібно інвалідовувати лише точний ключ, використовуйте exact:
await queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true,
});Якщо сервер повертає повний результат мутації, кеш можна оновити без негайного запиту:
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (createdPost) => {
queryClient.setQueryData<Post[]>(
['posts'],
(currentPosts = []) => [createdPost, ...currentPosts],
);
},
});Цей підхід зменшує кількість запитів, але вимагає впевненості, що локально сформований кеш відповідає серверному стану.
Якщо сервер застосовує сортування, фільтрацію, права доступу або додаткову обробку даних, безпечніше використовувати invalidateQueries.
Розглянемо список із пошуком:
const postsQuery = useQuery({
queryKey: ['posts', { search, page }],
queryFn: ({ signal }) =>
fetchPosts({
search,
page,
signal,
}),
});Для кожної комбінації search і page TanStack Query створить окремий запис кешу.
Важливо, щоб усі значення, які впливають на результат запиту, входили до queryKey. Неправильно:
useQuery({
queryKey: ['posts'],
queryFn: () => fetchPosts({ search }),
});У цьому випадку різні значення search використовують один і той самий ключ, тому кеш може містити неправильні дані.
isPending та isFetchingУ TanStack Query v5:
isPending означає, що запит ще не має даних;
isFetching означає, що запит виконується просто зараз, зокрема під час фонового оновлення.
Тому інтерфейс може показувати вже завантажені дані разом із невеликим індикатором оновлення:
if (query.isPending) {
return <p>Перший запит...</p>;
}
return (
<>
{query.isFetching && <small>Оновлення...</small>}
<DataView data={query.data} />
</>
);Це краще, ніж повністю приховувати старі дані під час кожного фонового запиту.
TanStack Query і кешування fetch у Next.js — це різні рівні кешу.
У цьому прикладі клієнтський queryFn використовує:
fetch('/api/posts', {
cache: 'no-store',
});Джерелом кешування для клієнтського стану є TanStack Query. Це спрощує контроль над staleTime, повторними запитами та інвалідацією.
Серверне попереднє завантаження може використовувати іншу стратегію, залежно від джерела даних. Важливо не створювати кілька незалежних шарів кешу без чіткої причини, інакше дані можуть мати різну актуальність.
QueryClient під час кожного рендеруfunction Providers({ children }: Props) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}Це може скидати кеш і створювати зайві екземпляри клієнта. Використовуйте стабільний екземпляр через useState.
Хуки useQuery, useMutation і useQueryClient працюють у клієнтських компонентах. Компонент із такими хуками повинен починатися з:
'use client';Серверний компонент може створити QueryClient, виконати prefetchQuery і передати стан через HydrationBoundary.
queryKeyЯкщо результат залежить від фільтра, ідентифікатора або номера сторінки, вони повинні бути частиною ключа.
queryKey: ['users', userId]а не лише:
queryKey: ['users']Ключ після мутації повинен відповідати ключу запиту:
// Запит
queryKey: ['posts', { page }]
// Непов’язаний ключ
invalidateQueries({ queryKey: ['articles'] })У такому випадку список дописів не оновиться.
response.okfetch не відхиляє проміс автоматично для HTTP-відповідей зі статусом 400 або 500. Перевіряйте response.ok і викидайте помилку вручну.
Для мутацій зазвичай встановлюють:
mutations: {
retry: 0,
}Автоматичне повторення POST або іншої операції запису може створити дублікати, якщо сервер не підтримує ідемпотентність.
QueryClientНе зберігайте серверний QueryClient у змінній рівня модуля. Це може призвести до витоку кешованих даних між запитами різних користувачів.
QueryClientProvider надає TanStack Query всьому клієнтському дереву.
queryKey однозначно ідентифікує запис у кеші.
staleTime керує періодом свіжості даних.
gcTime визначає, коли невикористаний кеш може бути видалений.
retry налаштовує повторні спроби запитів.
refetchOnWindowFocus і refetchOnReconnect допомагають синхронізувати дані.
prefetchQuery, dehydrate і HydrationBoundary поєднують серверне завантаження Next.js із клієнтським кешем.
useMutation призначений для операцій запису.
Після мутації кеш можна оновити через setQueryData або інвалідувати через invalidateQueries.
Усі параметри, що впливають на результат запиту, повинні входити до queryKey.