Тема
Flow разработчика
Эта страница показывает production-структуру feature: React, MobX, Axios, Valibot, DTO, schema, mapper и Query Layer. Пример — регистрация пользователя.
Почему код разделён
| Слой | Знает | Не знает |
|---|---|---|
| UI | Поля формы и domain store | URL, Axios client, response DTO |
| Domain store | Form type, mapper, API-функция, состояние запроса | JSX и DOM |
| Mapper | Как form превращается в request DTO | Axios и MobX |
| API-функция | Endpoint, DTO, schema ответа | React и состояние экрана |
| Transport | baseURL, credentials, auth refresh | Конкретную feature |
Так изменение backend DTO не заставляет переписывать React-компонент.
Структура файлов
text
src/
data/
api.ts
auth/
registration/
api/
registration.api.ts
dto/
registration-request.dto.ts
registration-response.dto.ts
mapper/
registration.mapper.ts
model/
registration.model.ts
registration.store.ts
schema/
registration-form.schema.ts
registration-response.schema.ts
index.ts
features/
registration/
ui/
RegistrationForm.tsx
shared/
hooks/
use-store-instance.tsУстановка Valibot
bash
npm install valibotValibot проверяет данные во время выполнения. TypeScript проверяет только код во время сборки и не может гарантировать, что backend действительно прислал объект ожидаемой формы.
1. Transport client
ts
import { createAxiosClient } from "@dubium/query-layer"
// publicApi используется endpoint-ами без access token.
export const publicApi = createAxiosClient({
baseURL: "http://localhost:3000",
timeout: 15_000,
withCredentials: true,
})Этот файл создаётся один раз. Не создавайте отдельный Axios client для каждого endpoint.
2. Schema и type формы
ts
import { boolean, email, minLength, object, pipe, string, type InferInput } from "valibot"
// Schema описывает значения, которыми управляет форма.
export const RegistrationFormSchema = object({
firstName: pipe(string(), minLength(1, "Введите имя")),
email: pipe(string(), email("Введите корректный email")),
password: pipe(string(), minLength(8, "Минимум восемь символов")),
acceptedPersonalData: boolean(),
})
// Type выводится из schema, чтобы не описывать форму дважды.
export type RegistrationForm = InferInput<typeof RegistrationFormSchema>Form model использует понятные UI-имена. Backend DTO может называться иначе.
3. Request DTO
ts
// Это точный контракт body, который ждёт backend.
export interface RegistrationRequestDto {
first_name: string
email: string
password: string
checked_personal_data: boolean
}Разделение form и DTO оправдано даже при похожих полях: UI может хранить confirmPassword, чекбоксы и временные значения, которые backend не нужны.
4. Response DTO и Valibot schema
ts
export interface RegistrationUserDto {
id: string
firstName: string
email: string
}
export interface RegistrationSuccessResponseDto {
data: {
accessToken: string
user: RegistrationUserDto
}
}
export interface RegistrationErrorResponseDto {
code: string
message: string
errors?: Array<{
field: string
message: string
}>
}ts
import { array, object, optional, string } from "valibot"
// Schema успешного ответа проверяет реальный JSON backend.
export const RegistrationSuccessResponseSchema = object({
data: object({
accessToken: string(),
user: object({
id: string(),
firstName: string(),
email: string(),
}),
}),
})
// Error schema используется безопасно: ошибка может иметь неизвестную форму.
export const RegistrationErrorResponseSchema = object({
code: string(),
message: string(),
errors: optional(
array(
object({
field: string(),
message: string(),
}),
),
),
})DTO даёт TypeScript type. Schema проверяет runtime-данные. Наличие только DTO не защищает от сломанного или изменившегося ответа backend.
5. Mapper формы в DTO
ts
import type { RegistrationRequestDto } from "../dto/registration-request.dto"
import type { RegistrationForm } from "../schema/registration-form.schema"
// Mapper — чистая функция: без HTTP, MobX и side effects.
export const mapRegistrationFormToRequestDto = (form: RegistrationForm): RegistrationRequestDto => {
return {
first_name: form.firstName.trim(),
email: form.email.trim().toLowerCase(),
password: form.password,
checked_personal_data: form.acceptedPersonalData,
}
}Mapper — единственное место, которое знает различия firstName и first_name.
6. API-функция
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { parse } from "valibot"
import { publicApi } from "../../../api"
import type { RegistrationRequestDto } from "../dto/registration-request.dto"
import type { RegistrationSuccessResponseDto } from "../dto/registration-response.dto"
import { RegistrationSuccessResponseSchema } from "../schema/registration-response.schema"
export const registrationApi = async (
dto: RegistrationRequestDto,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<RegistrationSuccessResponseDto>> => {
// response.data пока unknown: доверять внешнему JSON нельзя.
const response = await publicApi.post<unknown>("/api/v1/auth/registration", dto, config)
// parse либо возвращает проверенные данные, либо бросает ValibotError.
const data: RegistrationSuccessResponseDto = parse(RegistrationSuccessResponseSchema, response.data)
// Сохраняем status, headers и другие поля AxiosResponse,
// но заменяем data на проверенное значение.
return {
...response,
data,
}
}API-функция является последней границей недоверенных backend-данных. После неё store получает типизированный и проверенный ответ.
7. Domain models
ts
// UI-модель пользователя не обязана повторять весь response DTO.
export interface RegistrationUser {
id: string
firstName: string
email: string
}
// UI получает предсказуемую предметную ошибку.
export interface RegistrationError {
code: string
message: string
fields: Array<{
field: string
message: string
}>
}8. Domain store
ts
import { makeAutoObservable } from "mobx"
import { safeParse } from "valibot"
import { createRequestStore, type INormalizedAxiosError } from "@dubium/query-layer"
import type { THandlerStatus } from "@dubium/query-layer/contracts"
import { registrationApi } from "../api/registration.api"
import { mapRegistrationFormToRequestDto } from "../mapper/registration.mapper"
import { RegistrationErrorResponseSchema } from "../schema/registration-response.schema"
import type { RegistrationForm } from "../schema/registration-form.schema"
import type { RegistrationError, RegistrationUser } from "./registration.model"
export class RegistrationStore {
// Начальные значения принадлежат feature, а не React-компоненту.
readonly defaultValues: RegistrationForm = {
firstName: "",
email: "",
password: "",
acceptedPersonalData: false,
}
// RequestStore отвечает за lifecycle одного submit-запроса.
private readonly requestHandler = createRequestStore({
api: registrationApi,
options: {
// Registration изменяет server state и может быть неидемпотентной.
retry: false,
},
})
constructor() {
makeAutoObservable<this, "requestHandler">(
this,
{
// Не преобразуем вложенный MobX-store повторно.
requestHandler: false,
},
{
autoBind: true,
},
)
}
get loading(): boolean {
return this.requestHandler.loading
}
get status(): THandlerStatus | null {
return this.requestHandler.status
}
// Техническая ошибка остаётся доступна для логов и общих UI-состояний.
get requestError(): INormalizedAxiosError | null {
return this.requestHandler.error
}
// Не доверяем payload ошибки: он пришёл извне и имеет тип unknown.
get error(): RegistrationError | null {
const result = safeParse(RegistrationErrorResponseSchema, this.requestHandler.error?.payload)
if (!result.success) {
return null
}
return {
code: result.output.code,
message: result.output.message,
fields: result.output.errors ?? [],
}
}
get user(): RegistrationUser | null {
const user = this.requestHandler.data?.data.user
if (!user) {
return null
}
return {
id: user.id,
firstName: user.firstName,
email: user.email,
}
}
cancel(): void {
this.requestHandler.cancel()
}
reset(): void {
this.requestHandler.reset()
}
async fetch(values: RegistrationForm): Promise<RegistrationUser | null> {
// values пришли из React-формы.
// Mapper превращает UI-модель в backend DTO.
const dto = mapRegistrationFormToRequestDto(values)
// execute вызывает registrationApi(dto, { signal }).
const response = await this.requestHandler.execute(dto)
if (response === null) {
return null
}
// Здесь можно сохранить token в отдельный TokenStore.
// tokenStore.setEncodedToken(response.data.data.accessToken)
return response.data.data.user
}
dispose(): void {
this.requestHandler.dispose()
}
}Почему fetch(values) получает форму, а не DTO: React работает с понятиями UI, а store владеет преобразованием в backend contract. Если DTO поменяется, компонент останется прежним.
9. useStoreInstance
Следующий hook не входит в npm-пакет @dubium/query-layer. Это маленький проектный helper, который можно положить в src/shared/hooks.
ts
import { useState } from "react"
/**
* Создаёт один экземпляр store на всё время жизни React-компонента.
*
* useState получает функцию-инициализатор. React вызывает её только при
* первом mount, поэтому MobX-store не пересоздаётся при каждом render.
*
* @typeParam TStore - Тип создаваемого store.
* @param initializer - Функция, которая создаёт store.
* @returns Стабильный экземпляр store.
*/
export const useStoreInstance = <TStore>(initializer: () => TStore): TStore => {
const [store] = useState(initializer)
return store
}Hook не вызывает dispose(), потому что разные stores могут иметь разный lifecycle. Cleanup остаётся явным в useEffect компонента.
10. React-компонент
ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"
import { RegistrationStore } from "../../../data/auth/registration/model/registration.store"
import type { RegistrationForm as RegistrationFormValues } from "../../../data/auth/registration/schema/registration-form.schema"
import { useStoreInstance } from "../../../shared/hooks/use-store-instance"
export const RegistrationForm = observer(() => {
// Hook создаёт store один раз и возвращает тот же объект при rerender.
const store = useStoreInstance(() => new RegistrationStore())
// Для простоты примера форма управляется одним объектом useState.
const [values, setValues] = useState<RegistrationFormValues>(store.defaultValues)
useEffect(() => {
// Cleanup выполняется при unmount компонента.
return () => store.dispose()
}, [store])
return (
<form
onSubmit={(event) => {
event.preventDefault()
// UI передаёт только form values.
// DTO, Axios и Valibot response parsing скрыты внутри data-layer.
void store.fetch(values)
}}
>
<input
aria-label="Имя"
value={values.firstName}
onChange={(event) => {
setValues((current) => ({
...current,
firstName: event.target.value,
}))
}}
/>
<input
aria-label="Email"
type="email"
value={values.email}
onChange={(event) => {
setValues((current) => ({
...current,
email: event.target.value,
}))
}}
/>
<input
aria-label="Пароль"
type="password"
value={values.password}
onChange={(event) => {
setValues((current) => ({
...current,
password: event.target.value,
}))
}}
/>
<label>
<input
checked={values.acceptedPersonalData}
type="checkbox"
onChange={(event) => {
setValues((current) => ({
...current,
acceptedPersonalData: event.target.checked,
}))
}}
/>
Согласен на обработку данных
</label>
<button disabled={store.loading} type="submit">
{store.loading ? "Регистрируем…" : "Зарегистрироваться"}
</button>
{store.error && <p role="alert">{store.error.message}</p>}
{!store.error && store.requestError && <p role="alert">Не удалось выполнить запрос</p>}
{store.user && <p>Пользователь {store.user.firstName} зарегистрирован</p>}
</form>
)
})Компонент импортирует domain store, form type и локальный hook. Он не импортирует publicApi, registrationApi, request DTO или response schema.
Полная цепочка данных
mermaid
flowchart TD
Form["React form values"] --> Store["RegistrationStore.fetch"]
Store --> Mapper["Form → Request DTO"]
Mapper --> Handler["RequestStore.execute"]
Handler --> Api["registrationApi"]
Api --> Backend["POST backend"]
Backend --> Schema["Valibot parse"]
Schema --> Handler
Handler --> Store
Store --> UI["observer render"]Что тестировать
| Часть | Тип теста |
|---|---|
| Mapper | Unit: точное преобразование form → DTO. |
| Schema | Unit: valid/invalid backend payload. |
| API-функция | Integration с mocked Axios adapter/backend. |
| Domain store | Unit: loading, success, error, cancel, reset. |
| React-компонент | Component: действия пользователя и отображение state. |
| Весь flow | Playwright против тестового Express backend. |
Перед review проверьте анти-паттерны.