React Query (TanStack Query) - работа с сервером

16 июня 2026
Автор

Олег Марков

Введение

Каждое React-приложение рано или поздно сталкивается с задачей получения данных с сервера. На первый взгляд кажется, что useEffect + useState вполне справляются с этой задачей. Но стоит приложению немного вырасти, и вы обнаружите целый пласт проблем: дублирование запросов, устаревание данных, race conditions, ручное управление состояниями загрузки и ошибки, сложная логика обновления кэша.

TanStack Query (ранее известная как React Query) — это библиотека для управления «серверным состоянием» в React-приложениях. Она берёт на себя все сложности работы с асинхронными данными: кэширование, фоновую синхронизацию, инвалидацию, пагинацию и многое другое.

В этой статье вы узнаете, как установить и настроить TanStack Query, как использовать ключевые хуки useQuery и useMutation, научитесь управлять кэшем, реализуете бесконечную прокрутку и оптимистичные обновления.

Что такое серверное состояние и зачем нужна TanStack Query

Прежде чем переходить к коду, важно понять концептуальное различие между двумя типами состояния в React-приложениях.

Клиентское состояние — это данные, которые существуют только в браузере: открыт ли модальное окно, выбранный пункт меню, значение в поле ввода. Оно синхронное, предсказуемое и полностью под вашим контролем.

Серверное состояние — это данные, которые хранятся на сервере и лишь отображаются в браузере: список пользователей, содержимое статьи, данные профиля. У него принципиально другие характеристики:

  • Оно асинхронное — получение данных требует времени
  • Оно может устареть — другой пользователь мог изменить данные пока вы работаете
  • Оно разделяется между несколькими компонентами
  • Оно требует кэширования для производительности

Попытка управлять серверным состоянием теми же инструментами, что и клиентским (Redux, Zustand), приводит к значительному усложнению кода. TanStack Query решает эту проблему, предоставляя специализированные инструменты именно для серверного состояния.

Вот что вы получаете «из коробки»:

  • Автоматическое кэширование с настраиваемым временем жизни
  • Фоновое обновление устаревших данных
  • Дедупликация одновременных запросов
  • Автоматические повторные попытки при ошибках сети
  • Инвалидация и синхронизация кэша
  • Оптимистичные обновления
  • Пагинация и бесконечная прокрутка
  • DevTools для отладки

Установка и настройка

Установка пакетов

npm install @tanstack/react-query

Для работы с DevTools (рекомендуется в разработке):

npm install @tanstack/react-query-devtools

Создание QueryClient

QueryClient — это центральный объект, который управляет всем кэшем запросов. Он хранит данные, отслеживает состояние запросов и обеспечивает связь между компонентами.

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Время, в течение которого данные считаются «свежими»
      // Пока данные свежие, повторный запрос не выполняется
      staleTime: 1000 * 60 * 5, // 5 минут

      // Время хранения неиспользуемых данных в кэше
      gcTime: 1000 * 60 * 10, // 10 минут (раньше называлось cacheTime)

      // Количество повторных попыток при ошибке
      retry: 3,

      // Задержка между повторными попытками (экспоненциальная)
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

      // Обновлять данные при фокусе окна браузера
      refetchOnWindowFocus: true,

      // Обновлять данные при восстановлении сетевого соединения
      refetchOnReconnect: true,
    },
    mutations: {
      // Количество повторных попыток для мутаций
      retry: 0,
    },
  },
});

Подключение QueryClientProvider

QueryClientProvider — это React-провайдер, который делает QueryClient доступным для всех компонентов приложения. Его нужно разместить как можно выше в дереве компонентов.

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/query-client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      {/* DevTools отображаются только в режиме разработки */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);

Если вы используете Next.js App Router, создайте отдельный компонент-провайдер, поскольку провайдер требует 'use client':

// src/components/providers/QueryProvider.tsx
'use client';

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '@/lib/query-client';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// src/app/layout.tsx
import { QueryProvider } from '@/components/providers/QueryProvider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru">
      <body>
        <QueryProvider>{children}</QueryProvider>
      </body>
    </html>
  );
}

useQuery: получение данных

useQuery — основной хук для получения и кэширования данных. Он принимает объект конфигурации и возвращает объект с состоянием запроса.

