Skip to content

RequestStore

RequestStore выполняет HTTP-запрос напрямую, без сохранения результата в общем кэше запросов, и хранит его MobX-состояние.

Его удобно использовать для login, registration, submit формы, upload, export и других действий, результат которых не нужно разделять между несколькими экранами.

Создавайте его через factory-функцию createRequestStore(...). HTTP-метод выбирает API-функция, поэтому RequestStore может вызвать GET, POST, PUT, PATCH или DELETE.

Если включён retry, store добавляет один стабильный Idempotency-Key ко всем попыткам выполнения. Backend должен дедуплицировать изменяющие операции по этому заголовку. Явный header пользователя имеет приоритет.

Как он работает

  1. Код приложения вызывает метод, который запускает запрос.
  2. Входные данные передаются в requestHandler.execute(...).
  3. RequestStore вызывает переданную API-функцию и предоставляет ей AbortSignal.
  4. Во время выполнения запроса loading === true.
  5. После завершения запроса store обновляет data, response, status и error.
  6. Компонент или другой код приложения получает актуальное состояние через MobX.

RequestStore не зависит от UI-фреймворка и не требует определённой архитектуры приложения. В примерах документации вызов обычно проходит через domain store, но это не является обязательным.

API-функция

API-функция знает HTTP-метод и URL. Последний параметр — AxiosRequestConfig: через него RequestStore передаст сигнал отмены.

ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"

import { publicApi } from "../../../api"

// Данные, которые backend ожидает в body запроса.
export interface LoginRequestDto {
    email: string
    password: string
}

// Данные успешного ответа backend.
export interface LoginResponseDto {
    accessToken: string
    user: {
        id: string
        name: string
    }
}

// Функция ничего не знает о React и MobX.
// Она только описывает POST /api/v1/auth/login.
export const loginApi = (
    dto: LoginRequestDto,
    config?: AxiosRequestConfig,
): Promise<AxiosResponse<LoginResponseDto>> => {
    return publicApi.post<LoginResponseDto>("/api/v1/auth/login", dto, config)
}

Domain store

Внешний store даёт UI предметные имена. Технический requestHandler остаётся приватным: компонент не должен вызывать универсальный execute() напрямую.

ts
import { makeAutoObservable } from "mobx"

import { createRequestStore, type INormalizedAxiosError } from "@dubium/query-layer"
import type { THandlerStatus } from "@dubium/query-layer/contracts"

import { loginApi, type LoginRequestDto } from "../api/login.api"

// Значения формы в этом примере совпадают с request DTO.
// Если формы и API различаются, добавьте mapper перед execute().
export type LoginFormValues = LoginRequestDto

export class LoginStore {
    // RequestStore управляет только состоянием HTTP-запроса.
    // Тип результата и тип values выводятся из сигнатуры loginApi.
    private readonly requestHandler = createRequestStore({
        api: loginApi,
        options: {
            // Ошибку login обычно показывают сразу, без автоматических повторов.
            retry: false,
        },
    })

    constructor() {
        makeAutoObservable<this, "requestHandler">(
            this,
            {
                // Вложенный RequestStore уже observable.
                // MobX не должен повторно преобразовывать его внутреннее состояние.
                requestHandler: false,
            },
            {
                // Методы сохраняют this, даже если переданы как callback.
                autoBind: true,
            },
        )
    }

    // UI использует этот getter, чтобы заблокировать кнопку.
    get loading(): boolean {
        return this.requestHandler.loading
    }

    // status показывает общий этап: loading, success, error или canceled.
    get status(): THandlerStatus | null {
        return this.requestHandler.status
    }

    // Ошибка уже нормализована библиотекой.
    get error(): INormalizedAxiosError | null {
        return this.requestHandler.error
    }

    // Domain store раскрывает только нужную UI часть ответа.
    get userName(): string | null {
        return this.requestHandler.data?.user.name ?? null
    }

    // Отменяет активный Axios-запрос через AbortController.
    cancel(): void {
        this.requestHandler.cancel()
    }

    // Очищает data, error и status, например после закрытия формы.
    reset(): void {
        this.requestHandler.reset()
    }

    async login(values: LoginFormValues): Promise<string | null> {
        // values пришли из контролируемых полей React-формы.
        // execute передаст их первым аргументом в loginApi(values, config).
        const response = await this.requestHandler.execute(values)

        // null означает отменённый запрос или отсутствие результата.
        // При успехе возвращаем token вызывающему коду.
        return response?.data.accessToken ?? null
    }

