Разделение API слайсов

По мере роста приложения единый API-слайс начинает превращаться в перегруженную структуру, содержащую десятки или сотни endpoints. Это приводит к нескольким проблемам:

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

RTK Query предоставляет механизм масштабирования через разделение API-слайсов и динамическое расширение endpoints.


Базовый API-слайс

Наиболее распространённый подход — создание одного базового API и расширение его через injectEndpoints.

Базовая конфигурация:

// shared/api/baseApi.js

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const baseApi = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    tagTypes: ['User', 'Post', 'Comment'],
    endpoints: () => ({})
})

Особенности такого подхода:

  • существует единая точка подключения middleware;
  • создаётся один reducer;
  • сохраняется общий кэш;
  • endpoints могут подключаться динамически;
  • отсутствует дублирование конфигурации.

Расширение API через injectEndpoints

Каждый модуль приложения может расширять базовый API собственными endpoints.

Модуль пользователей

// entities/user/api/userApi.js

import { baseApi } from '@/shared/api/baseApi'

export const userApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users',
            providesTags: ['User']
        }),

        getUserById: builder.query({
            query: (id) => `/users/${id}`,
            providesTags: (result, error, id) => [
                { type: 'User', id }
            ]
        })
    })
})

Модуль постов

// entities/post/api/postApi.js

import { baseApi } from '@/shared/api/baseApi'

export const postApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getPosts: builder.query({
            query: () => '/posts',
            providesTags: ['Post']
        }),

        createPost: builder.mutation({
            query: (body) => ({
                url: '/posts',
                method: 'POST',
                body
            }),

            invalidatesTags: ['Post']
        })
    })
})

Экспорт хуков

После расширения API автоматически создаются hooks.

export const {
    useGetUsersQuery,
    useGetUserByIdQuery
} = userApi
export const {
    useGetPostsQuery,
    useCreatePostMutation
} = postApi

Каждый модуль экспортирует только собственные hooks, что повышает инкапсуляцию.


Архитектурное разделение

На практике endpoints обычно группируются по бизнес-доменам.

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

src/
├─ app/
├─ shared/
│  └─ api/
│     └─ baseApi.js
├─ entities/
│  ├─ user/
│  │  └─ api/
│  │     └─ userApi.js
│  ├─ post/
│  │  └─ api/
│  │     └─ postApi.js
│  └─ comment/
│     └─ api/
│        └─ commentApi.js

Такой подход особенно хорошо сочетается с:

  • Feature-Sliced Design;
  • Domain-Driven Design;
  • модульной архитектурой;
  • монорепозиториями.

Почему не стоит создавать множество createApi

RTK Query допускает создание нескольких API-слайсов:

export const userApi = createApi({...})
export const postApi = createApi({...})

Однако такой подход создаёт ряд проблем.

Отдельный reducer для каждого API

reducer: {
    [userApi.reducerPath]: userApi.reducer,
    [postApi.reducerPath]: postApi.reducer
}

Количество reducers начинает быстро расти.


Отдельный middleware

middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(
        userApi.middleware,
        postApi.middleware
    )

Каждый middleware добавляет дополнительную нагрузку.


Изолированный кэш

Разные API-слайсы:

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

Дублирование конфигурации

fetchBaseQuery({
    baseUrl: '/api'
})

Такая конфигурация начинает повторяться в каждом API.


Когда несколько createApi действительно нужны

Несколько API-слайсов оправданы в следующих случаях:

Разные backend-сервисы

baseUrl: '/auth-api'
baseUrl: '/payments-api'

Разные механизмы авторизации

Например:

  • JWT;
  • Basic Auth;
  • OAuth;
  • API Key.

Разные стратегии кэширования

Иногда части приложения требуют:

  • независимого жизненного цикла кэша;
  • собственной политики invalidation;
  • отдельной логики retry.

Микрофронтенды

В микрофронтенд-архитектуре каждый модуль может иметь собственный API-слайс.


Override существующих endpoints

injectEndpoints поддерживает переопределение.

const extendedApi = baseApi.injectEndpoints({
    overrideExisting: true,

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/v2/users'
        })
    })
})

Это полезно:

  • при миграции API;
  • в тестировании;
  • при white-label архитектуре;
  • при кастомизации модулей.

Ленивое подключение endpoints

Одно из ключевых преимуществ разделения API — возможность lazy loading.

Динамический импорт

const UserPage = lazy(() => import('./UserPage'))

Внутри страницы:

import '@/entities/user/api/userApi'

Endpoints регистрируются только после загрузки модуля.


Code Splitting

RTK Query поддерживает полноценный code splitting.

Пример

// baseApi.js

export const baseApi = createApi({
    reducerPath: 'api',
    baseQuery,
    endpoints: () => ({})
})
// orderApi.js

