Query functions

query function — это функция, которая отвечает за получение данных в TanStack Query. Именно она выполняет HTTP-запрос, обращается к API, читает данные из IndexedDB, localStorage или любого другого источника.

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

Базовая структура выглядит следующим образом:

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
});

В этом примере:

  • queryKey идентифицирует запрос;
  • queryFn выполняет получение данных.

Простая query function

Наиболее распространённый вариант — использование fetch.

async function fetchUsers() {
    const response = await fetch('/api/users');

    if (!response.ok) {
        throw new Error('Ошибка загрузки пользователей');
    }

    return response.json();
}

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

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
});

Ключевой момент заключается в том, что query function обязана:

  1. Возвращать Promise;
  2. Либо вернуть данные;
  3. Либо выбросить ошибку.

Inline query function

Функцию можно передавать непосредственно внутрь useQuery.

const query = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
        const response = await fetch('/api/users');

        if (!response.ok) {
            throw new Error('Ошибка загрузки');
        }

        return response.json();
    }
});

Такой подход удобен для небольших запросов.

Недостатки:

  • ухудшается читаемость;
  • усложняется переиспользование;
  • сложнее тестировать;
  • появляется дублирование кода.

Поэтому в крупных проектах query functions обычно выносят отдельно.


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

TanStack Query не зависит от HTTP-клиента.

Вместо fetch можно использовать axios.

import axios from 'axios';

async function fetchUsers() {
    const response = await axios.get('/api/users');

    return response.data;
}

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

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
});

Возврат данных

Query function должна вернуть итоговые данные, которые попадут в data.

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
});

console.log(query.data);

Если функция возвращает:

return response.data;

то именно это значение станет содержимым query.data.


Генерация ошибок

Ошибки должны выбрасываться через throw.

async function fetchUsers() {
    const response = await fetch('/api/users');

    if (!response.ok) {
        throw new Error('Ошибка сервера');
    }

    return response.json();
}

Тогда TanStack Query автоматически:

  • переведёт запрос в состояние ошибки;
  • заполнит error;
  • активирует retry-механику;
  • обновит isError.

Что происходит при throw

Если query function выбрасывает ошибку:

throw new Error('Unauthorized');

то состояние запроса становится:

{
    status: 'error',
    isError: true,
    error: Error
}

Пример:

const {
    data,
    error,
    isError
} = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
});

if (isError) {
    return <div>{error.message}</div>;
}

Query function context

TanStack Query автоматически передаёт объект контекста в query function.

useQuery({
    queryKey: ['user', 15],
    queryFn: ({ queryKey }) => {
        console.log(queryKey);
    }
});

Контекст содержит:

  • queryKey
  • signal
  • meta
  • pageParam (для infinite queries)

Получение параметров из queryKey

Наиболее частое применение context — извлечение параметров из query key.

useQuery({
    queryKey: ['user', userId],
    queryFn: ({ queryKey }) => {
        const [, id] = queryKey;

        return fetchUser(id);
    }
});

Функция получения пользователя:

async function fetchUser(id) {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
        throw new Error('Пользователь не найден');
    }

    return response.json();
}

Почему queryKey лучше props внутри queryFn

Неправильный подход:

useQuery({
    queryKey: ['user'],
    queryFn: () => fetchUser(userId)
});

Проблема заключается в том, что queryKey не содержит userId.

Это приводит к:

  • неправильному кешированию;
  • конфликтам данных;
  • невозможности корректной инвалидации;
  • повторному использованию неправильного кеша.

Правильный вариант:

useQuery({
    queryKey: ['user', userId],
    queryFn: ({ queryKey }) => {
        const [, id] = queryKey;

        return fetchUser(id);
    }
});

Использование signal для отмены запросов

TanStack Query поддерживает AbortController.

В query function передаётся signal.

useQuery({
    queryKey: ['users'],
    queryFn: async ({ signal }) => {
        const response = await fetch('/api/users', {
            signal
        });

        return response.json();
    }
});

Когда происходит отмена

Запрос может быть отменён при:

  • размонтировании компонента;
  • повторном запросе;
  • смене query key;
  • ручной отмене;
  • garbage collection.

Обработка AbortError

При использовании fetch отменённый запрос вызывает ошибку AbortError.

async function fetchUsers({ signal }) {
    const response = await fetch('/api/users', {
        signal
    });

    return response.json();
}

Обычно отдельно обрабатывать её не требуется — TanStack Query делает это автоматически.


Query function и зависимости

Query function должна быть максимально чистой и предсказуемой.

Плохой пример:

let counter = 0;

async function fetchUsers() {
    counter++;

    return api.getUsers(counter);
}

Проблемы:

  • нестабильность;
  • скрытые зависимости;
  • непредсказуемый кеш;
  • сложность тестирования.

Хорошая архитектура query functions

Правильный подход — разделение API-слоя.

// api/users.js

