Домовленості для типових сценаріїв
Форми (React + валідація)
Section titled “Форми (React + валідація)”Правила
- Логіку - у хук (
useFormX), UI - у компонент (<FormX/>). - Валідацію - явну (Zod/Yup) поруч із хуком.
- Сабміт робить сервіс (
formService.submit), компонент лише викликає. - Стани:
idle → submitting → success|error. Кнопка:disabled/aria-busy. - Помилки полів - через
aria-invalid+role="alert".
Скелет
export function useLoginForm(api = loginApi) { const [state, setState] = useState<'idle'|'submitting'|'success'|'error'>('idle'); const [errors, setErrors] = useState<Record<string,string>>({}); const submit = async (values: {email: string}) => { setState('submitting'); const ok = validate(values); // Zod if (!ok.success) { setErrors(ok.errors); setState('idle'); return; } try { await api(values); setState('success'); } catch { setState('error'); } }; return { state, errors, submit };}// LoginForm.tsx — лише UI + a11yТести (уривки)
disabled на invalid, toast/alert на error/success, правильний payload у сервіс.
Списки/таблиці (фільтри, пагінація, сортування)
Section titled “Списки/таблиці (фільтри, пагінація, сортування)”Правила
- Стан URL - у query params (source of truth).
- Дані тягне хук
useListQuery(params); UI - показує loading/empty/error. - Пагінація - cursor/offset в одному місці; не змішуємо стратегії.
Скелет
export function useUsers(params: { q?: string; page?: number; sort?: 'name'|'date' }) { return useQuery(['users', params], () => api.users.list(params), { keepPreviousData: true });}Обов’язкові стани: loading skeleton → data / empty state / error state.
Модалки/діалоги/поповери
Section titled “Модалки/діалоги/поповери”Правила
- Керовані компоненти:
isOpen,onOpenChange. - Фокус-менеджмент:
role="dialog",aria-modal="true", focus trap. - Escape/Backdrop - події в одному місці; не дублювати логіку в кожному діалозі.
Пошук з debounce
Section titled “Пошук з debounce”Правила
- Debounce реалізує хук (
useDebouncedValueабоuseDebouncedCallback). - Ніколи не робимо debounce у сервісі/API.
export const useDebouncedValue = (v: string, ms=300) => { const [value, set] = useState(v); useEffect(() => { const t = setTimeout(() => set(v), ms); return () => clearTimeout(t); }, [v, ms]); return value;};Завантаження файлів
Section titled “Завантаження файлів”Правила
- Перевірка типу/розміру - до відправки.
- Прогрес - через
onUploadProgress/ReadableStream. - Помилки сервера мапимо в дружні повідомлення; файл не зберігаємо у стані як
FileListбез потреби.
Дати/час/тайзона
Section titled “Дати/час/тайзона”Правила
Правила
- Усі дати зберігаємо у форматі ISO UTC (
YYYY-MM-DDTHH:mm:ssZ).
Відображення - лише у UI, із врахуванням локалі та тайзони. - Усі обчислення (
addDays,differenceInMinutes,isAfter, тощо) робимо через бібліотеку, а не власні утиліти. - Визначаємо єдині бібліотеки:
date-fns- для серверних компонентів, Node.js, та чистих утиліт.
Причина: мала вага, без side-effects, відмінно типізований, і не тягне тайзону браузера.dayjs- для клієнтського коду (React, UI).
Причина: легкий, immutable API, зручний форматтер, однаковий результат у браузері та тестах jsdom.
- Не використовуємо
new Date()напряму - створюємо утиліту часу:
import { formatISO, parseISO } from 'date-fns';export const time = { now: () => new Date(), parse: (s: string) => parseISO(s), iso: (d: Date) => formatISO(d),};→ у тестах:
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));expect(time.iso(time.now())).toBe('2025-01-01T00:00:00Z');Переваги
- Код детермінований, легко мокати час у тестах.
- Немає різниці між Node і браузером.
- Ми не тестуємо сторонні бібліотеки, лише нашу логіку (наприклад, правильність форматування для UI або умов відображення).
Локалізація (i18n)
Section titled “Локалізація (i18n)”Правила
- Ключі - стійкі (
domain.section.key), без інлайнового тексту в логіці. - Плейсхолдери (variables) і формати - в ресурсах i18n, не в коді.
- Тести використовують
en-USза замовчуванням.
Toast/сповіщення
Section titled “Toast/сповіщення”Правила
- Єдиний сервіс
notify.success/error/infoабоtoast.success/error/info. - UI компонента не знає про toasts напряму — тільки виклики сервісу із хука/дії.
Error Boundary / помилки на рівні сторінки
Section titled “Error Boundary / помилки на рівні сторінки”Таксономія помилок
Section titled “Таксономія помилок”- User-facing (UI): те, що бачить користувач. Завжди дружнє повідомлення + “Повторити”.
- Dev-facing (log): технічні деталі (stack, payload, headers*) → тільки в лог/трекинг.
- Рівні:
Handled(очікувані) - валідація, 4xx від API.Unexpected- винятки в коді, 5xx від API/SSR.Infra- timeouts, network/offline, DNS.
- Санітизація: ніколи не логувати PII/секрети. Маскуємо токени, емейли, телефони.
headers/bodyлогуються лише в очищеному/знеособленому вигляді.
Куди логувати (єдиний шлюз)
Section titled “Куди логувати (єдиний шлюз)”Вводимо один сервіс log з адаптерами:
- локально:
console(dev) - прод: Sentry (або Zabbix/Logtail/Datadog/Elastic) + /api/log (fallback)
type Level = 'info'|'warn'|'error';type Meta = Record<string, unknown>;
export const log = { info: (msg: string, meta?: Meta) => emit('info', msg, sanitize(meta)), warn: (msg: string, meta?: Meta) => emit('warn', msg, sanitize(meta)), error: (msg: string, meta?: Meta) => emit('error', msg, sanitize(meta)),};
function emit(level: Level, message: string, meta?: Meta) { if (process.env.NEXT_PUBLIC_ENV === 'development') { // eslint-disable-next-line no-console console[level](`[${level}] ${message}`, meta ?? {}); return; } try { import('@sentry/nextjs').then(S => S.captureMessage(message, { level, extra: meta })); } catch { navigator.sendBeacon?.('/api/log', JSON.stringify({ level, message, meta })); }}
function sanitize(meta?: Meta): Meta { // вирізати tokens, cookies, emails, phones... return meta ?? {};}API-fallback:
import { NextResponse } from 'next/server';export async function POST(req: Request) { const { level, message, meta } = await req.json(); // Тут відправляємо у зовнішній лог-сервіс або stdout у JSON console.log(JSON.stringify({ level, message, meta, ts: new Date().toISOString() })); return NextResponse.json({ ok: true });}import { NextResponse } from 'next/server';export async function POST(req: Request) { const { level, message, meta } = await req.json(); // Тут відправляємо у зовнішній лог-сервіс або stdout у JSON console.log(JSON.stringify({ level, message, meta, ts: new Date().toISOString() })); return NextResponse.json({ ok: true });}Error Boundary (React) + сторінка помилки
Section titled “Error Boundary (React) + сторінка помилки”- UI-рівень: ловить JavaScript-винятки в клієнтських компонентах → показує дружній fallback + “Спробувати знову”.
- Next.js app router: використовуємо
error.tsx(per-segment) +not-found.tsx.
'use client';import React from 'react';import { log } from '@/lib/log';
export class ErrorBoundary extends React.Component<React.PropsWithChildren, { hasError: boolean }> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: unknown, info: unknown) { log.error('UI crash', { error, info }); } render() { if (this.state.hasError) { return ( <div role="alert"> Щось пішло не так. <button onClick={() => location.reload()}>Оновити</button> </div> ); } return this.props.children; }}
// app/layout.tsximport { ErrorBoundary } from '@/components/ErrorBoundary';export default function RootLayout({ children }: { children: React.ReactNode }) { return <ErrorBoundary>{children}</ErrorBoundary>;}Єдиний fetch-шар з обробкою 4xx/5xx
Section titled “Єдиний fetch-шар з обробкою 4xx/5xx”Усі запити - через apiClient. Він:
- додає
x-correlation-id - мапить помилки в єдиний формат
- логує 5xx/Unexpected через
log.error - повертає user-facing повідомлення для UI
export class HttpError extends Error { status: number; correlationId?: string; details?: unknown; constructor(msg: string, status: number, meta?: any) { super(msg); this.status = status; Object.assign(this, meta); }}
export async function apiFetch<T>( input: RequestInfo, init?: RequestInit, ): Promise<T> { const cid = crypto.randomUUID(); const res = await fetch(input, { ...init, headers: { ...(init?.headers || {}), "x-correlation-id": cid }, }); if (!res.ok) { const isJson = res.headers .get("content-type") ?.includes("application/json"); const body = isJson ? await res.json().catch(() => ({})) : await res.text().catch(() => ""); const err = new HttpError(`HTTP ${res.status}`, res.status, { correlationId: cid, body, }); if (res.status >= 500) log.error("API 5xx", { url: String(input), status: res.status, cid }); throw err; } return res.json() as Promise<T>;}UI-поведінка (приклад):
try { await apiFetch('/api/users'); }catch (e) { if (e instanceof HttpError && e.status >= 500) notify.error('Сервер недоступний. Повторіть спробу пізніше.'); else notify.error('Сталася помилка. Перевірте дані.');}SSR / Route Handlers (Next.js)
Section titled “SSR / Route Handlers (Next.js)”- На сервері лог - JSON у stdout (щоб збирався платформою/драйвером).
- Для 5xx у route handlers: лог +
NextResponse.json({ message: 'Internal error' }, { status: 500 }). - Додаємо кореляційний ID у request контекст (через proxy/middleware), прокидуємо в відповіді та логи.
import { NextResponse } from 'next/server';export default function proxy(req: Request) { const cid = crypto.randomUUID(); const res = NextResponse.next(); res.headers.set('x-correlation-id', cid); return res;}GraphQL (queries/mutations)
Section titled “GraphQL (queries/mutations)”Правила
- Схеми типізуємо (генератор типів: codegen).
- Окремий шар адаптерів:
gqlApi.query('Users', vars)не тече в UI. - Кешування/інвалідація - централізовано (URQL/Apollo/TanStack Query + graphql-request/власний клієнт).
- Помилки: user-facing (toast/alert) vs dev-facing (лог/trace) - розділяємо.
Скелет
// gqlClient.ts — обгорткаexport const gql = new GraphQLClient(API_URL, { headers: () => ({ Authorization: token() }) });
// users.gql.ts — адаптерexport const listUsers = (vars: { q?: string }) => gql.request<UsersQuery, UsersVars>(UsersDocument, vars);REST SDK / API-клієнт
Section titled “REST SDK / API-клієнт”- Один клієнт (
apiClient) з інтерсепторами, таймаутами, retry/backoff. - Всі endpoints - у сервісах; UI викликає тільки сервіси, не
fetch/ky/axiosнапряму. - Ендпоінти повертають доменні моделі, не сирі відповіді.
Фіча-флаги / доступи (RBAC)
Section titled “Фіча-флаги / доступи (RBAC)”useFeature(flag)таuseCan(permission)— єдині точки істини.- Гілки UI не рендеряться, якщо фіча вимкнена (не через CSS
display:none).
Інфініт-скрол / пагінація курсорами
Section titled “Інфініт-скрол / пагінація курсорами”- Використовуємо
useInfiniteQuery(або аналог) зgetNextPageParam. - Merge сторінок робиться в хук, не у компоненті.
- Плейсхолдери й “skeletons” - обов’язкові.
Стан авторизації
Section titled “Стан авторизації”- Авторизаційний стан - у провайдері
AuthProvider. - Оновлення токена - в сервісі, з блокуванням запитів (mutex).
- UI читає тільки:
useAuth()→{ user, isAuthenticated, login, logout }.
Роутінг
Section titled “Роутінг”- Навігацію інкапсулюємо:
useNav()із методамиgo.toUser(123),go.back() - Ніяких “магічних”
navigate('/users?id=123')розкиданих по коду.
A11y та селектори (узгоджено з тестами)
Section titled “A11y та селектори (узгоджено з тестами)”- Всі інтерактивні контролі мають валідні role/name.
- Стан - через
aria-*(aria-disabled,aria-expanded,aria-busy). - Списки - із заголовками; таблиці -
caption, заголовки колонок.
Логування та телеметрія
Section titled “Логування та телеметрія”- Єдиний шлюз
log.info/error/event. - Ніколи не логувати PII. Прод-логи вимикаються в тестах (guard у
setupTests.ts).
Обробка конфігів/ENV
Section titled “Обробка конфігів/ENV”- Читаємо ENV в одному місці (
config.ts), екпортуємо чисті константи. - У тестах -
.env.testабо пряме налаштування перед створенням клієнтів.