Фабрика запросов @tanstack/react-query на примере FSD и axios
Фабрика запросов @tanstack/react-query на примере FSD и axios
В современном мире разработки фронтенда, оптимизация работы с данными — это ключ к созданию
отзывчивых и масштабируемых приложений.
Использование библиотек для работы с асинхронными запросами, таких как @tanstack/react-query
и axios
, позволяет организовать удобную систему для работы с REST API, tRPC или GraphQL.
При разработке приложений, нам часто требуется выполнять одни и те же действия для различных сущностей API: получать список элементов, получать один элемент по ID, создавать новый элемент, совершать обновления и удаления.
Эти операции называются CRUD (Create, Read, Update, Delete). Я покажу, как можно реализовать универсальные CRUD-хуки с использованием вышеперечисленных библиотек в функциональном стиле на базе паттерна фабрика. Вместо того чтобы дублировать код для каждой сущности, мы можем использовать генерацию хуков, которая значительно сокращает количество копипаст кода и делает его гибким и переиспользуемым. Этот подход выглядит легко масштабируемым, универсальным и применимым к любому API.
@tanstack/react-query и axios
-
@tanstack/react-query
, которая до недавнего времени называласьreact-query
, — это библиотека для управления состоянием запросов, кэширования данных и синхронизации их с сервером. Она позволяет значительно улучшить производительность и упрощает работу с асинхронными запросами, абстрагируя логику работы с API от логики отображения данных в компонентах React. -
axios
— это популярная библиотека для выполнения HTTP-запросов. Она удобна своей гибкостью и простотой использования, в том числе при работе с асинхронными операциями.
Наша цель — создать функцию, которая для любой сущности будет автоматически генерировать CRUD-хуки, избавляя нас от необходимости каждый раз писать одно и то же. Например, нам нужно получать пользователей, посты, комментарии, и мы не хотим повторять однотипный код для каждой сущности.
Реализация CRUD-хуков в функциональном стиле
Итак, давайте приступим к реализации.
Мы создадим функцию createCrudHooks
, которая для любой сущности будет возвращать набор CRUD-хуков.
Шаг 1: Подключаем необходимые библиотеки
Для работы с запросами и кэшированием установим @tanstack/react-query
и axios
, не забыв
перед этим перейти в директорию нашего проекта:
cd ./my-awesome-project
npm i axios react-query
Шаг 2: Создаём основную функцию
Теперь создадим функцию createCRUDHooks
, которая принимает базовый URL и имя сущности
(например, users или posts).
Функция будет возвращать набор хуков для выполнения CRUD-операций.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import axios, { AxiosResponse } from 'axios'
// Функция для создания CRUD-хуков
const createCrudHooks = <T>(baseUrl: string, entity: string) => {
const api = axios.create({
baseURL: baseUrl,
})
// Fetch All (GET) - получить всё
const useFetchAll = (queryKey: string) => {
return useQuery<T[]>(queryKey, async () => {
const response: AxiosResponse<T[]> = await api.get(`/${entity}`)
return response.data
})
}
// Fetch One by ID (GET) - получить по id сущности
const useFetchOne = (queryKey: string, id: string | number) => {
return useQuery<T>([queryKey, id], async () => {
const response: AxiosResponse<T> = await api.get(`/${entity}/${id}`)
return response.data
})
}
// Create (POST) - создать сущность
const useCreate = () => {
const queryClient = useQueryClient()
return useMutation(
async (data: Partial<T>) => {
const response: AxiosResponse<T> = await api.post(`/${entity}`, data)
return response.data
},
{
onSuccess: () => {
queryClient.invalidateQueries(entity)
},
}
)
}
// Update (PUT) - обновить сущность
const useUpdate = () => {
const queryClient = useQueryClient()
return useMutation(
async ({ id, data }: { id: string | number; data: Partial<T> }) => {
const response: AxiosResponse<T> = await api.put(
`/${entity}/${id}`,
data
)
return response.data
},
{
onSuccess: () => {
queryClient.invalidateQueries(entity)
},
}
)
}
// Delete (DELETE) - удалить сущность
const useDelete = () => {
const queryClient = useQueryClient()
return useMutation(
async (id: string | number) => {
const response: AxiosResponse<void> = await api.delete(
`/${entity}/${id}`
)
return response.data
},
{
onSuccess: () => {
queryClient.invalidateQueries(entity)
},
}
)
}
return {
useFetchAll,
useFetchOne,
useCreate,
useUpdate,
useDelete
}
}
Про типизацию применимо к @tanstack/react-query
можно почитать в статье
Type-safe React Query.
Разберем подробнее, какие хуки возвращает функция createCrudHooks
:
Хук useFetchAll
Этот хук делает GET-запрос, который получает список элементов определённой сущности, например,
список пользователей (users) и использует хук useQuery
из библиотеки @tanstack/react-query
для кэширования и отслеживания состояния запроса.
Он возвращает массив элементов, который можно использовать в любом React-компоненте
для последующего рендера или каких-либо манипуляций.
Функцию можно расширить для передачи дополнительных параметров запроса, например,
для фильтрации результатов.
Хук useFetchOne
Этот хук получает один элемент по его ID (уникальному идентификатору).
Он также использует useQuery
, но принимает второй аргумент — идентификатор элемента.
Это позволяет получать данные по конкретному элементу сущности.
Хук useCreate
Этот хук используется для создания новых элементов.
Он вызывает POST-запрос к API и после успешного создания элемента вызывает invalidateQueries
,
чтобы актуализировать кэш, автоматически перезагружая данные в других компонентах,
где используются наши хуки.
Хук useUpdate
Для обновления элемента используется PUT-запрос. Хук принимает объект с ID элемента и данными для обновления. После успешного обновления кэш данных также обновляется.
Хук useDelete
Этот хук удаляет элемент по его ID с помощью DELETE-запроса и обновляет кэш данных после удаления.
Шаг 3: Тесты
Давайте добавим тесты, чтобы убедиться, что наш код работает корректно и без ошибок.
Для начала настроим тестовое окружение, воспользуемся библиотеками:
@testing-library/react
— для тестирования компонентов;@testing-library/jest-dom
— предоставляет дополнительные методы для ассертов (например,toBeInTheDocument
);vitest
— тестовый раннер и фреймворк для написания тестов;axios-mock-adapter
— для мокирования запросов черезaxios
.
npm i @testing-library/react @testing-library/jest-dom vitest axios-mock-adapter -D
Для запуска тестов добавьте следующий скрипт в package.json
:
"scripts": {
"test": "vitest"
}
Теперь вы можете запускать тесты:
npm run test
Создадим файл для тестов, например, crudHooks.test.tsx:
import { QueryClient, QueryClientProvider } from 'react-query'
import { act, renderHook } from '@testing-library/react'
import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'
// Путь к вашему коду с хуками
import { createCrudHooks } from './crudHooks'
// Мок-адаптер для axios
const mock = new MockAdapter(axios)
// Настройка react-query
const createTestQueryClient = () => {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
// Обертка для тестов с QueryClientProvider
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = createTestQueryClient()
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
// Пример сущности User
interface User {
id: number
name: string
email: string
}
// Создаем хуки для пользователей
const { useFetchAll, useFetchOne, useCreate, useUpdate, useDelete } =
createCrudHooks<User>('https://api.example.io', 'users')
// Очистка моков перед каждым тестом
beforeEach(() => {
mock.reset()
})
describe('CRUD Hooks', () => {
it('should fetch all users successfully', async () => {
// Мок данных
const mockData = [
{ id: 1, name: 'Ivan Green', email: 'ivan@example.io' },
{ id: 2, name: 'Inna Green', email: 'inna@example.io' },
]
// Мок ответа от API
mock.onGet('/users').reply(200, mockData)
// Рендерим хук useFetchAll
const { result, waitFor } = renderHook(() => useFetchAll('users'), {
wrapper,
})
// Ждем успешного выполнения запроса
await waitFor(() => result.current.isSuccess)
// Проверяем, что данные корректны
expect(result.current.data).toEqual(mockData)
})
it('should fetch one user by ID', async () => {
const mockUser = { id: 1, name: 'Ivan Green', email: 'ivan@example.io' }
// Мок ответа от API
mock.onGet('/users/1').reply(200, mockUser)
const { result, waitFor } = renderHook(() => useFetchOne('users', 1), {
wrapper,
})
await waitFor(() => result.current.isSuccess)
expect(result.current.data).toEqual(mockUser)
})
it('should create a new user', async () => {
const newUser = { id: 3, name: 'Sam Smith', email: 'sam@example.io' }
// Мок POST запроса
mock.onPost('/users').reply(201, newUser)
const { result, waitFor } = renderHook(() => useCreate(), { wrapper })
act(() => {
result.current.mutate({ name: 'Sam Smith', email: 'sam@example.io' })
})
await waitFor(() => result.current.isSuccess)
expect(result.current.data).toEqual(newUser)
})
it('should update a user', async () => {
const updatedUser = {
id: 1,
name: 'Ivan Updated',
email: 'ivan.updated@example.io',
}
// Мок PUT запроса
mock.onPut('/users/1').reply(200, updatedUser)
const { result, waitFor } = renderHook(() => useUpdate(), { wrapper })
act(() => {
result.current.mutate({
id: 1,
data: { name: 'Ivan Updated', email: 'Ivan.updated@example.io' },
})
})
await waitFor(() => result.current.isSuccess)
expect(result.current.data).toEqual(updatedUser)
})
it('should delete a user', async () => {
// Мок DELETE запроса
mock.onDelete('/users/1').reply(200)
const { result, waitFor } = renderHook(() => useDelete(), { wrapper })
act(() => {
result.current.mutate(1)
})
await waitFor(() => result.current.isSuccess)
// Проверяем, что запрос был успешным
expect(result.current.isSuccess).toBe(true)
})
})
Мы создали тесты для нашего набора CRUD-хуков с использованием react-testing-library
,
axios-mock-adapter
и vitest
.
Это делает наш код надёжным и протестированным, что важно для разработки масштабируемых
и поддерживаемых приложений.
Тестирование асинхронных операций становится значительно проще благодаря мокированию запросов
и инструментам для работы с хуками.
Пример использования в React-компоненте
Теперь, когда мы создали универсальные хуки, давайте посмотрим, как ими пользоваться в реальном компоненте:
interface User {
id: number
name: string
email: string
}
// Можно вынести в отдельный файл для переиспользования
const { useFetchAll, useCreate, useUpdate, useDelete } = createCrudHooks<User>(
'https://api.example.io',
'users'
)
type UserProps = {
user: User
}
const UserItem = ({ user }: UserProps) => {
const { mutate: updateUser } = useUpdate()
const { mutate: deleteUser } = useDelete()
const handleUpdate = () => {
updateUser({ id: user.id, data: { name: 'Иван' } })
}
const handleDelete = () => {
deleteUser(user.id)
}
return (
<li key={user.id}>
{user.name} - {user.email}
<button onClick={handleUpdate}>Обновить имя</button>
<button onClick={handleDelete}>Удалить</button>
</li>
)
}
export const Users = () => {
const { data: users, isLoading } = useFetchAll('users')
const { mutate: createUser } = useCreate()
const handleCreateUser = () => {
createUser({ name: 'Даша', email: 'dasha@example.io' })
}
if (isLoading) return <div>Загрузка пользователей...</div>
return (
<div>
<h1>Пользователи</h1>
<ul>
{users?.map((user: User) => (
<UserItem key={user.id} user={user} />
))}
</ul>
<button onClick={handleCreateUser}>Создать пользователя</button>
</div>
)
}
Что не так с этим подходом?
При использовании оберток для хуков можно получить проблемы с типизацией, которые описаны в обсуждении на github. Один из вариантов — это использовать фабрики запросов, которые мы рассмотрим далее. Можно написать свою фабрику или воспользоваться библиотекой Query Key Factory.
Пример использования фабрики запросов в приложении, построенном на принципах Feature-Sliced Design (FSD)
Когда проект масштабируется, становится важным обеспечить его модульность и структурированность.
Один из вариантов архитектуры — Feature-Sliced Design (FSD),
которая помогает организовать приложение по функциональному принципу, выделяя слои ответственности.
Рассмотрим, как применить FSD для проекта, использующего @tanstack/react-query
.
Что такое Feature-Sliced Design?
FSD (Feature-Sliced Design) — это подход к архитектуре, при котором приложение делится на независимые модули (фичи) по функциональному принципу. Главная цель — улучшить масштабируемость, поддерживаемость и читаемость проекта. FSD помогает избежать разрастания кодовой базы и упрощает тестирование.
Уровни FSD
- App (Приложение): Глобальные конфигурации, маршрутизация и провайдеры.
- Pages (Страницы): Полные страницы или большие части страницы при вложенном роутинге.
- Widgets (Виджеты): Большие самодостаточные куски функциональности или интерфейса, обычно реализующие целый пользовательский сценарий.
- Features (Фичи): Повторно используемые реализации целых фич продукта, то есть действий, приносящих бизнес-ценность пользователю.
- Entities (Сущности): Базовые объекты бизнес-логики, такие как пользователи, заказы и товары.
- Shared (Общие ресурсы): Общие модули, такие как UI-компоненты, утилиты и API-хелперы.
Фабрика запросов и FSD
Теперь рассмотрим архитектуру проекта с использованием Фабрики запросов и подхода FSD.
Структура проекта:
src/
├── app/ // Глобальные настройки и провайдеры
├── entities/ // Сущности
│ └── pokemon/
│ ├── api/
│ ├── model/
│ └── ui/
├── features/ // Фичи
├── widgets/ // Виджеты
├── pages/ // Страницы (например, PokemonsPage)
└── shared/ // Общие компоненты и утилиты
├── api/
├── config/
└── ui/
Shared (Общие ресурсы)
К Shared относятся общие компоненты, утилиты, конфигурации и общие хелперы для API.
Напишем функцию createQueries
, которая будет являться фабрикой запросов, представим,
что используем простое CRUD API для общения с сервером:
import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import * as qs from 'qs';
import { createQueryFn } from '../../api/createQueryFn';
import { createMutationFn } from '../../api/createMutationFn';
import { createDeleteMutationFn } from '../../api/createDeleteMutationFn';
import { createUpdateMutationFn } from '../../api/createUpdateMutationFn';
// Передаём дженерики для того, чтобы при последующем написании кода у нас корректно отображались типы
export const createQueries = <
CreateResponse,
CreateBody,
ReadResponse,
ReadOneResponse,
UpdateResponse,
UpdateBody,
DeleteResponse,
DeleteParams
>(
entity: string
) => ({
all: () =>
queryOptions({
queryKey: [entity],
}),
create: () => ({
mutationKey: [entity],
mutationFn: (body: CreateBody) =>
createMutationFn<CreateResponse, CreateBody>({
path: `/${entity}`,
body,
}),
placeholderData: keepPreviousData,
}),
read: (filters) =>
queryOptions({
queryKey: [entity, filters],
queryFn: () =>
createQueryFn<ReadResponse>({
path: `/${entity}?${qs.stringify(filters)}`,
}),
placeholderData: keepPreviousData,
}),
readOne: ({ id }) =>
queryOptions({
queryKey: [entity, id],
queryFn: () =>
createQueryFn<ReadOneResponse>({
path: `/${entity}/${id}`,
}),
placeholderData: keepPreviousData,
}),
update: () => ({
mutationKey: [entity],
mutationFn: ({ id, body }) =>
createUpdateMutationFn<UpdateResponse, UpdateBody>({
path: `/${entity}/${id}`,
body,
}),
placeholderData: keepPreviousData,
}),
delete: () => ({
mutationKey: [entity],
mutationFn: (params: DeleteParams) =>
createDeleteMutationFn<DeleteResponse>({
path: `/${entity}/${params.id}`,
}),
placeholderData: keepPreviousData,
}),
});
Функция возвращает объект с конфигурациями запросов для основных CRUD-операций.
Создадим файл для тестов createQueries.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { createQueries } from './createQueries.ts';
import * as api from '../../api/createQueryFn';
import * as mutationApi from '../../api/createMutationFn';
vi.mock('../../api/createQueryFn', () => ({
createQueryFn: vi.fn(),
}));
vi.mock('../../api/createMutationFn', () => ({
createMutationFn: vi.fn(),
}));
vi.mock('../../api/createDeleteMutationFn', () => ({
createDeleteMutationFn: vi.fn(),
}));
vi.mock('../../api/createUpdateMutationFn', () => ({
createUpdateMutationFn: vi.fn(),
}));
describe('createQueries', () => {
const entity = 'user';
it('should return correct query options for "all"', () => {
const queries = createQueries(entity);
const result = queries.all();
expect(result.queryKey).toEqual([entity]);
});
it('should create mutation for "create"', () => {
const queries = createQueries(entity);
const body = { name: 'Alexander' };
queries.create().mutationFn(body);
expect(mutationApi.createMutationFn).toHaveBeenCalledWith({
path: `/${entity}`,
body,
});
});
it('should return correct query options for "read"', () => {
const queries = createQueries(entity);
const filters = { page: 1 };
queries.read(filters).queryFn();
expect(api.createQueryFn).toHaveBeenCalledWith({
path: `/${entity}?page=1`,
});
});
it('should return correct query options for "readOne"', () => {
const queries = createQueries(entity);
const id = 123;
queries.readOne({ id }).queryFn();
expect(api.createQueryFn).toHaveBeenCalledWith({
path: `/${entity}/${id}`,
});
});
it('should create mutation for "update"', () => {
const queries = createQueries(entity);
const id = 123;
const body = { name: 'Updated' };
queries.update().mutationFn({ id, body });
expect(mutationApi.createUpdateMutationFn).toHaveBeenCalledWith({
path: `/${entity}/${id}`,
body,
});
});
it('should create mutation for "delete"', () => {
const queries = createQueries(entity);
const params = { id: 123 };
queries.delete().mutationFn(params);
expect(mutationApi.createDeleteMutationFn).toHaveBeenCalledWith({
path: `/${entity}/${params.id}`,
});
});
});
Теперь наша функция протестирована.
Entities (Сущности)
Сущности — это основные доменные объекты, с которыми работает приложение, например,
User
или Pokemon
.
Для каждой сущности будет свой модуль, включающий описание типов данных, CRUD-хуки,
UI-компоненты для отображения и, возможно, адаптеры для взаимодействия с API.
Пример структуры сущности Pokemon
:
src/
└── entities/
└── pokemon/
├── api/
│ ├── pokemon.query.ts // Queries для Pokemon
│ ├── usePokemon.tsx // Хук получения одного Pokemon
│ ├── usePokemonCreate.tsx // Хук создания Pokemon
│ ├── usePokemonDelete.tsx // Хук удаления Pokemon
│ ├── usePokemonList.tsx // Хук получения списка Pokemon
│ └── usePokemonUpdate.tsx // Хук обновления Pokemon
└── model/
└── Pokemon.ts // Описание типов данных сущности Pokemon
└── ui/
├── PokemonList.tsx // Компонент для отображения списка Pokemon
└── PokemonCard.tsx // Компонент для отображения отдельного Pokemon
- api/usePokemon*.ts: Реализация CRUD-хуков для работы с API пользователей.
- model/Pokemon.ts: Описание типов данных сущности
Pokemon
. - ui/PokemonList.tsx и ui/PokemonCard.tsx: Компоненты интерфейса для работы с сущностью пользователя.
Пример pokemon.query.ts с использованием createQueries
:
import { createQueries } from '../../../shared/lib/createQueries/createQueries';
import {
CreatePokemonResponse,
CreatePokemonBody,
ReadPokemonResponse,
ReadOnePokemonResponse,
UpdatePokemonResponse,
UpdatePokemonBody,
DeletePokemonResponse,
DeletePokemonParams,
} from '../model/Pokemon';
export const pokemonQueries = createQueries<
CreatePokemonResponse,
CreatePokemonBody,
ReadPokemonResponse,
ReadOnePokemonResponse,
UpdatePokemonResponse,
UpdatePokemonBody,
DeletePokemonResponse,
DeletePokemonParams
>('pokemon');
В createQueries
передаём необходимые для корректной типизации типы и название сущности
согласно контракту с бэком.
И используем pokemonQueries
для создания необходимых CRUD-хуков на примере usePokemonList
:
import { useQuery } from '@tanstack/react-query';
import { pokemonQueries } from './pokemon.query';
type Props = {
limit: number;
offset: number;
};
export const usePokemonList = ({ limit, offset }: Props) => {
return useQuery({
...pokemonQueries.read({ limit, offset }),
});
};
Получаем все необходимые для работы приложения CRUD-хуки, которые можно использовать в UI-компонентах или других слоях приложения, что делает структуру кода более инкапсулированной и адаптированной к FSD.
В зависимости от договорённостей в вашем проекте можно написать фабрику для CRUD-хуков, либо создавать только нужные вручную, как мы и сделали.
Features (Фичи)
Фичи представляют собой конкретные действия пользователя, приносящие бизнес-ценность. Например, редактирование или создание нового покемона. Эти фичи могут использовать сущности и оборачивать их в дополнительные бизнес-правила, если это необходимо.
Пример структуры фичи create-pokemon
:
src/
└── features/
└── create-pokemon/
├── model/
└── ui/
model/
: Может содержать хук или функцию для создания пользователя (например, используяusePokemonCreate
изentities/pokemon/api
).ui/
: Компоненты, которые визуализируют форму добавления пользователя или UI для подтверждения добавления.
import { usePokemonCreate } from '../../../entities/pokemon/api/usePokemonCreate';
import { CreatePokemonBody } from '../../../entities/pokemon/model/Pokemon';
type Props = {
pokemon: CreatePokemonBody;
};
export const CreatePokemon = ({ pokemon }: Props) => {
const { mutate: createPokemon } = usePokemonCreate();
const handleCreate = () => {
createPokemon({
name: pokemon.name,
});
};
return (
<button
onClick={handleCreate}
className="bg-green-500 text-white p-2 rounded hover:bg-green-600 transition-colors"
>
Создать {pokemon.name}
</button>
);
};
Использование компонента:
<CreatePokemon pokemon={{ name: 'Pikachu' }} />
Widgets (Виджеты)
Виджеты представляют собой более крупные модули интерфейса, которые могут состоять из нескольких
фич и сущностей.
Например, виджет PokemonProfile
может включать список всех сериалов и игр, в которых был покемон,
информацию о его профиле и т.д.
Наиболее подробно вы можете познакомиться с данным подходом в демо-проекте.
Выводы
Мы реализовали гибкую и переиспользуемую систему CRUD-хуков с помощью @tanstack/react-query
и axios
.
Теперь вы можете легко использовать эти хуки для любой сущности вашего API, значительно упростив
код и улучшив читаемость.
Этот подход помогает легко масштабировать приложение и минимизировать дублирование кода.
Применение принципов FSD
помогает структурировать проект, делая его более масштабируемым
и поддерживаемым.
Комбинирование этих подходов позволяет создавать гибкие приложения, в которых модульность
и независимость компонентов делают проект удобным в поддержке и масштабировании.
Попробуйте внедрить этот подход в вашем проекте, и вы быстро заметите, как он облегчает управление состоянием запросов и их интеграцию в компоненты React.
Полезные ссылки:
- ReactQueryKit.
- Фабрики запросов в @tanstack/react-query.
- В гайде FSD есть пример реализации фабрики запросов.
Тарас Протченко, команда Аврора Центр