    // Освобождает ресурсы, когда экземпляр store больше не нужен.
    dispose(): void {
        this.requestHandler.dispose()
    }
}

Почему login принимает values

values — обычный объект со значениями формы. Его создаёт React-компонент из email и password. Метод execute(...args) передаёт аргументы API-функции в том же порядке. В этом примере получается вызов:

ts
loginStore.login(values)
// Внутри: requestHandler.execute(values)
// Внутри RequestStore: loginApi(values, { signal })

Если API принимает два аргумента, например updateUserApi(id, dto, config), то вызов будет requestHandler.execute(id, dto).

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

ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"

import { LoginStore } from "../../data/auth/login/model/login.store"

export const LoginForm = observer(() => {
    // Функция-инициализатор создаёт один store на весь lifecycle компонента.
    const [store] = useState(() => new LoginStore())

    // Локальное состояние хранит только введённые пользователем значения.
    const [email, setEmail] = useState("")
    const [password, setPassword] = useState("")

    // При unmount отменяем запрос и освобождаем handler.
    useEffect(() => () => store.dispose(), [store])

    return (
        <form
            onSubmit={(event) => {
                event.preventDefault()

                // UI вызывает domain-метод. API-функция здесь не импортируется.
                void store.login({ email, password })
            }}
        >
            <input aria-label="Email" value={email} onChange={(event) => setEmail(event.target.value)} />

            <input
                aria-label="Пароль"
                type="password"
                value={password}
                onChange={(event) => setPassword(event.target.value)}
            />

            <button disabled={store.loading} type="submit">
                {store.loading ? "Входим…" : "Войти"}
            </button>

            {store.error && <p role="alert">{store.error.message}</p>}
            {store.userName && <p>Здравствуйте, {store.userName}</p>}
        </form>
    )
})

observer подписывает компонент на observable-свойства MobX. Поэтому изменение store.loading, store.error или store.userName автоматически вызывает повторный render.

Конфигурация RequestStore

ПолеTypeОбязательноеЧто делает
api(...args) => Promise<AxiosResponse<TData>>даAPI-функция. Последним аргументом должна принимать необязательный AxiosRequestConfig.
optionsIRequestStoreConfig<TData, TArgs>["options"]нетНастройки direct execution и lifecycle callbacks.
clientQueryClientнетВнутренняя интеграция. В обычном domain store не передаётся.
requestExecutorIRequestExecutorContractнетВнутренняя интеграция с QueryRuntime.
operationContextIRequestOperationContextнетВнутренний owner/correlation context direct request.
operationContextFactory() => IRequestOperationContextнетВнутренняя factory нового context для каждого execution; используется participant scope.

Рабочие поля options

Ниже перечислены настройки, которые фактически используются RequestStore.

OptionTypeПо умолчаниюЧто делает
cancelPreviousbooleantrueПеред новым запуском отменяет предыдущий активный запрос.
isLoadingbooleantrueУправляет обновлением состояния loading во время выполнения запроса.
keepPreviousDatabooleanfalseСохраняет предыдущие data во время нового запроса.
networkMode"online" | "always""online"В режиме online запрос ожидает восстановления сети. always запускает его независимо от состояния сети.
operationContextIRequestOperationContextundefinedПередаёт дополнительный контекст выполнения запроса.
requestGroupstringобщий пулВыбирает независимую группу лимита конкурентности runtime.
idempotencyKeystringauto при включённом retryСтабильный ключ изменяющего direct execution; используется всеми retry-попытками.
onSuccess(data, response, variables, context) => voidundefinedВызывается после успешного ответа.
onError(error, variables, context) => voidundefinedВызывается после окончательной ошибки.
onSettled(data, error, variables, context) => voidundefinedВызывается после завершения запроса с успехом или ошибкой.
onCallbackError(error, callbackName) => void | Promise<void>undefinedПолучает исключение lifecycle callback, не заменяя исходный result/error.
retryboolean | number | (failureCount, error) => booleanfalseУправляет повторными попытками после ошибки. Для изменяющих запросов учитывайте идемпотентность.
retryDelaynumber | (failureCount, error) => number1s → 2s → 4s → …, максимум 30sЗадаёт задержку между повторными попытками. Используется только если retry разрешает повтор.

