Пошук уроків, статей та іншого контенту
Освоїте TanStack Query для запитів, кешу, мутацій, повторних спроб та синхронізації серверного стану.
TanStack Query — бібліотека для роботи із серверним станом у React. Вона керує даними, які:
зберігаються на сервері;
можуть змінюватися незалежно від поточного компонента;
потребують кешування;
мають завантажуватися повторно;
можуть бути тимчасово застарілими;
повинні синхронізуватися після мутацій.
Локальний стан інтерфейсу — наприклад, відкритість модального вікна або значення поля форми — залишається відповідальністю React. TanStack Query не замінює useState, а вирішує іншу задачу: керування даними API.
Основні можливості:
виконання запитів;
кешування результатів;
визначення свіжості даних;
автоматичні повторні спроби;
скасування запитів;
мутації;
оптимістичні оновлення;
інвалідація та повторне завантаження даних;
синхронізація при поверненні у вкладку або відновленні мережі.
Встановіть пакет:
npm install @tanstack/react-queryДля перегляду кешу в режимі розробки можна додати Devtools:
npm install -D @tanstack/react-query-devtoolsQueryClient зберігає кеш і глобальні налаштування запитів. Увесь React-додаток потрібно обгорнути в QueryClientProvider.
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import App from "./App";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>,
);QueryClient зазвичай створюють один раз поза компонентом. Якщо створювати його під час кожного рендера, кеш буде постійно втрачатися.
useQueryДля отримання даних використовується useQuery.
const result = useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
});useQuery повертає, зокрема:
data — успішно отримані дані;
isPending — запит ще не має результату;
isFetching — зараз виконується будь-яке завантаження, зокрема фонове;
isError — запит завершився помилкою;
error — об’єкт помилки;
refetch — ручний запуск запиту;
isSuccess — запит завершився успішно.
Важливо розрізняти isPending та isFetching:
isPending зазвичай означає початковий стан без даних;
isFetching може бути true, коли на екрані вже є старі дані, але TanStack Query отримує нові.
queryKey однозначно ідентифікує дані в кеші. Це масив, елементи якого повинні описувати всі параметри запиту.
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
});
useQuery({
queryKey: ["tasks", { status: "completed" }],
queryFn: () => fetchTasks({ status: "completed" }),
});
useQuery({
queryKey: ["task", taskId],
queryFn: () => fetchTask(taskId),
});Такі ключі вважаються різними:
["tasks", { status: "completed" }]
["tasks", { status: "active" }]Якщо значення впливає на результат запиту, воно має бути частиною queryKey. Не варто залишати параметр лише в замиканні queryFn:
// Погано: page не входить до ключа
useQuery({
queryKey: ["tasks"],
queryFn: () => fetchTasks(page),
});У такому випадку різні сторінки можуть помилково використовувати один запис кешу.
Ключі зручно централізувати:
const taskKeys = {
all: ["tasks"] as const,
lists: () => [...taskKeys.all, "list"] as const,
list: (filters: { status?: string; page: number }) =>
[...taskKeys.lists(), filters] as const,
details: () => [...taskKeys.all, "detail"] as const,
detail: (id: string) => [...taskKeys.details(), id] as const,
};Тоді ключі в компоненті та під час інвалідації будуть узгодженими.
TanStack Query розділяє поняття свіжості даних і їх наявності в кеші.
staleTimestaleTime визначає, скільки часу дані вважаються свіжими.
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
staleTime: 60_000,
});Протягом 60 секунд TanStack Query не буде без потреби виконувати повторний запит через типові тригери, наприклад повернення фокусу у вкладку.
Значення:
0 — дані одразу вважаються застарілими;
30_000 — дані свіжі 30 секунд;
Infinity — дані не стають застарілими автоматично.
gcTimegcTime визначає, як довго невикористаний запис залишається в кеші. У TanStack Query v5 ця опція називається саме gcTime.
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
gcTime: 5 * 60_000,
});Якщо жоден компонент більше не використовує запит, його кеш згодом може бути видалений. gcTime не визначає, наскільки дані свіжі.
Типова послідовність:
компонент отримує дані;
дані зберігаються в кеші;
компонент розмонтовується;
запис стає невикористовуваним;
після завершення gcTime запис видаляється.
Параметри потрібно передавати і в queryKey, і в queryFn.
type TaskFilters = {
status: "all" | "active" | "completed";
page: number;
};
function useTasks(filters: TaskFilters) {
return useQuery({
queryKey: ["tasks", filters],
queryFn: ({ signal }) => fetchTasks(filters, signal),
staleTime: 30_000,
});
}Коли filters змінюються, змінюється ключ, і TanStack Query використовує окремий запис кешу.
Об’єкти в ключах повинні складатися зі стабільних серіалізованих значень. Не додавайте до queryKey функції, DOM-вузли або об’єкти, які містять несеріалізовані дані.
queryFn отримує AbortSignal. Його потрібно передавати в fetch, щоб запит можна було скасувати.
async function fetchTasks(signal?: AbortSignal) {
const response = await fetch("/api/tasks", { signal });
if (!response.ok) {
throw new Error("Не вдалося завантажити завдання");
}
return response.json();
}Якщо компонент перестав використовувати запит або ключ змінився, TanStack Query може скасувати попередній запит. Це особливо важливо для пошуку та швидкої зміни фільтрів.
Для помилок запитів TanStack Query за замовчуванням виконує повторні спроби. Кількість і правило повторів можна налаштувати:
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30_000),
});Можна повторювати лише помилки, які справді можуть бути тимчасовими:
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status === 404) {
return false;
}
return failureCount < 3;
},
});Для цього прикладу HttpError має бути власним класом помилки, який містить HTTP-статус.
Повторні спроби для мутацій за замовчуванням вимкнені, оскільки повторне виконання операції запису може мати небажані наслідки.
Мутації використовуються для операцій, які змінюють серверний стан:
створення;
оновлення;
видалення;
перемикання прапорця;
завантаження даних.
const mutation = useMutation({
mutationFn: createTask,
});Основні властивості мутації:
mutate — запускає мутацію;
mutateAsync — запускає мутацію та повертає Promise;
variables — аргументи поточної мутації;
isPending — мутація виконується;
isError — сталася помилка;
isSuccess — мутація завершилася успішно;
error — помилка мутації.
const createMutation = useMutation({
mutationFn: createTask,
onSuccess: () => {
console.log("Завдання створено");
},
onError: (error) => {
console.error(error);
},
});
createMutation.mutate({
title: "Підготувати реліз",
});mutate не потрібно викликати під час рендера. Зазвичай його викликають у onSubmit, onClick або іншому обробнику події.
Після зміни даних серверний кеш може стати застарілим. Для цього використовується invalidateQueries.
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: createTask,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});Інвалідація:
позначає відповідні дані як застарілі;
запускає фонове завантаження для активних запитів;
дозволяє іншим компонентам отримати актуальний результат.
Інвалідація за префіксом:
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});може зачепити:
["tasks"]
["tasks", { status: "active" }]
["tasks", { status: "completed" }]Для точнішого збігу використовуйте exact:
await queryClient.invalidateQueries({
queryKey: ["tasks"],
exact: true,
});Інвалідація є безпечнішим загальним рішенням, ніж ручне оновлення всіх залежних записів кешу.
Якщо відповідь мутації містить актуальний об’єкт, його можна одразу записати в кеш:
const updateMutation = useMutation({
mutationFn: updateTask,
onSuccess: (updatedTask) => {
queryClient.setQueryData(
["task", updatedTask.id],
updatedTask,
);
queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});setQueryData оновлює кеш синхронно. Функція оновлення не повинна змінювати попереднє значення напряму:
queryClient.setQueryData<Task[]>(["tasks"], (oldTasks) => {
if (!oldTasks) {
return oldTasks;
}
return oldTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task,
);
});Після ручного оновлення кешу все одно може бути доцільно виконати інвалідацію, якщо сервер здатен змінити додаткові поля або пов’язані дані.
Оптимістичне оновлення змінює інтерфейс одразу, не очікуючи відповіді сервера. Якщо запит завершиться помилкою, попередній стан відновлюється.
Типовий порядок:
скасувати поточне завантаження;
зберегти попередні дані;
оновити кеш оптимістично;
у разі помилки виконати відкат;
після завершення інвалідувати запит.
const toggleMutation = useMutation({
mutationFn: ({ id, completed }: ToggleTaskInput) =>
toggleTask(id, completed),
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({
queryKey: ["tasks"],
});
const previousTasks = queryClient.getQueryData<Task[]>(["tasks"]);
queryClient.setQueryData<Task[]>(["tasks"], (tasks) => {
if (!tasks) {
return tasks;
}
return tasks.map((task) =>
task.id === id ? { ...task, completed } : task,
);
});
return { previousTasks };
},
onError: (_error, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(
["tasks"],
context.previousTasks,
);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});onSettled виконується і після успіху, і після помилки. Він гарантує фінальну синхронізацію із сервером.
Оптимістичні оновлення варто використовувати, коли:
операція має передбачуваний результат;
відкат можна виконати коректно;
користувачеві важлива швидка реакція інтерфейсу.
Нижче наведено приклад списку завдань із:
завантаженням даних;
кешуванням;
повторними спробами;
створенням завдання;
оптимістичним перемиканням стану;
інвалідацією після мутацій.
Приклад очікує REST API:
GET /api/tasks
POST /api/tasks
PATCH /api/tasks/:id
// App.tsx
import { FormEvent, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
type Task = {
id: string;
title: string;
completed: boolean;
};
type CreateTaskInput = {
title: string;
};
type ToggleTaskInput = {
id: string;
completed: boolean;
};
async function request<T>(
input: RequestInfo,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function fetchTasks(signal: AbortSignal): Promise<Task[]> {
return request<Task[]>("/api/tasks", { signal });
}
async function createTask(input: CreateTaskInput): Promise<Task> {
return request<Task>("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
}
async function toggleTask(
input: ToggleTaskInput,
): Promise<Task> {
return request<Task>(`/api/tasks/${input.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
completed: input.completed,
}),
});
}
export default function App() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const tasksQuery = useQuery({
queryKey: ["tasks"],
queryFn: ({ signal }) => fetchTasks(signal),
staleTime: 30_000,
retry: 2,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 10_000),
});
const createMutation = useMutation({
mutationFn: createTask,
onSuccess: async () => {
setTitle("");
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});
const toggleMutation = useMutation({
mutationFn: toggleTask,
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({
queryKey: ["tasks"],
});
const previousTasks =
queryClient.getQueryData<Task[]>(["tasks"]);
queryClient.setQueryData<Task[]>(["tasks"], (tasks) => {
if (!tasks) {
return tasks;
}
return tasks.map((task) =>
task.id === id ? { ...task, completed } : task,
);
});
return { previousTasks };
},
onError: (_error, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(
["tasks"],
context.previousTasks,
);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({
queryKey: ["tasks"],
});
},
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedTitle = title.trim();
if (!normalizedTitle || createMutation.isPending) {
return;
}
createMutation.mutate({
title: normalizedTitle,
});
}
if (tasksQuery.isPending) {
return <p>Завантаження завдань…</p>;
}
if (tasksQuery.isError) {
return (
<section>
<p>Не вдалося завантажити завдання.</p>
<button onClick={() => tasksQuery.refetch()}>
Спробувати ще раз
</button>
</section>
);
}
return (
<main>
<h1>Завдання</h1>
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Нове завдання"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={
createMutation.isPending || title.trim().length === 0
}
>
{createMutation.isPending ? "Створення…" : "Додати"}
</button>
</form>
{createMutation.isError && (
<p role="alert">
Не вдалося створити завдання.
</p>
)}
{tasksQuery.isFetching && <p>Оновлення даних…</p>}
<ul>
{tasksQuery.data.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
disabled={toggleMutation.isPending}
onChange={(event) => {
toggleMutation.mutate({
id: task.id,
completed: event.target.checked,
});
}}
/>
{task.title}
</label>
</li>
))}
</ul>
</main>
);
}У цьому прикладі tasksQuery.isFetching не замінює весь список завдань індикатором завантаження. Старі дані залишаються видимими під час фонового оновлення.
TanStack Query може автоматично повторно отримувати застарілі дані.
За замовчуванням активний застарілий запит може оновитися, коли користувач повертається у вкладку браузера:
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
refetchOnWindowFocus: true,
});Це корисно для даних, які можуть змінитися, поки користувач працює в іншому вікні.
Якщо оновлення небажане:
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
refetchOnWindowFocus: false,
});Застарілі запити можуть повторитися після відновлення мережевого з’єднання:
useQuery({
queryKey: ["tasks"],
queryFn: fetchTasks,
refetchOnReconnect: true,
});Для даних, які потрібно регулярно перевіряти, використовуйте refetchInterval:
useQuery({
queryKey: ["job", jobId],
queryFn: () => fetchJob(jobId),
refetchInterval: 5_000,
});Періодичне оновлення потрібно застосовувати обережно. Для звичайного списку, який рідко змінюється, інвалідація після мутації часто є кращим рішенням.
Якщо запит залежить від значення, якого ще немає, його можна вимкнути через enabled.
function UserTasks({ userId }: { userId?: string }) {
const tasksQuery = useQuery({
queryKey: ["user-tasks", userId],
queryFn: ({ signal }) =>
request<Task[]>(`/api/users/${userId}/tasks`, {
signal,
}),
enabled: Boolean(userId),
});
if (!userId) {
return <p>Оберіть користувача.</p>;
}
if (tasksQuery.isPending) {
return <p>Завантаження…</p>;
}
return (
<ul>
{tasksQuery.data?.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}Коли enabled дорівнює false, запит не запускається автоматично. Це корисно для залежних запитів і пошуку за неповними параметрами.
select для проєкції данихselect дозволяє отримати з кешованих даних лише потрібне представлення:
const completedCountQuery = useQuery({
queryKey: ["tasks"],
queryFn: ({ signal }) => fetchTasks(signal),
select: (tasks) =>
tasks.filter((task) => task.completed).length,
});select не змінює дані в кеші. Він змінює значення data, яке повертається конкретному компоненту.
Це корисно, коли різні компоненти використовують один запит, але кожному потрібна різна частина результату.
Перед переходом на сторінку деталей можна заздалегідь завантажити потрібні дані:
function TaskLink({ task }: { task: Task }) {
const queryClient = useQueryClient();
async function prefetchTask() {
await queryClient.prefetchQuery({
queryKey: ["task", task.id],
queryFn: () =>
request<Task>(`/api/tasks/${task.id}`),
staleTime: 30_000,
});
}
return (
<button onMouseEnter={prefetchTask}>
Відкрити: {task.title}
</button>
);
}Коли сторінка деталей виконає useQuery з таким самим ключем, дані вже можуть бути в кеші.
QueryClientQueryClient надає методи для ручної роботи з кешем:
getQueryData — прочитати дані;
setQueryData — синхронно оновити дані;
setQueriesData — оновити кілька відповідних записів;
invalidateQueries — позначити запити застарілими;
removeQueries — видалити записи;
resetQueries — скинути стан запитів.
Наприклад, після виходу користувача з облікового запису можна видалити приватні дані:
queryClient.removeQueries({
queryKey: ["current-user"],
});Не слід без потреби видаляти весь кеш. Краще працювати з конкретними ключами, щоб не втрачати дані, які залишаються актуальними.
Помилки потрібно обробляти на потрібному рівні:
локально в компоненті — для конкретного запиту;
через спільний компонент помилки — для однакового інтерфейсу;
через глобальні налаштування — для загальної поведінки.
Простий локальний варіант:
if (query.isError) {
return (
<div role="alert">
<p>{query.error.message}</p>
<button onClick={() => query.refetch()}>
Повторити
</button>
</div>
);
}Не показуйте isFetching як фатальну помилку. Фонове оновлення може тимчасово завершитися невдало, хоча в кеші ще залишаються придатні старі дані.
QueryClient під час рендераfunction App() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
{/* ... */}
</QueryClientProvider>
);
}Так кеш створюється заново при рендері.
Правильніше створити клієнт один раз у точці входу застосунку або використати стабільний екземпляр.
queryKeyЯкщо результат залежить від userId, фільтра або номера сторінки, ці значення повинні бути в ключі.
// Погано
useQuery({
queryKey: ["tasks"],
queryFn: () => fetchTasksForUser(userId),
});
// Добре
useQuery({
queryKey: ["tasks", "user", userId],
queryFn: () => fetchTasksForUser(userId),
});Не потрібно дублювати відповідь useQuery у useState без конкретної причини:
// Непотрібне дублювання
const { data } = useQuery(...);
const [tasks, setTasks] = useState(data);Такі значення можуть розсинхронізуватися. Використовуйте дані з кешу безпосередньо або оновлюйте кеш через setQueryData.
// Погано
tasks.push(newTask);Кеш потрібно оновлювати іммутабельно через setQueryData, повертаючи новий масив або новий об’єкт.
Якщо запит використовує:
["tasks", { status: "active" }]а після мутації інвалідується зовсім інший ключ, актуальне представлення не оновиться. Централізовані фабрики ключів допомагають уникати таких помилок.
isPending для фонового оновленняЯкщо дані вже завантажені, але виконується повторний запит, isPending може бути false. Для невеликого індикатора оновлення використовуйте isFetching.
Повторна спроба POST, переказу коштів або іншої неідемпотентної операції може створити дубль. Не вмикайте retry для мутацій без розуміння поведінки API.
Для кожного серверного ресурсу:
визначте структуру queryKey;
винесіть HTTP-запити в окремі функції;
передавайте signal у fetch;
налаштуйте staleTime відповідно до частоти змін даних;
після мутації інвалідуйте пов’язані запити;
використовуйте setQueryData, якщо відповідь уже містить новий стан;
застосовуйте оптимістичні оновлення лише з надійним відкатом;
розрізняйте початкове завантаження та фонове оновлення;
перевіряйте ключі й стани кешу через Devtools.
TanStack Query призначений для керування серверним станом у React.
useQuery завантажує та кешує дані.
queryKey повинен містити всі параметри, що впливають на результат.
staleTime визначає свіжість даних, а gcTime — тривалість збереження невикористаного кешу.
queryFn може використовувати AbortSignal для скасування запитів.
Запити підтримують автоматичні повторні спроби та фонове оновлення.
useMutation призначений для створення, оновлення й видалення даних.
Після мутацій зазвичай потрібно викликати invalidateQueries.
setQueryData оновлює кеш без нового запиту.
Оптимістичні оновлення потребують збереження попереднього стану та відкату в onError.
isPending і isFetching описують різні етапи завантаження.
Узгоджені ключі кешу є основою правильної синхронізації серверного стану.