Базовое использование

// src/api/posts.ts
// Функция для получения данных — обычная async функция
interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

export async function fetchPosts(): Promise<Post[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  return response.json();
}

export async function fetchPostById(id: number): Promise<Post> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);

  if (!response.ok) {
    throw new Error(`Post ${id} not found`);
  }

  return response.json();
}
// src/components/PostsList.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from '../api/posts';

function PostsList() {
  const {
    data: posts,       // Данные ответа (типизированы автоматически)
    isLoading,         // true при первой загрузке (нет данных в кэше)
    isFetching,        // true при любом запросе (включая фоновое обновление)
    isSuccess,         // true если запрос успешно завершён
    isError,           // true если запрос завершился ошибкой
    error,             // Объект ошибки
    refetch,           // Функция для ручного перезапроса
    dataUpdatedAt,     // Время последнего успешного обновления данных
  } = useQuery({
    queryKey: ['posts'],        // Уникальный ключ для кэша
    queryFn: fetchPosts,        // Функция получения данных
    staleTime: 1000 * 60 * 2,  // Перезаписать глобальные настройки: 2 минуты
  });

  if (isLoading) {
    return (
      <div className="flex items-center gap-2">
        <span className="animate-spin">⏳</span>
        <span>Загрузка постов...</span>
      </div>
    );
  }

  if (isError) {
    return (
      <div className="text-red-500">
        <p>Ошибка загрузки: {error.message}</p>
        <button onClick={() => refetch()}>Повторить</button>
      </div>
    );
  }

  return (
    <div>
      {/* isFetching будет true при фоновом обновлении */}
      {isFetching && <div className="text-sm text-gray-400">Обновление...</div>}

      <ul>
        {posts?.map((post) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Ключи запросов (Query Keys)

Ключ запроса — это уникальный идентификатор для данных в кэше. Это может быть строка, массив строк или массив с дополнительными параметрами. TanStack Query использует ключ для дедупликации запросов и управления инвалидацией.

// Простой строковый ключ
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

// Массив с идентификатором — для запросов с параметрами
useQuery({ queryKey: ['posts', 42], queryFn: () => fetchPostById(42) });

// Массив с объектом — для запросов с несколькими параметрами
useQuery({
  queryKey: ['posts', { page: 1, limit: 10, status: 'published' }],
  queryFn: () => fetchPosts({ page: 1, limit: 10, status: 'published' }),
});

// Ключи с вложенностью — удобно для инвалидации групп
useQuery({ queryKey: ['users', userId, 'profile'], queryFn: () => fetchUserProfile(userId) });
useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId) });
// Инвалидация ['users', userId] инвалидирует оба запроса

Важно: ключи сравниваются структурно (deep equality), поэтому ['posts', { id: 1 }] и ['posts', { id: 1 }] — это один и тот же ключ.

Запрос с параметрами

// src/components/PostDetail.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchPostById } from '../api/posts';

interface PostDetailProps {
  postId: number;
}

function PostDetail({ postId }: PostDetailProps) {
  const { data: post, isLoading, isError } = useQuery({
    queryKey: ['posts', postId],         // Ключ включает параметр запроса
    queryFn: () => fetchPostById(postId), // Функция использует параметр из замыкания
    enabled: postId > 0,                  // Запрос выполняется только если postId > 0
  });

  if (isLoading) return <div>Загрузка...</div>;
  if (isError) return <div>Пост не найден</div>;

  return (
    <article>
      <h1>{post?.title}</h1>
      <p>{post?.body}</p>
    </article>
  );
}

Параметры useQuery

const query = useQuery({
  // Уникальный ключ запроса (обязательно)
  queryKey: ['posts'],

  // Функция для получения данных (обязательно)
  queryFn: fetchPosts,

  // Включить/выключить запрос
  // Полезно для условных запросов или зависимых запросов
  enabled: true,

  // Считать данные свежими N миллисекунд
  // 0 = всегда устаревшие (запрашивать при каждом монтировании)
  // Infinity = никогда не устаревают
  staleTime: 1000 * 60 * 5,

  // Как долго хранить неиспользуемые данные в кэше
  gcTime: 1000 * 60 * 10,

  // Обновлять при монтировании компонента (если данные устарели)
  refetchOnMount: true,

  // Обновлять при получении фокуса окном браузера
  refetchOnWindowFocus: true,

  // Обновлять при восстановлении соединения
  refetchOnReconnect: true,

  // Интервал автоматического обновления (в мс), 0 = отключено
  refetchInterval: 0,

  // Продолжать обновление, когда вкладка неактивна
  refetchIntervalInBackground: false,

  // Количество повторных попыток при ошибке
  retry: 3,

  // Задержка между попытками
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

  // Начальные данные (не помечаются как устаревшие автоматически)
  initialData: [],

  // Данные-заменитель (не сохраняются в кэш)
  placeholderData: [],

  // Выбрать/преобразовать данные
  select: (data) => data.filter(post => post.userId === 1),

  // Коллбэки
  meta: { source: 'posts-component' },
});

Зависимые запросы

Часто второй запрос зависит от результата первого. Используйте параметр enabled для создания цепочки запросов:

function UserPosts({ userId }: { userId: number }) {
  // Первый запрос: получаем профиль пользователя
  const userQuery = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
  });

  // Второй запрос: получаем посты только после загрузки профиля
  const postsQuery = useQuery({
    queryKey: ['users', userId, 'posts'],
    queryFn: () => fetchUserPosts(userQuery.data!.id),
    // Запрос не выполняется пока не получены данные пользователя
    enabled: !!userQuery.data,
  });

  if (userQuery.isLoading) return <div>Загрузка профиля...</div>;
  if (postsQuery.isLoading) return <div>Загрузка постов пользователя...</div>;

  return (
    <div>
      <h2>{userQuery.data?.name}</h2>
      <ul>
        {postsQuery.data?.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

Параллельные запросы

Несколько вызовов useQuery выполняются параллельно автоматически:

function Dashboard() {
  // Все три запроса выполняются одновременно
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
  const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats });

  const isLoading = usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading;

  if (isLoading) return <div>Загрузка дашборда...</div>;

  return (
    <div>
      <UsersWidget data={usersQuery.data} />
      <PostsWidget data={postsQuery.data} />
      <StatsWidget data={statsQuery.data} />
    </div>
  );
}

