Тема
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 пользователя имеет приоритет.
Как он работает
- Код приложения вызывает метод, который запускает запрос.
- Входные данные передаются в
requestHandler.execute(...). RequestStoreвызывает переданную API-функцию и предоставляет ейAbortSignal.- Во время выполнения запроса
loading === true. - После завершения запроса store обновляет
data,response,statusиerror. - Компонент или другой код приложения получает актуальное состояние через 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. |
options | IRequestStoreConfig<TData, TArgs>["options"] | нет | Настройки direct execution и lifecycle callbacks. |
client | QueryClient | нет | Внутренняя интеграция. В обычном domain store не передаётся. |
requestExecutor | IRequestExecutorContract | нет | Внутренняя интеграция с QueryRuntime. |
operationContext | IRequestOperationContext | нет | Внутренний owner/correlation context direct request. |
operationContextFactory | () => IRequestOperationContext | нет | Внутренняя factory нового context для каждого execution; используется participant scope. |
Рабочие поля options
Ниже перечислены настройки, которые фактически используются RequestStore.
| Option | Type | По умолчанию | Что делает |
|---|---|---|---|
cancelPrevious | boolean | true | Перед новым запуском отменяет предыдущий активный запрос. |
isLoading | boolean | true | Управляет обновлением состояния loading во время выполнения запроса. |
keepPreviousData | boolean | false | Сохраняет предыдущие data во время нового запроса. |
networkMode | "online" | "always" | "online" | В режиме online запрос ожидает восстановления сети. always запускает его независимо от состояния сети. |
operationContext | IRequestOperationContext | undefined | Передаёт дополнительный контекст выполнения запроса. |
requestGroup | string | общий пул | Выбирает независимую группу лимита конкурентности runtime. |
idempotencyKey | string | auto при включённом retry | Стабильный ключ изменяющего direct execution; используется всеми retry-попытками. |
onSuccess | (data, response, variables, context) => void | undefined | Вызывается после успешного ответа. |
onError | (error, variables, context) => void | undefined | Вызывается после окончательной ошибки. |
onSettled | (data, error, variables, context) => void | undefined | Вызывается после завершения запроса с успехом или ошибкой. |
onCallbackError | (error, callbackName) => void | Promise<void> | undefined | Получает исключение lifecycle callback, не заменяя исходный result/error. |
retry | boolean | number | (failureCount, error) => boolean | false | Управляет повторными попытками после ошибки. Для изменяющих запросов учитывайте идемпотентность. |
retryDelay | number | (failureCount, error) => number | 1s → 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 обычно хранится в приватном поле, а наружу предоставляются только необходимые приложению свойства и методы.
| Method | Type | Что означает |
|---|---|---|
loading | boolean | Сейчас выполняется запрос. |
status | THandlerStatus | null | null, loading, success, error или canceled. |
data | TData | undefined | Данные последнего успешного ответа. |
response | AxiosResponse<TData> | null | Полный Axios response. |
error | INormalizedAxiosError | null | Нормализованная ошибка запроса. |
hasData | boolean | Есть ли данные успешного ответа. |
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.