Appearance
Антипаттерны
Ниже частые ошибки в DSA. Смысл списка не в запретах ради запретов, а в сохранении понятного владельца.
1. React-компонент валидирует внешний DTO
Плохо:
tsx
const post = parse(PostResponseSchema, input)
return <PostCard post={post} />Почему: transport-контракт протёк в UI.
Лучше выполнить unknown → Valibot → DTO → mapper внутри data.
2. DTO используется как props
Плохо:
ts
interface IPostCardProps {
post: PostResponseDto
}Лучше:
ts
interface IPostCardProps {
post: IPost
}UI получает внутреннюю форму данных.
3. Mapper живёт в page
Page не должна знать, что внешний контракт использует published_at.
Mapper принадлежит владельцу данных:
txt
data/posts/posts.mapper.ts4. Store возвращает JSX
MobX store хранит состояние и действия. React-компонент отображает состояние.
txt
store → данные
component → JSX5. Store использует React hooks
useParams, useNavigate, useForm и другие React hooks не вызываются внутри MobX class.
Route params читаются в React-слое и передаются обычными значениями.
6. MobX дублирует React Hook Form
Плохо:
txt
form.title
store.titleЕсли оба значения должны всегда совпадать, появился второй источник истины.
Поля формы оставьте React Hook Form. MobX используйте для состояния сценария, которое живёт отдельно.
7. Одна schema для формы и внешнего ответа
Даже одинаковые сегодня поля имеют разных владельцев.
txt
PostFormSchema
PostResponseSchemaИх можно объединить только если это действительно один контракт, а не случайное совпадение.
8. shared знает про посты
Плохо:
txt
shared/PostCard
shared/usePostsХорошо:
txt
components/posts/PostCard
shared/ui/Button9. Page содержит data-layer
Плохо:
txt
pages/Post/
post.schema.ts
post.store.tsPage представляет URL. Данные принадлежат data.
10. Deep import
Плохо:
ts
import { postsStore } from "@/data/posts/posts.store"Хорошо:
ts
import { postsStore } from "@/data/posts"11. Один огромный store
Если список, детали, редактор и комментарии имеют разный lifecycle, один BlogStore быстро становится точкой связанности всего приложения.
Разделяйте по владельцам и сценариям.
12. Store знает конкретный route
Плохо:
ts
store.openPostRoute("/posts/123")Store может вернуть postId. Навигацию выполняет React-слой через React Router.
13. observer ставится везде
observer нужен компоненту, который читает observable MobX state.
Dumb-компонент на обычных props не обязан быть observer.
14. Структура создаётся заранее
Не создавайте пустые api/, model/, types/, schema/, mapper/ для каждого slice.
Начните плоско и разделяйте по мере роста. Когда технические папки появляются, не смешивайте роли: *.types.ts должны находиться в types/, а не в model/.
15. Вызов функции через optional chaining
Не используйте optional call для условного вызова функции. Сначала явно проверьте, что функция существует, и только потом вызывайте её.
ts
if (this.unsubscribe) {
this.unsubscribe()
}Явная проверка делает условие вызова заметным и соответствует принятому стилю кода.
16. Spread результата register() в JSX
Не передавайте результат register() в элемент через spread. Props поля должны быть видны явно в JSX.
tsx
const titleField = form.register("title")
<input
name={titleField.name}
onBlur={titleField.onBlur}
onChange={titleField.onChange}
ref={titleField.ref}
/>Так по месту использования видно, какие обработчики и ссылки получает элемент, а добавление других props не скрывается внутри spread.
Итоговая проверка
Большинство проблем можно найти четырьмя вопросами:
- Кто владеет кодом?
- Не вышел ли внешний контракт в React?
- Не идёт ли зависимость снизу вверх?
- Не появилось ли два источника истины?
DTO через indexed access
Не выводите DTO самостоятельной сущности через indexed access из поля response DTO.
Если Post — самостоятельный внешний контракт, у него должна быть собственная schema и собственный DTO:
ts
// ✅ хорошо
export const PostSchema = object({
id: string(),
title: string(),
})
export type PostDto = InferOutput<typeof PostSchema>
export const PostsResponseSchema = object({
posts: array(PostSchema),
})
export type PostsResponseDto = InferOutput<typeof PostsResponseSchema>Так PostDto не зависит от формы конкретного response envelope и остаётся явным контрактом.