Если количество запросов динамическое, используйте useQueries:

import { useQueries } from '@tanstack/react-query';

function PostsGallery({ postIds }: { postIds: number[] }) {
  const postQueries = useQueries({
    queries: postIds.map(id => ({
      queryKey: ['posts', id],
      queryFn: () => fetchPostById(id),
    })),
  });

  const isLoading = postQueries.some(q => q.isLoading);
  const posts = postQueries.map(q => q.data).filter(Boolean);

  if (isLoading) return <div>Загрузка постов...</div>;

  return (
    <div className="grid grid-cols-3 gap-4">
      {posts.map(post => (
        <PostCard key={post?.id} post={post!} />
      ))}
    </div>
  );
}

useMutation: отправка данных

useMutation используется для операций, которые изменяют данные на сервере: создание, обновление, удаление. В отличие от useQuery, мутации не выполняются автоматически — вы вызываете их вручную.

Базовое использование

// src/api/posts.ts
interface CreatePostData {
  title: string;
  body: string;
  userId: number;
}

export async function createPost(data: CreatePostData): Promise<Post> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error('Не удалось создать пост');
  }

  return response.json();
}

export async function updatePost(id: number, data: Partial<CreatePostData>): Promise<Post> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Не удалось обновить пост ${id}`);
  }

  return response.json();
}

export async function deletePost(id: number): Promise<void> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
    method: 'DELETE',
  });

  if (!response.ok) {
    throw new Error(`Не удалось удалить пост ${id}`);
  }
}
// src/components/CreatePostForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPost } from '../api/posts';
import { useState } from 'react';

function CreatePostForm() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const mutation = useMutation({
    mutationFn: createPost,

    // Вызывается при успешном завершении
    onSuccess: (newPost) => {
      console.log('Пост создан:', newPost);
      // Инвалидируем кэш списка постов, чтобы он обновился
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      // Очищаем форму
      setTitle('');
      setBody('');
    },

    // Вызывается при ошибке
    onError: (error) => {
      console.error('Ошибка создания поста:', error.message);
    },

    // Вызывается всегда (и при успехе, и при ошибке)
    onSettled: () => {
      console.log('Запрос завершён');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({
      title,
      body,
      userId: 1,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="Заголовок поста"
          required
        />
      </div>
      <div>
        <textarea
          value={body}
          onChange={(e) => setBody(e.target.value)}
          placeholder="Содержимое поста"
          required
        />
      </div>

      {mutation.isError && (
        <div className="text-red-500">{mutation.error.message}</div>
      )}

      {mutation.isSuccess && (
        <div className="text-green-500">Пост успешно создан!</div>
      )}

      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Создаём...' : 'Создать пост'}
      </button>
    </form>
  );
}

mutateAsync для работы с промисами

Метод mutate работает с коллбэками, но иногда удобнее использовать mutateAsync, который возвращает промис:

function PostActions({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: deletePost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const handleDelete = async () => {
    const confirmed = window.confirm('Удалить пост?');
    if (!confirmed) return;

    try {
      // mutateAsync возвращает промис — удобно в async/await
      await deleteMutation.mutateAsync(postId);
      console.log('Пост удалён');
    } catch (error) {
      console.error('Ошибка удаления:', error);
    }
  };

  return (
    <button
      onClick={handleDelete}
      disabled={deleteMutation.isPending}
    >
      {deleteMutation.isPending ? 'Удаление...' : 'Удалить'}
    </button>
  );
}

Состояния мутации

useMutation возвращает объект с несколькими полезными свойствами:

const {
  mutate,        // Функция запуска мутации (без возвращаемого значения)
  mutateAsync,   // Функция запуска мутации (возвращает промис)
  isPending,     // true пока мутация выполняется
  isSuccess,     // true после успешного завершения
  isError,       // true после ошибки
  isIdle,        // true если мутация ещё не запускалась
  data,          // Данные ответа после успешного завершения
  error,         // Объект ошибки
  reset,         // Сбрасывает состояние мутации в isIdle
  variables,     // Аргументы последнего вызова mutate
  submittedAt,   // Время последнего вызова
} = useMutation({ mutationFn: createPost });

Коллбэки с переменными и контекстом

Все коллбэки мутации получают переменные (аргументы вызова), результат/ошибку и контекст:

interface UpdatePostVariables {
  id: number;
  title: string;
}

const updateMutation = useMutation({
  mutationFn: ({ id, title }: UpdatePostVariables) => updatePost(id, { title }),

  // variables — аргументы вызова mutate
  onMutate: async (variables) => {
    console.log('Начинаем обновление поста', variables.id);
    // Можно вернуть контекст для использования в onSuccess/onError
    return { previousTitle: 'Старый заголовок' };
  },

  // data — ответ сервера, variables — аргументы, context — результат onMutate
  onSuccess: (data, variables, context) => {
    console.log(`Пост ${variables.id} обновлён`);
    console.log('Старый заголовок был:', context?.previousTitle);
  },

  onError: (error, variables, context) => {
    console.error(`Ошибка обновления поста ${variables.id}:`, error.message);
  },

  onSettled: (data, error, variables) => {
    // Вызывается всегда после завершения
    queryClient.invalidateQueries({ queryKey: ['posts', variables.id] });
  },
});

Инвалидация кэша с queryClient.invalidateQueries

Инвалидация кэша — ключевая концепция TanStack Query. Когда вы изменяете данные на сервере, нужно пометить соответствующие кэшированные данные как устаревшие, чтобы они были обновлены при следующем отображении.

Базовая инвалидация

import { useQueryClient } from '@tanstack/react-query';

function PostManager() {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: deletePost,
    onSuccess: () => {
      // Инвалидировать все запросы с ключом, начинающимся с 'posts'
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const updateMutation = useMutation({
    mutationFn: ({ id, data }: { id: number; data: Partial<Post> }) => updatePost(id, data),
    onSuccess: (updatedPost) => {
      // Инвалидировать конкретный пост
      queryClient.invalidateQueries({ queryKey: ['posts', updatedPost.id] });
      // Также инвалидировать список постов
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

Точная инвалидация с exact

По умолчанию invalidateQueries инвалидирует все запросы, ключ которых начинается с указанного ключа. Используйте exact: true для точного совпадения:

// Инвалидирует ['posts'], ['posts', 1], ['posts', { page: 1 }] и т.д.
queryClient.invalidateQueries({ queryKey: ['posts'] });

// Инвалидирует ТОЛЬКО запросы с ключом ['posts']
queryClient.invalidateQueries({ queryKey: ['posts'], exact: true });

// Инвалидирует только запрос для поста с id=1
queryClient.invalidateQueries({ queryKey: ['posts', 1], exact: true });

// Инвалидировать все запросы с предикатом
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'posts' &&
    typeof query.queryKey[1] === 'number' &&
    query.queryKey[1] > 100,
});

Прямое обновление кэша без перезапроса

Вместо инвалидации (которая вызывает новый запрос) можно напрямую обновить данные в кэше через setQueryData:

const createMutation = useMutation({
  mutationFn: createPost,
  onSuccess: (newPost) => {
    // Вариант 1: Инвалидировать и перезапросить
    // queryClient.invalidateQueries({ queryKey: ['posts'] });

    // Вариант 2: Напрямую добавить новый пост в кэш (без нового HTTP-запроса)
    queryClient.setQueryData<Post[]>(['posts'], (oldPosts) => {
      if (!oldPosts) return [newPost];
      return [...oldPosts, newPost];
    });

    // Также сохранить данные нового поста для запроса по id
    queryClient.setQueryData(['posts', newPost.id], newPost);
  },
});

Предзагрузка данных (Prefetching)

Предзагрузка позволяет загружать данные заранее — например, при наведении мыши на ссылку:

function PostLink({ postId, title }: { postId: number; title: string }) {
  const queryClient = useQueryClient();

  const prefetchPost = () => {
    // Загружаем данные заранее, они попадут в кэш
    queryClient.prefetchQuery({
      queryKey: ['posts', postId],
      queryFn: () => fetchPostById(postId),
      // Не загружать если данные в кэше свежее 10 минут
      staleTime: 10 * 60 * 1000,
    });
  };

  return (
    <a
      href={`/posts/${postId}`}
      onMouseEnter={prefetchPost} // Начинаем загрузку при наведении
      onFocus={prefetchPost}       // И при фокусе (для доступности)
    >
      {title}
    </a>
  );
}

useInfiniteQuery для пагинации

useInfiniteQuery — специализированный хук для реализации бесконечной прокрутки и «Загрузить ещё». Он автоматически объединяет данные нескольких страниц.

Настройка API для бесконечной прокрутки

// src/api/posts.ts
interface PostsPage {
  posts: Post[];
  nextPage: number | null;
  totalPages: number;
}

export async function fetchPostsPage(page: number = 1): Promise<PostsPage> {
  const limit = 10;
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${limit}`
  );

  const posts: Post[] = await response.json();
  const total = parseInt(response.headers.get('X-Total-Count') ?? '100');
  const totalPages = Math.ceil(total / limit);

  return {
    posts,
    nextPage: page < totalPages ? page + 1 : null,
    totalPages,
  };
}

