Skip to content

Домовленості для типових сценаріїв

Правила

  • Логіку - у хук (useFormX), UI - у компонент (<FormX/>).
  • Валідацію - явну (Zod/Yup) поруч із хуком.
  • Сабміт робить сервіс (formService.submit), компонент лише викликає.
  • Стани: idle → submitting → success|error. Кнопка: disabled/aria-busy.
  • Помилки полів - через aria-invalid + role="alert".

Скелет

useLoginForm.ts
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 в одному місці; не змішуємо стратегії.

Скелет

useUsers.ts
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 реалізує хук (useDebouncedValue або useDebouncedCallback).
  • Ніколи не робимо debounce у сервісі/API.
hooks/debounce.tsx
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;
};

Правила

  • Перевірка типу/розміру - до відправки.
  • Прогрес - через onUploadProgress/ReadableStream.
  • Помилки сервера мапимо в дружні повідомлення; файл не зберігаємо у стані як FileList без потреби.

Правила

Правила

  • Усі дати зберігаємо у форматі 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() напряму - створюємо утиліту часу:
utils/time.ts
import { formatISO, parseISO } from 'date-fns';
export const time = {
now: () => new Date(),
parse: (s: string) => parseISO(s),
iso: (d: Date) => formatISO(d),
};

→ у тестах:

utils/time.ts
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
expect(time.iso(time.now())).toBe('2025-01-01T00:00:00Z');

Переваги

  • Код детермінований, легко мокати час у тестах.
  • Немає різниці між Node і браузером.
  • Ми не тестуємо сторонні бібліотеки, лише нашу логіку (наприклад, правильність форматування для UI або умов відображення).

Правила

  • Ключі - стійкі (domain.section.key), без інлайнового тексту в логіці.
  • Плейсхолдери (variables) і формати - в ресурсах i18n, не в коді.
  • Тести використовують en-US за замовчуванням.

Правила

  • Єдиний сервіс notify.success/error/info або toast.success/error/info.
  • UI компонента не знає про toasts напряму — тільки виклики сервісу із хука/дії.

Error Boundary / помилки на рівні сторінки

Section titled “Error Boundary / помилки на рівні сторінки”
  • 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)
utils/log.ts
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:

app/api/log/route.ts
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 });
}
app/api/log/route.ts
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.
components/ErrorBoundary.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.tsx
import { 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
utils/apiClient.ts
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('Сталася помилка. Перевірте дані.');
}
  • На сервері лог - JSON у stdout (щоб збирався платформою/драйвером).
  • Для 5xx у route handlers: лог + NextResponse.json({ message: 'Internal error' }, { status: 500 }).
  • Додаємо кореляційний ID у request контекст (через proxy/middleware), прокидуємо в відповіді та логи.
proxy.ts
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;
}

Правила

  • Схеми типізуємо (генератор типів: 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);
  • Один клієнт (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” - обов’язкові.
  • Авторизаційний стан - у провайдері AuthProvider.
  • Оновлення токена - в сервісі, з блокуванням запитів (mutex).
  • UI читає тільки: useAuth(){ user, isAuthenticated, login, logout }.
  • Навігацію інкапсулюємо: 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 в одному місці (config.ts), екпортуємо чисті константи.
  • У тестах - .env.test або пряме налаштування перед створенням клієнтів.