Встроенная задержка использует full jitter в диапазоне от 0 до указанной exponential-backoff границы. Пользовательское число или результат функции retryDelay не изменяется. Подробнее: «Конкурентность запросов и retry jitter».

variables в обработчиках результата запроса содержит аргументы, переданные в execute(...args).

Например:

ts
requestHandler.execute(userId, dto)

В onSuccess, onError и onSettled значение variables будет соответствовать аргументам [userId, dto].

Состояние и методы

Все перечисленные ниже свойства и методы доступны у экземпляра RequestStore.

Если store создан через createRequestStore(...):

ts
private readonly requestHandler = createRequestStore({
    api: loginApi,
})

то обращаться к его состоянию и методам можно через requestHandler:

ts
this.requestHandler.loading
this.requestHandler.data
this.requestHandler.error

this.requestHandler.execute(values)
this.requestHandler.cancel()
this.requestHandler.reset()
this.requestHandler.dispose()

Так как requestHandler приватный, UI не работает с ним напрямую. LoginStore предоставляет наружу только те свойства и предметные методы, которые нужны приложению.

В domain store экземпляр RequestStore обычно хранится в приватном поле, а наружу предоставляются только необходимые приложению свойства и методы.

MethodTypeЧто означает
loadingbooleanСейчас выполняется запрос.
statusTHandlerStatus | nullnull, loading, success, error или canceled.
dataTData | undefinedДанные последнего успешного ответа.
responseAxiosResponse<TData> | nullПолный Axios response.
errorINormalizedAxiosError | nullНормализованная ошибка запроса.
hasDatabooleanЕсть ли данные успешного ответа.
execute(...args)Promise<AxiosResponse<TData> | null>Выполняет API-функцию с переданными аргументами.
executeWithConfig(args, config)Promise<AxiosResponse<TData> | null>Выполняет запрос с дополнительным Axios config для конкретного вызова.
fetch(...args)то же, что executeПсевдоним execute(...).
cancel()voidОтменяет активный запрос.
reset()voidОтменяет активный запрос и очищает локальное состояние.
dispose()voidОтменяет активный запрос и освобождает ресурсы store.
Нажмите на меня, чтобы посмотреть пример с execute(...) и executeWithConfig(...)

Оба метода выполняют запрос через ту же API-функцию и тот же Axios client (createAxiosClient), который уже используется приложением.

RequestStore не создаёт новый Axios client для каждого запроса.

Например, Axios client может быть создан один раз:

ts
export const publicApi = createAxiosClient({
    baseURL: "/api/v1",
})

API-функция использует этот client:

ts
export const loginApi = (
    dto: LoginRequestDto,
    config?: AxiosRequestConfig,
): Promise<AxiosResponse<LoginResponseDto>> => {
    return publicApi.post("/auth/login", dto, config)
}

А RequestStore получает API-функцию:

ts
private readonly requestHandler = createRequestStore({
    api: loginApi,
})

Обычный вызов:

ts
await this.requestHandler.execute(values)

передаёт values в loginApi и автоматически добавляет внутренний AbortSignal.

Если конкретному запросу нужны дополнительные настройки Axios, используйте executeWithConfig(...):

ts
await this.requestHandler.executeWithConfig([values], {
    timeout: 10_000,
})

В этом случае timeout применяется только к этому запросу.

Концептуально вызов будет выглядеть так:

ts
loginApi(values, {
    timeout: 10_000,
    signal,
})

а внутри API-функции:

ts
publicApi.post("/auth/login", values, {
    timeout: 10_000,
    signal,
})

Важно:

  • createAxiosClient(...) задаёт основные настройки Axios client;
  • createRequestStore(...) создаёт store для конкретной API-функции;
  • execute(...) выполняет обычный запрос;
  • executeWithConfig(...) добавляет настройки только для текущего запроса;
  • настройки самого publicApi при этом не изменяются;
  • новый Axios client не создаётся.

В execute(...) аргументы API-функции передаются через запятую:

ts
requestHandler.execute(userId, dto)

В executeWithConfig(...) те же аргументы передаются массивом, потому что второй параметр метода используется для AxiosRequestConfig:

ts
requestHandler.executeWithConfig([userId, dto], {
    timeout: 10_000,
})

Когда выбрать другое API

  • Нужен ручной кэшируемый GET — FetchStore.
  • Чтение должно запускаться автоматически — QueryStore.
  • Нужен полноценный lifecycle изменяющей операции — MutationStore.