Использование useInfiniteQuery

// src/components/InfinitePostsList.tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { fetchPostsPage } from '../api/posts';
import { useRef, useEffect } from 'react';

function InfinitePostsList() {
  const loadMoreRef = useRef<HTMLDivElement>(null);

  const {
    data,                   // Объект с pages[] и pageParams[]
    fetchNextPage,          // Загрузить следующую страницу
    fetchPreviousPage,      // Загрузить предыдущую страницу
    hasNextPage,            // true если есть ещё страницы
    hasPreviousPage,        // true если есть предыдущие страницы
    isFetchingNextPage,     // true при загрузке следующей страницы
    isFetchingPreviousPage, // true при загрузке предыдущей страницы
    isLoading,              // true при первой загрузке
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => fetchPostsPage(pageParam),

    // Начальный параметр первой страницы
    initialPageParam: 1,

    // Возвращает параметр следующей страницы
    // Возвращает undefined если следующей страницы нет
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,

    // Опционально: для двунаправленной пагинации
    // getPreviousPageParam: (firstPage) => firstPage.previousPage ?? undefined,
  });

  // Автоматическая подгрузка при прокрутке с Intersection Observer
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current);
    }

    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) return <div>Загрузка...</div>;
  if (isError) return <div>Ошибка: {error.message}</div>;

  // data.pages — массив ответов для каждой загруженной страницы
  const allPosts = data.pages.flatMap(page => page.posts);

  return (
    <div>
      <ul>
        {allPosts.map((post) => (
          <li key={post.id} className="mb-4 p-4 border rounded">
            <h3 className="font-semibold">{post.title}</h3>
            <p className="text-gray-600">{post.body}</p>
          </li>
        ))}
      </ul>

      {/* Элемент для отслеживания прокрутки */}
      <div ref={loadMoreRef} className="py-4 text-center">
        {isFetchingNextPage ? (
          <span>Загрузка...</span>
        ) : hasNextPage ? (
          <button onClick={() => fetchNextPage()}>
            Загрузить ещё
          </button>
        ) : (
          <span className="text-gray-400">Все посты загружены</span>
        )}
      </div>
    </div>
  );
}

