При разработке приложений с использованием RTK Query структура проекта начинает играть критически важную роль уже после появления нескольких API-модулей, десятков endpoints и сложной бизнес-логики. Хаотичное размещение сервисов, типов и хуков быстро приводит к дублированию кода, циклическим зависимостям и ухудшению поддержки.
Грамотная структура должна решать несколько задач одновременно:
RTK Query хорошо интегрируется как с feature-based архитектурой, так и с классическим layered-подходом.
Наиболее распространённая структура выглядит следующим образом:
src/
├── app/
│ ├── store.js
│ └── providers/
│
├── shared/
│ ├── api/
│ │ ├── baseApi.js
│ │ ├── baseQuery.js
│ │ ├── endpoints/
│ │ └── utils/
│ │
│ ├── config/
│ ├── lib/
│ └── types/
│
├── entities/
│ ├── user/
│ ├── post/
│ └── comment/
│
├── features/
│ ├── auth/
│ ├── profile/
│ └── posts/
│
├── pages/
│
└── widgets/
Подобная организация хорошо масштабируется и позволяет изолировать API-логику от UI-компонентов.
Ключевым элементом архитектуры RTK Query обычно становится единый базовый API.
// shared/api/baseApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com'
}),
tagTypes: ['User', 'Post', 'Comment'],
endpoints: () => ({})
})
Такой файл становится фундаментом всей API-системы приложения.
Основные преимущества:
С ростом проекта размещать все endpoints внутри одного файла становится невозможно.
Плохой пример:
createApi({
endpoints: (builder) => ({
getUsers: ...,
getPosts: ...,
getComments: ...,
getNotifications: ...,
getSettings: ...,
login: ...,
logout: ...
})
})
Подобный подход создаёт огромные файлы и усложняет навигацию.
Правильнее разделять endpoints по доменам.
// entities/user/api/userApi.js
import { baseApi } from '@/shared/api/baseApi'
export const userApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
}),
getUserById: builder.query({
query: (id) => `/users/${id}`
})
})
})
export const {
useGetUsersQuery,
useGetUserByIdQuery
} = userApi
// entities/post/api/postApi.js
import { baseApi } from '@/shared/api/baseApi'
export const postApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts'
}),
createPost: builder.mutation({
query: (body) => ({
url: '/posts',
method: 'POST',
body
})
})
})
})
export const {
useGetPostsQuery,
useCreatePostMutation
} = postApi
Использование injectEndpoints даёт несколько важных
преимуществ.
Каждый модуль независим:
entities/
├── user/
│ ├── api/
│ ├── model/
│ └── ui/
│
├── post/
│ ├── api/
│ ├── model/
│ └── ui/
Endpoints можно подключать динамически.
const extendedApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getAnalytics: builder.query({
query: () => '/analytics'
})
})
})
Это особенно полезно в больших приложениях.
Модули пользователей не знают о внутреннем устройстве модулей комментариев.
Практически всегда имеет смысл выделять отдельную папку под инфраструктуру RTK Query.
Пример:
shared/api/
├── baseApi.js
├── baseQuery.js
├── auth/
├── interceptors/
├── utils/
├── serializers/
└── endpoints/
По мере роста приложения логика fetchBaseQuery
усложняется:
Поэтому baseQuery часто выносится отдельно.
// shared/api/baseQuery.js
import { fetchBaseQuery } from '@reduxjs/toolkit/query'
export const baseQuery = fetchBaseQuery({
baseUrl: '/api',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
// shared/api/baseApi.js
import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQuery } from './baseQuery'
export const baseApi = createApi({
reducerPath: 'api',
baseQuery,
endpoints: () => ({})
})
RTK Query особенно хорошо сочетается с feature slicing.
Пример структуры:
src/
├── app/
├── shared/
├── entities/
├── features/
├── widgets/
└── pages/
Если API относится к сущности, логично хранить его рядом.
entities/
└── user/
├── api/
│ └── userApi.js
│
├── model/
├── ui/
└── lib/
Преимущества:
Важно понимать разницу между инфраструктурным API и feature-логикой.
shared/api/
Содержит:
features/auth/api/
entities/post/api/
Содержит:
Типы быстро начинают дублироваться, если хранить их рядом с компонентами.
Правильнее выделять отдельные model/types.
entities/
└── user/
├── api/
├── model/
│ ├── types.js
│ └── selectors.js
└── ui/
// entities/user/model/types.js
export const UserRole = {
ADMIN: 'admin',
USER: 'user'
}
Серверные модели часто отличаются от UI-моделей.
Плохой подход:
const user = response
Лучший подход:
transformResponse: (response) => ({
id: response.id,
fullName: response.first_name + ' ' + response.last_name
})
При сложных преобразованиях полезно выделять адаптеры.
entities/
└── user/
├── adapters/
│ └── userAdapter.js
export const mapUserDto = (dto) => ({
id: dto.id,
fullName: `${dto.first_name} ${dto.last_name}`,
avatar: dto.avatar_url
})
RTK Query автоматически генерирует hooks.
Есть два подхода.
export const {
useGetUsersQuery
} = userApi
Плюсы:
Минусы:
// entities/user/api/index.js
export {
useGetUsersQuery,
useGetUserByIdQuery
} from './userApi'
Такой подход лучше масштабируется.
Крупные проекты почти всегда используют barrel exports.
// entities/user/index.js
export * from './api'
export * from './model'
export * from './ui'
import {
useGetUsersQuery
} from '@/entities/user'
При неправильной организации тегов начинается хаос инвалидации.
// shared/api/tags.js
export const TAGS = {
USER: 'User',
POST: 'Post',
COMMENT: 'Comment'
}
providesTags: [TAGS.USER]
Разные сущности должны иметь собственные стратегии кеширования.
getUsers: builder.query({
query: () => '/users',
keepUnusedDataFor: 300
})
В больших системах иногда разделяют:
entities/
└── post/
├── api/
│ ├── queries/
│ └── mutations/
Хотя RTK Query минимизирует необходимость в selectors, они всё ещё полезны.
export const selectCurrentUser = (state) =>
userApi.endpoints.getCurrentUser.select()(state)
Критически важно не смешивать UI-логику и API.
Плохой пример:
queryFn: async () => {
alert('Ошибка')
}
API-слой не должен знать о:
Лучше создавать централизованный слой обработки ошибок.
shared/api/errors/
├── parseError.js
├── handleAuthError.js
└── index.js
Некоторые endpoints требуют авторизации, некоторые — нет.
shared/api/
├── publicApi.js
└── privateApi.js
При использовании SSR структура становится сложнее.
app/
├── store/
├── providers/
├── hydration/
└── ssr/
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === HYDRATE) {
return action.payload[reducerPath]
}
}
Если приложение использует несколько источников данных:
shared/api/
├── rest/
├── graphql/
└── websocket/
RTK Query поддерживает streaming и realtime-обновления.
shared/api/websocket/
├── socket.js
├── listeners.js
└── subscriptions.js
RTK Query отлично поддерживает lazy endpoints.
const analyticsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getStats: builder.query({
query: () => '/stats'
})
})
})
Каждая feature должна иметь собственную структуру.
features/
└── auth/
├── api/
├── model/
├── ui/
├── lib/
└── config/
Общие API-утилиты необходимо выносить отдельно.
shared/api/utils/
├── createQueryString.js
├── normalizeError.js
└── createPaginationParams.js
В крупных проектах структура обычно становится глубже.
src/
├── app/
├── processes/
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/
RTK Query при этом располагается преимущественно в:
api/
└── api.js
Файл на тысячи строк становится неуправляемым.
toast.success()
navigate('/profile')
внутри endpoints — плохая практика.
getUsers
fetchUsers
loadUsers
для одного и того же запроса создают архитектурный шум.
pages/
└── UsersPage/
└── api.js
Такой подход разрушает переиспользуемость.
В monorepo API можно выносить в отдельный пакет.
packages/
├── api/
├── shared/
├── admin-app/
└── client-app/
Иногда RTK Query строится поверх внутреннего SDK.
shared/api/
├── sdk/
├── adapters/
├── endpoints/
└── baseApi.js
Некоторые endpoints лучше скрывать внутри feature.
export {
useLoginMutation
}
const internalApi = ...
Неэкспортируемые endpoints уменьшают связность системы.
Тесты API лучше хранить рядом.
entities/
└── user/
├── api/
│ ├── userApi.js
│ └── userApi.test.js
shared/api/
├── mocks/
├── fixtures/
└── production/
При использовании MSW:
shared/mocks/
├── handlers/
├── fixtures/
└── browser.js
Финальная структура большого приложения может выглядеть так:
src/
├── app/
│ ├── store/
│ ├── providers/
│ └── router/
│
├── shared/
│ ├── api/
│ │ ├── baseApi.js
│ │ ├── baseQuery.js
│ │ ├── tags.js
│ │ ├── utils/
│ │ ├── websocket/
│ │ ├── graphql/
│ │ └── rest/
│ │
│ ├── lib/
│ ├── config/
│ └── ui/
│
├── entities/
│ ├── user/
│ │ ├── api/
│ │ ├── model/
│ │ ├── adapters/
│ │ └── ui/
│ │
│ ├── post/
│ └── comment/
│
├── features/
│ ├── auth/
│ ├── profile/
│ └── editor/
│
├── widgets/
├── pages/
└── processes/