export async function getUsers() {
    const response = await fetch('/api/users');

    if (!response.ok) {
        throw new Error('Ошибка загрузки');
    }

    return response.json();
}

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

import { getUsers } from './api/users';

useQuery({
    queryKey: ['users'],
    queryFn: getUsers
});

Использование query function factories

В больших проектах применяются фабрики query functions.

function createUserQuery(userId) {
    return {
        queryKey: ['user', userId],
        queryFn: () => fetchUser(userId)
    };
}

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

const query = useQuery(createUserQuery(15));

Query function и retries

Если функция выбрасывает ошибку, TanStack Query может автоматически повторить запрос.

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    retry: 3
});

При ошибке библиотека:

  1. Выполнит запрос;
  2. Получит исключение;
  3. Повторит запрос;
  4. Повторит ещё раз при необходимости.

Поведение retry

Retry работает только при ошибке.

Если query function успешно вернула данные:

return data;

повторных запросов не будет.


Retry и типы ошибок

Иногда retry необходимо отключать для определённых ошибок.

Например:

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    retry: (failureCount, error) => {
        if (error.status === 401) {
            return false;
        }

        return failureCount < 3;
    }
});

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

В query function можно получить дополнительные метаданные.

useQuery({
    queryKey: ['users'],
    meta: {
        requiresAuth: true
    },
    queryFn: ({ meta }) => {
        console.log(meta.requiresAuth);
    }
});

Асинхронность query functions

Query function может использовать:

  • async/await;
  • обычные Promise;
  • сторонние библиотеки;
  • GraphQL-клиенты;
  • IndexedDB;
  • WebSocket snapshot API.

Пример с Promise:

function fetchUsers() {
    return fetch('/api/users')
        .then(response => {
            if (!response.ok) {
                throw new Error('Ошибка');
            }

            return response.json();
        });
}

Query functions и GraphQL

Пример с GraphQL:

async function fetchPosts() {
    const response = await fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query: `
                query {
                    posts {
                        id
                        title
                    }
                }
            `
        })
    });

    const result = await response.json();

    return result.data.posts;
}

Параллельные запросы внутри query function

Иногда необходимо выполнить несколько запросов одновременно.

async function fetchDashboardData() {
    const [
        users,
        posts,
        comments
    ] = await Promise.all([
        fetch('/api/users').then(r => r.json()),
        fetch('/api/posts').then(r => r.json()),
        fetch('/api/comments').then(r => r.json())
    ]);

    return {
        users,
        posts,
        comments
    };
}

Нормализация данных

Query function может преобразовывать данные перед возвратом.

async function fetchUsers() {
    const response = await fetch('/api/users');
    const users = await response.json();

    return users.map(user => ({
        id: user.id,
        fullName: `${user.firstName} ${user.lastName}`
    }));
}

Когда не стоит преобразовывать данные

Сложную трансформацию лучше выносить в select.

Плохой пример:

queryFn: async () => {
    const data = await fetchUsers();

    return heavyTransformation(data);
}

Причины:

  • query function должна отвечать за получение данных;
  • тяжёлые вычисления ухудшают производительность;
  • усложняется повторное использование.

Query functions и infinite queries

В infinite query появляется pageParam.

useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 1 }) => {
        const response = await fetch(
            `/api/posts?page=${pageParam}`
        );

        return response.json();
    },
    getNextPageParam: lastPage => {
        return lastPage.nextPage;
    }
});

Типичные ошибки

Отсутствие throw

Неправильно:

if (!response.ok) {
    return null;
}

Правильно:

if (!response.ok) {
    throw new Error('Ошибка');
}

Нестабильные query keys

Неправильно:

queryKey: ['user']

если запрос зависит от userId.

Правильно:

queryKey: ['user', userId]

Побочные эффекты

Query function не должна:

  • изменять глобальное состояние;
  • вызывать уведомления;
  • менять DOM;
  • выполнять навигацию.

Плохой пример:

async function fetchUsers() {
    alert('Запрос');

    return api.getUsers();
}

Query function как единый источник данных

В крупной архитектуре query functions становятся центральной частью data layer.

Типичная структура:

src/
├── api/
│   ├── users.js
│   ├── posts.js
│   └── comments.js
├── queries/
│   ├── users.js
│   ├── posts.js
│   └── comments.js

Пример:

// api/users.js

export async function getUsers() {
    const response = await fetch('/api/users');

    if (!response.ok) {
        throw new Error('Ошибка');
    }

    return response.json();
}
// queries/users.js

import { getUsers } from '../api/users';

export function usersQuery() {
    return {
        queryKey: ['users'],
        queryFn: getUsers
    };
}

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

const query = useQuery(usersQuery());

Преимущества выделения query functions

Разделение логики даёт:

  • переиспользуемость;
  • централизованный API-слой;
  • удобное тестирование;
  • независимость от UI;
  • упрощённую миграцию;
  • единообразную обработку ошибок;
  • чистую архитектуру;
  • совместимость с SSR и hydration.