Пагинация с кнопками (классическая)

Если нужна классическая постраничная навигация, а не бесконечная прокрутка, используйте обычный useQuery с параметром placeholderData:

import { useQuery, keepPreviousData } from '@tanstack/react-query';

function PaginatedPosts() {
  const [page, setPage] = useState(1);

  const { data, isPlaceholderData, isLoading } = useQuery({
    queryKey: ['posts', 'paginated', page],
    queryFn: () => fetchPostsPage(page),
    // Показывать предыдущие данные пока загружается новая страница
    // Это убирает "мигание" при переключении страниц
    placeholderData: keepPreviousData,
  });

  return (
    <div>
      {isLoading ? (
        <div>Загрузка...</div>
      ) : (
        <>
          <ul>
            {data?.posts.map((post) => (
              <li key={post.id}>{post.title}</li>
            ))}
          </ul>

          <div className="flex gap-2 mt-4">
            <button
              onClick={() => setPage(p => Math.max(1, p - 1))}
              disabled={page === 1 || isPlaceholderData}
            >
              Назад
            </button>

            <span>Страница {page} из {data?.totalPages}</span>

            <button
              onClick={() => setPage(p => p + 1)}
              disabled={!data?.nextPage || isPlaceholderData}
            >
              Вперёд
            </button>
          </div>
        </>
      )}
    </div>
  );
}

