Абстракции над API

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

Абстракции над API позволяют:

  • централизовать HTTP-логику;
  • унифицировать обработку ошибок;
  • избавиться от дублирования;
  • скрыть детали транспортного слоя;
  • упростить тестирование;
  • изолировать TanStack Query от конкретной реализации API;
  • упростить миграцию между fetch, axios, GraphQL-клиентами и другими транспортами.

TanStack Query не является HTTP-клиентом. Библиотека отвечает за:

  • кэширование;
  • синхронизацию;
  • повторные запросы;
  • инвалидацию;
  • фоновые обновления;
  • управление серверным состоянием.

Получение данных остаётся ответственностью разработчика. Именно поэтому архитектура API-слоя становится критически важной.


Прямая работа с fetch внутри queryFn

Наиболее примитивный вариант выглядит следующим образом:

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

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

    return response.json();
};

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

Проблемы такого подхода:

  • одинаковая проверка response.ok дублируется;
  • отсутствует единый формат ошибок;
  • нет централизованной авторизации;
  • нет автоматической сериализации;
  • сложно внедрять interceptors;
  • компоненты начинают зависеть от деталей HTTP.

При росте приложения код превращается в набор разрозненных query-функций с повторяющейся логикой.


Выделение API-клиента

Первый уровень абстракции — создание отдельного API-клиента.

Базовый API-клиент

export class ApiClient {
    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }

    async request(url, options = {}) {
        const response = await fetch(`${this.baseUrl}${url}`, {
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            },
            ...options
        });

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

        return response.json();
    }

    get(url) {
        return this.request(url, {
            method: 'GET'
        });
    }

    post(url, body) {
        return this.request(url, {
            method: 'POST',
            body: JSON.stringify(body)
        });
    }

    put(url, body) {
        return this.request(url, {
            method: 'PUT',
            body: JSON.stringify(body)
        });
    }

    delete(url) {
        return this.request(url, {
            method: 'DELETE'
        });
    }
}

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

export const api = new ApiClient('/api');
const fetchUsers = () => {
    return api.get('/users');
};

Разделение API по доменам

В крупных приложениях единый API-клиент быстро становится перегруженным. Обычно используется доменное разделение.

Структура:

src/
├── api/
│   ├── client.ts
│   ├── users.api.ts
│   ├── posts.api.ts
│   ├── auth.api.ts
│   └── comments.api.ts

users.api.ts

import { api } from './client';

export const usersApi = {
    getUsers() {
        return api.get('/users');
    },

    getUser(id) {
        return api.get(`/users/${id}`);
    },

    createUser(data) {
        return api.post('/users', data);
    },

    updateUser(id, data) {
        return api.put(`/users/${id}`, data);
    },

    deleteUser(id) {
        return api.delete(`/users/${id}`);
    }
};

Теперь query-функции становятся компактными:

const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: usersApi.getUsers
});

Изоляция транспортного слоя

Одна из главных задач абстракции — скрыть транспортную реализацию.

Компоненты и query-hooks не должны знать:

  • используется fetch или axios;
  • REST API или GraphQL;
  • JSON или protobuf;
  • cookies или bearer token.

Проблемный подход

const query = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
        const response = await axios.get('/users');
        return response.data;
    }
});

Компонент напрямую зависит от axios.


Правильная изоляция

export const usersApi = {
    async getUsers() {
        return api.get('/users');
    }
};

Теперь замена транспорта не затрагивает слой TanStack Query.


Абстракции для query-функций

Часто создаются готовые query options.

Factory-функции

export const usersQueries = {
    all() {
        return {
            queryKey: ['users'],
            queryFn: usersApi.getUsers
        };
    },

    detail(id) {
        return {
            queryKey: ['users', id],
            queryFn: () => usersApi.getUser(id)
        };
    }
};

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

const usersQuery = useQuery(usersQueries.all());

const userQuery = useQuery(usersQueries.detail(userId));

Преимущества:

  • единый источник queryKey;
  • исключение дублирования;
  • переиспользуемость;
  • упрощение prefetch;
  • упрощение invalidateQueries.

Query Key Factory

Очень распространённый паттерн.

Централизация ключей

export const userKeys = {
    all: ['users'],

    lists: () => [...userKeys.all, 'list'],

    list: (filters) => [...userKeys.lists(), filters],

    details: () => [...userKeys.all, 'detail'],

    detail: (id) => [...userKeys.details(), id]
};

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

useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => usersApi.getUser(id)
});

Объединение query keys и API

Наиболее удобный подход — единая query factory.

export const usersQueries = {
    all: () => ({
        queryKey: ['users'],
        queryFn: usersApi.getUsers
    }),

    detail: (id) => ({
        queryKey: ['users', id],
        queryFn: () => usersApi.getUser(id)
    })
};

Такой подход особенно удобен в:

  • prefetchQuery;
  • ensureQueryData;
  • SSR;
  • hydration;
  • invalidation;
  • optimistic updates.

Абстракции для mutations

Mutations также часто выносятся в отдельные factory-функции.

Пример

export const usersMutations = {
    create() {
        return {
            mutationFn: usersApi.createUser
        };
    },

    update() {
        return {
            mutationFn: ({ id, data }) => {
                return usersApi.updateUser(id, data);
            }
        };
    }
};

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

const createUserMutation = useMutation(
    usersMutations.create()
);

Кастомные hooks как уровень абстракции

Следующий уровень — создание hooks поверх TanStack Query.

Пример

export const useUsers = () => {
    return useQuery({
        queryKey: ['users'],
        queryFn: usersApi.getUsers
    });
};

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

const { data, isPending } = useUsers();

Когда кастомные hooks полезны

Они особенно эффективны при наличии:

  • сложной логики;
  • селекторов;
  • трансформаций;
  • объединения нескольких queries;
  • интеграции с UI-состоянием;
  • сложной авторизации.

Когда hooks становятся проблемой

Чрезмерная абстракция может ухудшить поддержку.

Плохо:

useUsersData()
useUsersFetcher()
useUsersProvider()
useUsersResource()
useUsersController()

Появляется многослойная архитектура без реальной пользы.


Абстракция через сервисный слой

Иногда API-слой отделяется от бизнес-логики.

Схема

TanStack Query
      ↓
Service Layer
      ↓
API Layer
      ↓
HTTP Client

API Layer

export const usersApi = {
    getUsers() {
        return api.get('/users');
    }
};

Service Layer

export const usersService = {
    async getActiveUsers() {
        const users = await usersApi.getUsers();

        return users.filter(user => user.active);
    }
};

Query

useQuery({
    queryKey: ['active-users'],
    queryFn: usersService.getActiveUsers
});

Назначение service layer

Сервисный слой полезен для:

  • бизнес-логики;
  • агрегации данных;
  • объединения нескольких API;
  • вычислений;
  • нормализации;
  • адаптации DTO;
  • permission-логики.

DTO и адаптеры

Одна из важнейших задач абстракции — преобразование серверных данных.

Серверный формат редко идеально подходит UI.


Проблема прямого использования DTO

API:

{
    "user_id": 15,
    "first_name": "John",
    "last_name": "Smith",
    "is_active": 1
}

UI ожидает:

{
    id: number;
    fullName: string;
    isActive: boolean;
}

Адаптеры данных

const mapUserDto = (dto) => {
    return {
        id: dto.user_id,
        fullName: `${dto.first_name} ${dto.last_name}`,
        isActive: Boolean(dto.is_active)
    };
};

Интеграция адаптеров

export const usersApi = {
    async getUsers() {
        const data = await api.get('/users');

        return data.map(mapUserDto);
    }
};

Теперь UI полностью изолирован от серверного DTO.


Централизованная обработка ошибок

Без абстракции ошибки становятся хаотичными.


Плохой вариант

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

Нормализация ошибок

export class ApiError extends Error {
    constructor(message, status, payload) {
        super(message);

        this.status = status;
        this.payload = payload;
    }
}

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

async request(url, options = {}) {
    const response = await fetch(url, options);

    if (!response.ok) {
        const payload = await response.json();

        throw new ApiError(
            payload.message,
            response.status,
            payload
        );
    }

    return response.json();
}

Преимущества нормализованных ошибок

Появляется возможность:

  • централизованного error handling;
  • глобальных toast-уведомлений;
  • обработки кодов авторизации;
  • retry-логики;
  • аналитики;
  • автоматического logout.

Авторизация в API-слое

Авторизация не должна дублироваться в query-функциях.


Неправильно

