Урок 09

Серверное состояние: TanStack Query

Серверные данные — это не то же самое, что клиентское состояние. Их нужно кешировать, обновлять, инвалидировать. Для этого есть TanStack Query (бывший React Query).

Проблема

Своими руками приходится делать:

  • Кеш
  • Индикатор загрузки
  • Обработка ошибок
  • Повторные запросы
  • Инвалидация после мутаций
  • Дедупликация запросов

TanStack Query даёт всё это из коробки.

Установка

npm install @tanstack/react-query

Провайдер

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 60_000,
            retry: 1,
        },
    },
});

function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <Main />
        </QueryClientProvider>
    );
}

useQuery

Запрос данных:

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

function Posts() {
    const { data, isLoading, error } = useQuery({
        queryKey: ['posts'],
        queryFn: async () => {
            const res = await fetch('/api/posts');
            if (!res.ok) throw new Error('Failed');
            return res.json();
        },
    });

    if (isLoading) return <p>Загрузка...</p>;
    if (error) return <p>Ошибка</p>;

    return (
        <ul>
            {data.map((post: Post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
}

queryKey — идентификатор кеша. queryFn — функция запроса.

Параметры

useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId),
    enabled: !!postId,
    staleTime: 5 * 60 * 1000,
});
  • enabled — запускать ли запрос
  • staleTime — сколько данные считаются свежими
  • refetchOnWindowFocus — обновлять при возврате на вкладку

useMutation

Изменение данных:

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

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

    const mutation = useMutation({
        mutationFn: (newPost: PostInput) =>
            fetch('/api/posts', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(newPost),
            }).then((r) => r.json()),
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: ['posts'] });
        },
    });

    return (
        <button
            onClick={() => mutation.mutate({ title: 'Новый', body: 'Текст' })}
            disabled={mutation.isPending}
        >
            Создать
        </button>
    );
}

invalidateQueries помечает кеш устаревшим и запускает повторный запрос.

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

Обновить UI до ответа сервера:

useMutation({
    mutationFn: updatePost,
    onMutate: async (newPost) => {
        await queryClient.cancelQueries({ queryKey: ['posts'] });
        const previous = queryClient.getQueryData(['posts']);
        queryClient.setQueryData(['posts'], (old: Post[]) =>
            old.map((p) => (p.id === newPost.id ? newPost : p))
        );
        return { previous };
    },
    onError: (_, __, context) => {
        queryClient.setQueryData(['posts'], context?.previous);
    },
    onSettled: () => {
        queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
});

SWR

Альтернатива от Vercel. Проще, но менее функциональна:

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((r) => r.json());

function Posts() {
    const { data, error, isLoading } = useSWR('/api/posts', fetcher);

    if (isLoading) return <p>Загрузка</p>;
    if (error) return <p>Ошибка</p>;

    return <ul>{data.map((p: Post) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Итоги

  • Серверные данные — отдельный слой
  • TanStack Query кеширует, повторяет, инвалидирует
  • useQuery для чтения, useMutation для записи
  • Оптимистичные обновления улучшают UX
  • SWR — более простая альтернатива