Оптимистичные обновления

Оптимистичные обновления — техника, при которой UI обновляется немедленно, не дожидаясь ответа сервера. Если запрос завершается ошибкой, изменения откатываются. Это значительно улучшает ощущение отзывчивости интерфейса.

Оптимистичное удаление

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

function TodoList() {
  const queryClient = useQueryClient();

  const deleteMutation = useMutation({
    mutationFn: (id: number) => deleteTodo(id),

    // onMutate вызывается СИНХРОННО до отправки запроса
    onMutate: async (deletedId) => {
      // 1. Отменяем исходящие запросы для этого ключа,
      //    чтобы они не перезаписали наши оптимистичные данные
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // 2. Сохраняем текущие данные (для возможного отката)
      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // 3. Оптимистично обновляем кэш
      queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) =>
        oldTodos?.filter(todo => todo.id !== deletedId) ?? []
      );

      // 4. Возвращаем контекст с сохранёнными данными
      return { previousTodos };
    },

    // При ошибке откатываем изменения
    onError: (error, deletedId, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos);
      }
      console.error('Ошибка удаления, откатываем изменения');
    },

    // После завершения (успех или ошибка) синхронизируемся с сервером
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  const todosQuery = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  return (
    <ul>
      {todosQuery.data?.map(todo => (
        <li key={todo.id} className="flex items-center gap-2">
          <span>{todo.title}</span>
          <button
            onClick={() => deleteMutation.mutate(todo.id)}
            disabled={deleteMutation.isPending}
          >
            Удалить
          </button>
        </li>
      ))}
    </ul>
  );
}

Оптимистичное обновление

