Организация структуры проекта

При разработке приложений с использованием RTK Query структура проекта начинает играть критически важную роль уже после появления нескольких API-модулей, десятков endpoints и сложной бизнес-логики. Хаотичное размещение сервисов, типов и хуков быстро приводит к дублированию кода, циклическим зависимостям и ухудшению поддержки.

Грамотная структура должна решать несколько задач одновременно:

  • разделение ответственности;
  • изоляция API-слоя;
  • переиспользование endpoints;
  • масштабируемость;
  • удобство типизации;
  • минимизация связности между модулями;
  • поддержка code splitting;
  • предсказуемость архитектуры.

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-компонентов.


Централизованный API-модуль

Ключевым элементом архитектуры RTK Query обычно становится единый базовый API.

Создание baseApi

// 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-системы приложения.

Основные преимущества:

  • единая конфигурация;
  • общая авторизация;
  • единая обработка ошибок;
  • общие tagTypes;
  • централизованная настройка кеширования;
  • поддержка injectEndpoints.

Разделение endpoints по модулям

С ростом проекта размещать все endpoints внутри одного файла становится невозможно.

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

createApi({
    endpoints: (builder) => ({
        getUsers: ...,
        getPosts: ...,
        getComments: ...,
        getNotifications: ...,
        getSettings: ...,
        login: ...,
        logout: ...
    })
})

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

Правильнее разделять endpoints по доменам.


Модульная организация endpoints

Users API

// 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

Posts API

// 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

Использование injectEndpoints даёт несколько важных преимуществ.

Масштабируемость

Каждый модуль независим:

entities/
├── user/
│   ├── api/
│   ├── model/
│   └── ui/
│
├── post/
│   ├── api/
│   ├── model/
│   └── ui/

Lazy loading

Endpoints можно подключать динамически.

const extendedApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getAnalytics: builder.query({
            query: () => '/analytics'
        })
    })
})

Это особенно полезно в больших приложениях.


Изоляция бизнес-доменов

Модули пользователей не знают о внутреннем устройстве модулей комментариев.


Организация папки api

Практически всегда имеет смысл выделять отдельную папку под инфраструктуру RTK Query.

Пример:

shared/api/
├── baseApi.js
├── baseQuery.js
├── auth/
├── interceptors/
├── utils/
├── serializers/
└── endpoints/

Вынос baseQuery

По мере роста приложения логика fetchBaseQuery усложняется:

  • авторизация;
  • refresh token;
  • retry;
  • обработка ошибок;
  • обновление access token;
  • работа с cookies;
  • логирование;
  • преобразование ответов.

Поэтому baseQuery часто выносится отдельно.


Пример 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: () => ({})
})

Feature-based архитектура

RTK Query особенно хорошо сочетается с feature slicing.

Пример структуры:

src/
├── app/
├── shared/
├── entities/
├── features/
├── widgets/
└── pages/

Размещение API внутри entities

Если API относится к сущности, логично хранить его рядом.

entities/
└── user/
    ├── api/
    │   └── userApi.js
    │
    ├── model/
    ├── ui/
    └── lib/

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

  • локальность логики;
  • лёгкая навигация;
  • отсутствие гигантских API-файлов;
  • независимость модулей.

Разделение shared и feature endpoints

Важно понимать разницу между инфраструктурным API и feature-логикой.

Shared API

shared/api/

Содержит:

  • baseApi;
  • baseQuery;
  • общие interceptors;
  • сериализацию;
  • инфраструктурные утилиты.

Feature API

features/auth/api/
entities/post/api/

Содержит:

  • конкретные endpoints;
  • бизнес-запросы;
  • mutations;
  • трансформацию данных.

Организация типов

Типы быстро начинают дублироваться, если хранить их рядом с компонентами.

Правильнее выделять отдельные model/types.


Пример структуры

entities/
└── user/
    ├── api/
    ├── model/
    │   ├── types.js
    │   └── selectors.js
    └── ui/

Типы пользователя

// entities/user/model/types.js

export const UserRole = {
    ADMIN: 'admin',
    USER: 'user'
}

Разделение DTO и UI-моделей

Серверные модели часто отличаются от UI-моделей.

Плохой подход:

const user = response

Лучший подход:

transformResponse: (response) => ({
    id: response.id,
    fullName: response.first_name + ' ' + response.last_name
})

Папка adapters

При сложных преобразованиях полезно выделять адаптеры.

entities/
└── user/
    ├── adapters/
    │   └── userAdapter.js

Пример адаптера

export const mapUserDto = (dto) => ({
    id: dto.id,
    fullName: `${dto.first_name} ${dto.last_name}`,
    avatar: dto.avatar_url
})

Хранение hooks

RTK Query автоматически генерирует hooks.

Есть два подхода.


Экспорт рядом с API

export const {
    useGetUsersQuery
} = userApi

Плюсы:

  • простота;
  • минимум файлов.

Минусы:

  • сложнее реэкспортировать.

Отдельный index.js

// entities/user/api/index.js

export {
    useGetUsersQuery,
    useGetUserByIdQuery
} from './userApi'

Такой подход лучше масштабируется.


Barrel exports

Крупные проекты почти всегда используют barrel exports.


Пример

// entities/user/index.js

export * from './api'
export * from './model'
export * from './ui'

Импорт

import {
    useGetUsersQuery
} from '@/entities/user'

Организация tagTypes

При неправильной организации тегов начинается хаос инвалидации.


Централизованное хранение tagTypes

// shared/api/tags.js

export const TAGS = {
    USER: 'User',
    POST: 'Post',
    COMMENT: 'Comment'
}

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

providesTags: [TAGS.USER]

Изоляция кеширования

Разные сущности должны иметь собственные стратегии кеширования.


Пример

getUsers: builder.query({
    query: () => '/users',

    keepUnusedDataFor: 300
})

Разделение read и write API

В больших системах иногда разделяют:

  • query endpoints;
  • mutation endpoints.

Пример структуры

entities/
└── post/
    ├── api/
    │   ├── queries/
    │   └── mutations/

Организация selectors

Хотя RTK Query минимизирует необходимость в selectors, они всё ещё полезны.


Пример

export const selectCurrentUser = (state) =>
    userApi.endpoints.getCurrentUser.select()(state)

Разделение API и UI

Критически важно не смешивать UI-логику и API.

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

queryFn: async () => {
    alert('Ошибка')
}

API-слой не должен знать о:

  • modals;
  • notifications;
  • routing;
  • DOM;
  • UI-компонентах.

Организация error handling

Лучше создавать централизованный слой обработки ошибок.


Пример

shared/api/errors/
├── parseError.js
├── handleAuthError.js
└── index.js

Разделение public и private API

Некоторые endpoints требуют авторизации, некоторые — нет.


Пример

shared/api/
├── publicApi.js
└── privateApi.js

SSR-структура

При использовании SSR структура становится сложнее.


Пример

app/
├── store/
├── providers/
├── hydration/
└── ssr/

Организация rehydration

extractRehydrationInfo(action, { reducerPath }) {
    if (action.type === HYDRATE) {
        return action.payload[reducerPath]
    }
}

Разделение REST и GraphQL

Если приложение использует несколько источников данных:

shared/api/
├── rest/
├── graphql/
└── websocket/

WebSocket-структура

RTK Query поддерживает streaming и realtime-обновления.


Пример структуры

shared/api/websocket/
├── socket.js
├── listeners.js
└── subscriptions.js

Организация code splitting

RTK Query отлично поддерживает lazy endpoints.


Пример

const analyticsApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getStats: builder.query({
            query: () => '/stats'
        })
    })
})

Feature isolation

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

features/
└── auth/
    ├── api/
    ├── model/
    ├── ui/
    ├── lib/
    └── config/

Shared utilities

Общие API-утилиты необходимо выносить отдельно.


Пример

shared/api/utils/
├── createQueryString.js
├── normalizeError.js
└── createPaginationParams.js

Структура enterprise-проекта

В крупных проектах структура обычно становится глубже.

src/
├── app/
├── processes/
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/

RTK Query при этом располагается преимущественно в:

  • shared/api;
  • entities/*/api;
  • features/*/api.

Антипаттерны структуры RTK Query

Огромный api.js

api/
└── api.js

Файл на тысячи строк становится неуправляемым.


Смешивание UI и API

toast.success()
navigate('/profile')

внутри endpoints — плохая практика.


Дублирование endpoints

getUsers
fetchUsers
loadUsers

для одного и того же запроса создают архитектурный шум.


Хранение API рядом со страницами

pages/
└── UsersPage/
    └── api.js

Такой подход разрушает переиспользуемость.


Организация monorepo

В monorepo API можно выносить в отдельный пакет.

packages/
├── api/
├── shared/
├── admin-app/
└── client-app/

Структура API SDK

Иногда RTK Query строится поверх внутреннего SDK.

shared/api/
├── sdk/
├── adapters/
├── endpoints/
└── baseApi.js

Инкапсуляция endpoints

Некоторые endpoints лучше скрывать внутри feature.


Публичный экспорт

export {
    useLoginMutation
}

Приватные endpoints

const internalApi = ...

Неэкспортируемые endpoints уменьшают связность системы.


Организация тестов

Тесты API лучше хранить рядом.

entities/
└── user/
    ├── api/
    │   ├── userApi.js
    │   └── userApi.test.js

Разделение mock и production API

shared/api/
├── mocks/
├── fixtures/
└── production/

Организация mock handlers

При использовании MSW:

shared/mocks/
├── handlers/
├── fixtures/
└── browser.js

Архитектура крупного RTK Query проекта

Финальная структура большого приложения может выглядеть так:

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/