export const orderApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getOrders: builder.query({
            query: () => '/orders'
        })
    })
})

Chunk с orderApi будет загружен только при необходимости.


Влияние на bundle size

Разделение API-слайсов помогает:

  • уменьшить initial bundle;
  • сократить количество неиспользуемого кода;
  • ускорить загрузку SPA;
  • уменьшить memory footprint.

Особенно заметно в крупных enterprise-приложениях.


Разделение tagTypes

Все теги желательно регистрировать в базовом API.

tagTypes: [
    'User',
    'Post',
    'Comment',
    'Order'
]

Если тег отсутствует в tagTypes, RTK Query выдаст предупреждение.


Масштабирование tagTypes

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

// shared/api/tags.js

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

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

providesTags: [TAGS.USER]

Это снижает риск опечаток.


Разделение baseQuery

Иногда требуется несколько уровней baseQuery.

Базовый запрос

const rawBaseQuery = fetchBaseQuery({
    baseUrl: '/api'
})

Обёртка для refresh token

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {
    let result = await rawBaseQuery(
        args,
        api,
        extraOptions
    )

    if (result.error?.status === 401) {
        // refresh token
    }

    return result
}

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

export const baseApi = createApi({
    reducerPath: 'api',
    baseQuery: baseQueryWithReauth,
    endpoints: () => ({})
})

Все модули автоматически получают общую логику авторизации.


Инкапсуляция API-модулей

Хорошая практика — скрывать внутреннюю структуру endpoints.

Плохо

export const api = baseApi.injectEndpoints(...)

Лучше

const api = baseApi.injectEndpoints(...)
export const {
    useGetUsersQuery
} = api

Модуль экспортирует только публичный контракт.


Проблема циклических импортов

Неправильная организация API часто приводит к циклическим зависимостям.

Пример проблемы

userApi -> authApi
authApi -> userApi

Как избежать циклов

Не импортировать endpoints друг в друга

Плохо:

import { userApi } from '../user/userApi'

внутри:

authApi.js

Использовать invalidateTags

Вместо прямого вызова:

invalidatesTags: ['User']

Выносить общие сущности

shared/api/
shared/lib/
shared/config/

Инъекция endpoints в разных слоях

RTK Query хорошо работает с многослойной архитектурой.

Shared layer

baseApi

Entity layer

userApi
postApi

Feature layer

updateProfileApi
checkoutApi

Widget layer

Обычно API здесь не размещается, но возможно подключение feature-модулей.


Тестирование разделённых API

Модульность значительно упрощает тестирование.

Тестируется только конкретный API-модуль

import { userApi } from './userApi'

Изоляция endpoints

Можно мокать только нужный модуль.

jest.mock('./userApi')

SSR и разделение API

При Server-Side Rendering разделение API помогает:

  • уменьшить размер initial state;
  • загружать только нужные endpoints;
  • разделять серверные чанки;
  • уменьшать время гидратации.

Prefetch и разделённые endpoints

Даже разделённые endpoints используют единый store.

dispatch(
    baseApi.util.prefetch(
        'getUsers',
        undefined,
        { force: true }
    )
)

Все endpoints остаются частью одного API-контейнера.


Динамическое удаление endpoints

RTK Query не удаляет injected endpoints автоматически.

После инъекции endpoint остаётся зарегистрированным до перезагрузки приложения.

Это важно учитывать:

  • в микрофронтендах;
  • в plugin-based системах;
  • в runtime-модулях.

Типизация разделённых API

TypeScript корректно объединяет типы injected endpoints.

export const userApi = baseApi.injectEndpoints({
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

Типы автоматически расширяются.


Особенности hot reload

Во время HMR endpoints могут инжектироваться повторно.

Для предотвращения предупреждений используется:

overrideExisting: false

или:

overrideExisting: 'throw'

Паттерн barrel exports

Для крупных проектов удобно использовать barrel-файлы.

// entities/user/index.js

export * from './api/userApi'

Организация endpoints по операциям

Иногда API разделяют не по сущностям, а по операциям.

Query-модуль

userQueriesApi.js

Mutation-модуль

userMutationsApi.js

Подход встречается редко, но полезен:

  • в CQRS;
  • в event-driven системах;
  • в сложных enterprise-приложениях.

Разделение API и производительность

Грамотное разделение API-слайсов:

  • уменьшает связанность модулей;
  • улучшает масштабируемость;
  • снижает когнитивную нагрузку;
  • упрощает рефакторинг;
  • улучшает tree shaking;
  • делает архитектуру предсказуемой.

В большинстве современных приложений оптимальной стратегией считается:

  1. один базовый createApi;
  2. модульное расширение через injectEndpoints;
  3. разделение endpoints по бизнес-доменам;
  4. lazy loading API-модулей;
  5. единая система тегов и кэширования.