function TodoItem({ todo }: { todo: Todo }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: (todo: Todo) =>
      updateTodo(todo.id, { completed: !todo.completed }),

    onMutate: async (toggledTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

      // Оптимистично переключаем статус
      queryClient.setQueryData<Todo[]>(['todos'], (oldTodos) =>
        oldTodos?.map(t =>
          t.id === toggledTodo.id
            ? { ...t, completed: !t.completed }
            : t
        ) ?? []
      );

      return { previousTodos };
    },

    onError: (error, variables, context) => {
      // Откатываем при ошибке
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <li
      className={`cursor-pointer ${todo.completed ? 'line-through text-gray-400' : ''}`}
      onClick={() => toggleMutation.mutate(todo)}
    >
      {todo.title}
    </li>
  );
}

Оптимистичное добавление с временным ID

function AddTodoForm() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');

  const addMutation = useMutation({
    mutationFn: (title: string) => createTodo({ title, completed: false }),

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

      // Создаём временный объект с отрицательным ID
      const optimisticTodo: Todo = {
        id: -Date.now(), // Временный уникальный ID
        title: newTitle,
        completed: false,
      };

      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        [...(old ?? []), optimisticTodo]
      );

      return { previousTodos, optimisticTodo };
    },

    onSuccess: (realTodo, variables, context) => {
      // Заменяем временный элемент реальным ответом сервера
      queryClient.setQueryData<Todo[]>(['todos'], (old) =>
        old?.map(todo =>
          todo.id === context?.optimisticTodo.id ? realTodo : todo
        ) ?? []
      );
    },

    onError: (error, variables, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;
    addMutation.mutate(title);
    setTitle('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Новая задача"
      />
      <button type="submit" disabled={addMutation.isPending}>
        Добавить
      </button>
    </form>
  );
}

QueryClient DevTools для отладки

TanStack Query DevTools — незаменимый инструмент разработчика, который отображает состояние кэша в реальном времени.

Установка и подключение

npm install @tanstack/react-query-devtools
// Базовое подключение (уже показано выше)
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {/* Отображается только в process.env.NODE_ENV === 'development' */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Настройка DevTools

<ReactQueryDevtools
  // Открыт по умолчанию
  initialIsOpen={false}

  // Позиция кнопки: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'
  buttonPosition="bottom-right"

  // Позиция панели: 'top' | 'bottom' | 'left' | 'right'
  position="bottom"
/>

Что показывают DevTools

DevTools предоставляют полную информацию о состоянии кэша:

  • Список всех запросов с их ключами и статусами (fresh, stale, fetching, paused, inactive)
  • Данные кэша для каждого запроса — можно изучить прямо в интерфейсе
  • Время последнего обновления и время до следующего устаревания
  • Количество наблюдателей — сколько компонентов используют данный запрос
  • История запросов и ошибки

Программное использование QueryClient

Помимо UI-инструмента, можно работать с кэшем программно:

import { useQueryClient } from '@tanstack/react-query';

function DebugPanel() {
  const queryClient = useQueryClient();

  const logCacheState = () => {
    // Получить все запросы из кэша
    const queries = queryClient.getQueryCache().getAll();
    console.log('Все запросы в кэше:', queries.map(q => ({
      key: q.queryKey,
      state: q.state.status,
      dataUpdatedAt: new Date(q.state.dataUpdatedAt).toISOString(),
    })));
  };

  const clearCache = () => {
    // Очистить весь кэш
    queryClient.clear();
    console.log('Кэш очищен');
  };

  const refetchAll = () => {
    // Перезапросить все активные запросы
    queryClient.refetchQueries({ type: 'active' });
  };

  return (
    <div className="fixed bottom-4 right-4 flex flex-col gap-2">
      <button onClick={logCacheState}>Показать кэш в консоли</button>
      <button onClick={clearCache}>Очистить кэш</button>
      <button onClick={refetchAll}>Обновить все данные</button>
    </div>
  );
}

Пользовательские хуки для инкапсуляции логики

Хорошей практикой является создание пользовательских хуков, которые инкапсулируют логику работы с конкретными данными:

// src/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchPosts, fetchPostById, createPost, updatePost, deletePost } from '../api/posts';

// Хук для получения всех постов
export function usePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    staleTime: 1000 * 60 * 5,
  });
}