fetch('/users', {
    headers: {
        Authorization: `Bearer ${token}`
    }
});

Правильный подход

async request(url, options = {}) {
    const token = authStorage.getToken();

    return fetch(url, {
        ...options,
        headers: {
            Authorization: `Bearer ${token}`,
            ...options.headers
        }
    });
}

Refresh token логика

Крупные приложения часто внедряют автоматическое обновление токенов.

async request(url, options = {}) {
    const response = await fetch(url, options);

    if (response.status === 401) {
        await refreshToken();

        return fetch(url, options);
    }

    return response.json();
}

Такая логика должна жить исключительно внутри API-абстракции.


GraphQL как абстракция

TanStack Query не зависит от REST.


GraphQL API Layer

export const graphqlClient = async (
    query,
    variables = {}
) => {
    const response = await fetch('/graphql', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            query,
            variables
        })
    });

    return response.json();
};

Query-функции для GraphQL

export const usersApi = {
    async getUsers() {
        const response = await graphqlClient(`
            query {
                users {
                    id
                    name
                }
            }
        `);

        return response.data.users;
    }
};

Для TanStack Query транспорт остаётся прозрачным.


Генерация API-клиентов

В современных проектах часто используются генераторы:

  • OpenAPI Generator;
  • Orval;
  • Swagger Codegen;
  • GraphQL Code Generator.

Пример с OpenAPI

export const usersApi = {
    getUsers() {
        return UsersService.getUsers();
    }
};

TanStack Query взаимодействует уже с готовым SDK.


Интеграция с query factories

export const usersQueries = {
    all: () => ({
        queryKey: ['users'],
        queryFn: usersApi.getUsers
    })
};

Абстракции и SSR

При SSR особенно важно иметь централизованные query factories.


Prefetch

await queryClient.prefetchQuery(
    usersQueries.all()
);

Hydration

useQuery(usersQueries.all());

Повторное использование одной конфигурации снижает вероятность ошибок.


Абстракции и optimistic updates

Централизация query keys значительно упрощает обновление кэша.

queryClient.invalidateQueries({
    queryKey: userKeys.all
});

Инкапсуляция invalidate-логики

Иногда invalidation выносится в сервисы.

export const usersCache = {
    invalidateAll(queryClient) {
        return queryClient.invalidateQueries({
            queryKey: ['users']
        });
    }
};

Переиспользование query options

Одна из мощных возможностей TanStack Query — повторное использование query-конфигурации.

const usersQuery = usersQueries.all();

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

useQuery(usersQuery);

queryClient.prefetchQuery(usersQuery);

queryClient.ensureQueryData(usersQuery);

Абстракции и тестирование

Чем лучше изолирован API-слой, тем проще тестирование.


Тестирование сервиса

vi.mock('./users.api');

test('returns active users', async () => {
    usersApi.getUsers.mockResolvedValue([
        { id: 1, active: true },
        { id: 2, active: false }
    ]);

    const users = await usersService.getActiveUsers();

    expect(users).toHaveLength(1);
});

Mock API

Абстракции позволяют легко переключать источники данных.

export const usersApi =
    process.env.NODE_ENV === 'test'
        ? mockUsersApi
        : realUsersApi;

Антипаттерн: чрезмерная абстракция

Частая ошибка — создание слишком большого количества слоёв.


Пример переусложнения

Component
↓
Custom Hook
↓
Query Factory
↓
Repository
↓
Service
↓
Adapter
↓
Api Client
↓
Fetch Wrapper
↓
Fetch

Каждый слой добавляет:

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

Практический баланс

Для большинства приложений достаточно:

TanStack Query
↓
API Layer
↓
HTTP Client

или:

TanStack Query
↓
Service Layer
↓
API Layer
↓
HTTP Client

Когда нужны сложные абстракции

Сложная архитектура оправдана при наличии:

  • большого backend API;
  • нескольких backend-сервисов;
  • GraphQL + REST одновременно;
  • сложной бизнес-логики;
  • SSR;
  • offline-first;
  • микрофронтендов;
  • shared SDK;
  • multi-platform приложений.

Когда абстракции вредят

Избыточные уровни вредны в:

  • небольших SPA;
  • MVP;
  • прототипах;
  • dashboard-приложениях;
  • внутренних админках.

В таких случаях прямой API-layer обычно оказывается наиболее эффективным решением.