// Хук для получения конкретного поста
export function usePost(id: number) {
  return useQuery({
    queryKey: ['posts', id],
    queryFn: () => fetchPostById(id),
    enabled: id > 0,
  });
}

// Хук для создания поста с инвалидацией
export function useCreatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createPost,
    onSuccess: (newPost) => {
      queryClient.setQueryData<Post[]>(['posts'], (old) =>
        old ? [...old, newPost] : [newPost]
      );
    },
  });
}

// Хук для обновления поста с оптимистичным обновлением
export function useUpdatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: number; data: Partial<Post> }) =>
      updatePost(id, data),

    onMutate: async ({ id, data }) => {
      await queryClient.cancelQueries({ queryKey: ['posts', id] });
      const previousPost = queryClient.getQueryData<Post>(['posts', id]);

      queryClient.setQueryData<Post>(['posts', id], (old) =>
        old ? { ...old, ...data } : old
      );

      return { previousPost };
    },

    onError: (error, { id }, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['posts', id], context.previousPost);
      }
    },

    onSettled: (data, error, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['posts', id] });
    },
  });
}

// Хук для удаления поста
export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deletePost,
    onSuccess: (_, deletedId) => {
      queryClient.setQueryData<Post[]>(['posts'], (old) =>
        old?.filter(post => post.id !== deletedId) ?? []
      );
      queryClient.removeQueries({ queryKey: ['posts', deletedId] });
    },
  });
}

Использование этих хуков в компонентах становится очень чистым:

function PostsPage() {
  const { data: posts, isLoading } = usePosts();
  const createPost = useCreatePost();
  const deletePost = useDeletePost();

  if (isLoading) return <div>Загрузка...</div>;

  return (
    <div>
      {posts?.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <button onClick={() => deletePost.mutate(post.id)}>
            Удалить
          </button>
        </div>
      ))}
    </div>
  );
}

Обработка ошибок

TanStack Query предоставляет несколько механизмов для обработки ошибок.

Глобальный обработчик ошибок

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      // Глобальный обработчик для всех query-ошибок
      console.error(`Ошибка запроса ${String(query.queryKey)}:`, error.message);
      // Можно показать тост-уведомление
      // toast.error(`Ошибка: ${error.message}`);
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      // Глобальный обработчик для всех mutation-ошибок
      console.error('Ошибка мутации:', error.message);
    },
  }),
});

Error Boundary интеграция

TanStack Query поддерживает интеграцию с React Error Boundaries через параметр throwOnError:

import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ error, resetErrorBoundary }) => (
            <div>
              <p>Произошла ошибка: {error.message}</p>
              <button onClick={resetErrorBoundary}>Повторить</button>
            </div>
          )}
        >
          <PostsList />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

function PostsList() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    // Пробрасывать ошибку в Error Boundary
    throwOnError: true,
  });

  return <ul>{data?.map(/* ... */)}</ul>;
}

Заключение

TanStack Query — одна из наиболее удачных библиотек для управления серверным состоянием в React. Она решает реальные проблемы, с которыми сталкивается каждый разработчик при работе с асинхронными данными, и делает это элегантно и без лишней сложности.

Ключевые преимущества библиотеки:

  • Автоматическое кэширование избавляет от лишних запросов и ускоряет приложение
  • Фоновая синхронизация поддерживает данные актуальными без явных усилий
  • useQuery и useMutation — простые, но мощные примитивы для любых сценариев
  • useInfiniteQuery упрощает реализацию бесконечной прокрутки
  • Оптимистичные обновления улучшают UX с минимальным кодом
  • DevTools делают отладку прозрачной и удобной
  • TypeScript-first — полная типизация без дополнительных усилий

Рекомендуется использовать TanStack Query как основной инструмент для работы с серверными данными в любом React-приложении, независимо от того, используете ли вы Redux, Zustand или другое решение для клиентского состояния.

Стрелочка влевоРабота с формами и селектами в ReactПлагины в React что это и как их использоватьСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

Uncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыHOC в React: мастерство композиции компонентовuseMemo: как спасти производительность от тяжелых вычисленийError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRedux Toolkit - современный ReduxRecoil — библиотека управления состоянием от FacebookВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseOptimistic — оптимистичные обновления UIuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияuseEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19Оптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextКак предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в React
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий