Rule of Three - принцип, который гласит: не создавайте абстракцию пока код не повторился 3 раза. Это защита от преждевременных абстракций и оверинжиниринга.
// components/auth/login-form.tsx
const LoginForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
try {
await login({ email, password });
router.push('/dashboard');
} catch (error) {
setErrors({ general: error.message });
}
};
return (
<form onSubmit={handleSubmit}>
{/* JSX */}
</form>
);
};// components/auth/register-form.tsx
const RegisterForm = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
try {
await register({ email, password, confirmPassword });
router.push('/dashboard');
} catch (error) {
setErrors({ general: error.message });
}
};
return (
<form onSubmit={handleSubmit}>
{/* Похожий JSX */}
</form>
);
};// components/auth/reset-password-form.tsx
const ResetPasswordForm = () => {
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
try {
await resetPassword({ email });
router.push('/login');
} catch (error) {
setErrors({ general: error.message });
}
};
return (
<form onSubmit={handleSubmit}>
{/* Опять похожий JSX */}
</form>
);
};❌ Плохо:
- Задача: "Добавить форму сброса пароля"
- В процессе: создали useAuthForm хук и отрефакторили все формы
- Результат: сложный PR, смешанные изменения, трудно ревьюить✅ Хорошо:
1. Задача: "Добавить форму сброса пароля"
- Создаем ResetPasswordForm по аналогии с существующими
- Комментируем в коде: TODO: рефакторинг после 3го повторения
2. Отдельная задача: "Рефакторинг auth форм"
- Создаем useAuthForm хук
- Атомарно применяем ко всем формам
- Один PR, один фокус// Анализируем что общего в трех компонентах
Общее:
- useState для полей и ошибок
- handleSubmit логика
- Обработка ошибок
- Редирект после успеха
Разное:
- Набор полей
- API вызовы
- Пути редиректа// hooks/use-auth-form.ts
interface AuthFormConfig {
onSubmit: (data: any) => Promise<void>;
redirectTo?: string;
fields: string[];
}
export const useAuthForm = (config: AuthFormConfig) => {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setErrors({});
setIsSubmitting(true);
try {
await config.onSubmit(formData);
if (config.redirectTo) {
router.push(config.redirectTo);
}
} catch (error) {
setErrors({ general: error.message });
} finally {
setIsSubmitting(false);
}
};
const updateField = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return {
formData,
errors,
isSubmitting,
handleSubmit,
updateField
};
};// Рефакторим все формы в одном PR
const LoginForm = () => {
const { formData, errors, handleSubmit, updateField } = useAuthForm({
onSubmit: (data) => login(data),
redirectTo: '/dashboard',
fields: ['email', 'password']
});
return (
<form onSubmit={handleSubmit}>
<input
value={formData.email || ''}
onChange={(e) => updateField('email', e.target.value)}
/>
{/* остальные поля */}
</form>
);
};## Задача: Рефакторинг auth форм
### Проблема:
- Дублирование логики в LoginForm, RegisterForm, ResetPasswordForm
- Одинаковые ошибки в 3 местах
- Сложно добавлять новые поля
### Решение:
- Создать useAuthForm хук
- Вынести общую логику обработки форм
- Применить ко всем auth формам
### Критерии готовности:
- [ ] Создан useAuthForm хук
- [ ] Отрефакторены все 3 формы
- [ ] Тесты покрывают новую логику
- [ ] Документация обновленаRule of Three НЕ применяется для:
// 120 строк дублированного кода в трех файлах
LoginForm.tsx - 40 строк
RegisterForm.tsx - 40 строк
ResetForm.tsx - 40 строк// 60 строк общего кода + 30 строк специфики
useAuthForm.ts - 30 строк (переиспользуемая логика)
LoginForm.tsx - 10 строк (только UI)
RegisterForm.tsx - 10 строк (только UI)
ResetForm.tsx - 10 строк (только UI)Перед рефакторингом:
Во время рефакторинга:
После рефакторинга:
Главное правило: Рефакторинг ради качества кода, а не ради рефакторинга!
❌ Преждевременная оптимизация - FP везде "на всякий случай"
❌ Оверинжиниринг - сложные абстракции для простых задач
❌ Универсальные решения - попытка решить все проблемы сразу
❌ Магические типы - сложная типизация без очевидной пользы
├── app/ # 🚀 Только роутинг и специальные файлы
│ ├── [locale]/ # 🌍 Локализация (корневой динамический сегмент)
│ │ ├── layout.tsx # Главный layout с провайдерами i18n
│ │ ├── page.tsx # Главная страница
│ │ ├── projects/
│ │ │ ├── page.tsx # /projects - страница проектов
│ │ │ ├── loading.tsx # Loading UI для /projects
│ │ │ ├── _components/ # 📦 Приватные компоненты для projects
│ │ │ │ ├── projects-grid.tsx
│ │ │ │ ├── project-card.tsx
│ │ │ │ └── projects-filters.tsx
│ │ │ └── [id]/
│ │ │ ├── page.tsx # /projects/[id] - детали проекта
│ │ │ └── _components/
│ │ │ ├── project-gallery.tsx
│ │ │ └── project-details.tsx
│ │ └── search/
│ │ ├── page.tsx # /search - поиск
│ │ └── _components/
│ │ ├── search-input.tsx
│ │ └── search-results.tsx
│ ├── api/ # 🔌 API Routes (если нужны)
│ │ └── revalidate/
│ │ └── route.ts
│ ├── globals.css # Глобальные стили
│ └── favicon.ico
├── components/ # 🧩 Глобальные переиспользуемые компоненты
│ ├── ui/ # Базовые UI компоненты
│ │ ├── button.tsx
│ │ ├── input.tsx
│ │ ├── modal.tsx
│ │ └── loading-spinner.tsx
│ ├── layout/ # Layout компоненты
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── navigation.tsx
│ └── shared/ # Общие бизнес-компоненты
│ ├── search-input/ # 🎯 Колокация для переиспользуемых компонентов
│ │ ├── search-input.tsx
│ │ ├── types.ts
│ │ ├── transformer.ts
│ │ └── hooks/
│ │ ├── use-search-api.ts
│ │ └── use-search-input.ts
│ └── project-card/
│ ├── project-card.tsx
│ ├── types.ts
│ └── transformer.ts
├── lib/ # 🛠️ Утилиты и конфигурация
│ ├── directus/
│ │ ├── client.ts # Конфигурация Directus
│ │ └── types.ts # Схема Directus
│ ├── i18n/
│ │ ├── config.ts # Конфигурация локализации
│ │ ├── request.ts # Server-side i18n
│ │ └── navigation.ts # Типизированные ссылки
│ ├── utils/
│ │ ├── cn.ts # className утилиты
│ │ ├── transforms.ts # Переиспользуемые трансформации (FP)
│ │ └── validations.ts # Схемы валидации
│ └── hooks/ # Глобальные хуки
│ ├── use-locale.ts
│ └── use-breakpoint.ts
├── messages/ # 🌍 Переводы
│ ├── en.json
│ ├── ru.json
│ └── de.json
├── types/ # 📝 Глобальные типы
│ ├── directus/
│ │ ├── base.ts # Базовые типы без отношений
│ │ ├── relations.ts # Типы с отношениями
│ │ └── schema.ts # Главная схема для Directus
│ └── global.ts
├── middleware.ts # 🔀 Middleware для i18n
├── next.config.js # ⚙️ Конфигурация Next.js
└── package.jsonApp директория принимает колокацию, но используйте её только для файлов связанных с роутингом
// ✅ В app/ директории
page.tsx // Страница роута
layout.tsx // Layout для роута
loading.tsx // Loading UI
error.tsx // Error UI
not-found.tsx // 404 страница
_components/ // Приватные компоненты (префикс _)Папки с префиксом _ не участвуют в роутинге и подходят для колокации UI логики
app/
├── projects/
│ ├── page.tsx
│ └── _components/ // ✅ Не влияет на роутинг
│ ├── project-grid.tsx
│ └── project-card.tsxПапки в скобках () используются для организации без влияния на URL
app/
├── (marketing)/ // ✅ Группа для маркетинга
│ ├── about/
│ └── contact/
├── (shop)/ // ✅ Группа для магазина
│ ├── products/
│ └── cart/
└── (auth)/ // ✅ Группа для аутентификации
├── login/
└── register/Переиспользуемые компоненты лучше держать в src/components или components на корневом уровне
Используйте app/[locale]/ как корневой сегмент для поддержки URL вида /en/about, /ru/projects
app/
└── [locale]/
├── layout.tsx # Корневой layout с i18n провайдерами
├── page.tsx # Главная страница
├── projects/
│ ├── page.tsx # /en/projects, /ru/projects
│ └── [id]/
│ └── page.tsx # /en/projects/123, /ru/projects/123
└── about/
└── page.tsx # /en/about, /ru/about// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './lib/i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: [
// Исключаем внутренние пути Next.js
'/((?!api|_next|_vercel|.*\\..*).*)'
]
};// lib/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'ru', 'de'],
defaultLocale: 'en',
pathnames: {
'/': '/',
'/projects': {
en: '/projects',
ru: '/proekty',
de: '/projekte'
}
}
});
export type Locale = (typeof routing.locales)[number];// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { routing } from '@/lib/i18n/routing';
import { notFound } from 'next/navigation';
interface RootLayoutProps {
children: React.ReactNode;
params: { locale: string };
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function RootLayout({
children,
params: { locale }
}: RootLayoutProps) {
// Валидация локали
if (!routing.locales.includes(locale as any)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}// lib/i18n/navigation.ts
import { createLocalizedPathnamesNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter } =
createLocalizedPathnamesNavigation(routing);
// Использование
import { Link } from '@/lib/i18n/navigation';
<Link href="/projects">Проекты</Link> // автоматически станет /ru/proekty// messages/en.json
{
"common": {
"loading": "Loading...",
"error": "Something went wrong"
},
"projects": {
"title": "Our Projects",
"empty": "No projects found",
"loadMore": "Load More"
}
}
// messages/ru.json
{
"common": {
"loading": "Загрузка...",
"error": "Что-то пошло не так"
},
"projects": {
"title": "Наши проекты",
"empty": "Проекты не найдены",
"loadMore": "Загрузить ещё"
}
}// components/shared/projects-grid/hooks/use-projects-api.ts
import { useSWR } from 'swr';
import { readItems } from '@directus/sdk';
import { directusClient } from '@/lib/directus/client';
export const useProjectsAPI = (filters: ProjectFilters) => {
return useSWR(
['projects', filters],
() => directusClient.request(
readItems('projects', {
fields: ['id', 'name', 'description', 'photos_relations.id'] as const,
filter: filters
})
)
);
};
// Автоматически выводим тип из запроса
export type ProjectsAPIResponse = Awaited<ReturnType<typeof useProjectsAPI>['data']>;// types/directus/schema.ts
// ✅ Правильно
interface DirectusSchema {
// Обычные коллекции - массивы
projects: Project[];
articles: Article[];
// Синглтоны - без массива
global_settings: GlobalSettings;
// Junction коллекции тоже включаем
projects_tags: ProjectTag[];
}
// ❌ Избегайте
interface BadSchema {
projects?: Project[]; // ❌ optional types
articles: Project[] | null; // ❌ unions с null
settings: {}; // ❌ пустые типы
}// types/directus/relations.ts
interface Project {
id: number;
name: string;
status: string;
// M2O - ID или объект
author: number | User;
// O2M - массив ID или объектов
tags: number[] | Tag[];
// M2M через junction - массив ID или junction объектов
categories: number[] | ProjectCategory[];
}
interface ProjectCategory {
id: number;
project_id: number | Project;
category_id: number | Category;
}ВАЖНО: Directus SDK НЕ автоматически добавляет null к типам! Вы должны явно указать nullable поля в схеме.
// ✅ Правильно - отражаем реальную схему БД
interface User {
id: number; // PRIMARY KEY - NOT NULL
name: string; // REQUIRED поле - NOT NULL
email: string; // REQUIRED поле - NOT NULL
// ✅ NULLABLE поля - ОБЯЗАТЕЛЬНО указываем | null
avatar: string | null; // NULLABLE в БД
bio: string | null; // NULLABLE в БД
phone: string | null; // NULLABLE в БД
// Отношения - тоже могут быть nullable
profile: number | UserProfile | null; // NULLABLE отношение
posts: number[] | Post[]; // NOT NULL (пустой массив если нет)
}
// ❌ Неправильно - игнорируем nullable
interface UserBad {
id: number;
name: string;
email: string;
avatar: string; // ❌ Типонебезопасно! В API может прийти null
bio: string; // ❌ Типонебезопасно! В API может прийти null
}
// При автогенерации типов результат будет корректным только с правильной схемой
type UserFromAPI = Awaited<ReturnType<typeof getUserAPI>['data']>;
// Результат: { id: number; name: string; avatar: string | null; bio: string | null; ... }[]1. Проверьте схему БД:
-- PostgreSQL/MySQL
DESCRIBE users;
-- Смотрите колонку "Null": YES = nullable, NO = not null
-- Или через Directus API
GET /fields/users
-- Поле "schema.is_nullable": true/false2. Тестируйте в runtime:
// В трансформере всегда проверяйте на null
export const transformUser = (apiUser: UserAPIResponse[0]): UserData => {
return {
id: apiUser.id,
name: apiUser.name,
// ✅ Безопасная обработка nullable полей
avatar: apiUser.avatar || '/default-avatar.png',
bio: apiUser.bio || 'Нет информации',
hasProfile: !!apiUser.profile
};
};// ❌ Создает циклическую ссылку
interface User {
id: number;
name: string;
posts: number[] | Post[]; // User ссылается на Post
}
interface Post {
id: number;
title: string;
author: number | User; // Post ссылается на User
}
// Ошибка: Type alias 'User' circularly references itself1. Разделение файлов и forward declarations
// types/directus/base.ts
export interface UserBase {
id: number;
name: string;
email: string;
}
export interface PostBase {
id: number;
title: string;
content: string;
}
// types/directus/relations.ts
import type { UserBase, PostBase } from './base';
export interface User extends UserBase {
posts: number[] | Post[];
}
export interface Post extends PostBase {
author: number | User;
}2. Lazy типы через type aliases
// types/directus/schema.ts
interface User {
id: number;
name: string;
posts: number[] | PostRelation[];
}
interface Post {
id: number;
title: string;
author: number | UserRelation;
}
// Lazy типы для разрыва циклов
type UserRelation = Omit<User, 'posts'>;
type PostRelation = Omit<Post, 'author'>;# TSC покажет циклические ссылки
$ npx tsc --noEmit
error TS2315: Type alias 'User' circularly references itself
# Но Cursor/VSCode могут не показывать из-за:
# - Ограниченной области видимости (открытые файлы)
# - Кеширования типов
# - Разных версий TypeScript Language Server# Проверить циклические зависимости
npx madge -c --extensions ts ./src
# Принудительная проверка типов
npx tsc --noEmit --skipLibCheck false
# Очистить кеш TypeScript в IDE
# Command Palette: "TypeScript: Restart TS Server"✅ Единственный источник истины - запрос определяет тип
✅ Автоматическое обновление - изменили поля → обновились типы
✅ Защита от рассинхронизации - невозможно забыть обновить тип
✅ Официально рекомендовано Directus
⚠️ Требует правильной схемы - nullable поля должны быть указаны явно
Ключевая идея: Требуемый тип поднимается из глубины наверх, к более общим компонентам, определяя перечень требуемых данных. Трансформеры - коннекторы между API и UI.
UI компонент (требует ProjectCardData[])
↑
Композитный хук (поднимает требования)
↑
Трансформер (коннектор: API → UI типы)
↑
API Hook (определяет структуру ответа сервера)
↑
Directus SDK (ReturnType автотипизация)// 1. UI компонент определяет требуемую структуру данных
// components/shared/project-card/project-card.tsx
interface ProjectCardData {
id: number;
name: string;
imageUrl: string | null;
status: 'published' | 'draft';
}
export const ProjectCard = ({ project }: { project: ProjectCardData }) => {
return (
<div className="project-card">
<h3>{project.name}</h3>
{project.imageUrl && <img src={project.imageUrl} alt={project.name} />}
<span className={`status ${project.status}`}>{project.status}</span>
</div>
);
};
// 2. API Hook с автотипизацией (колоцированный рядом с компонентом)
// components/shared/projects-grid/hooks/use-projects-api.ts
export const useProjectsAPI = (filters: ProjectFilters) => {
return useSWR(['projects', filters], () =>
directusClient.request(readItems('projects', {
// Запрашиваем только нужные поля исходя из UI требований
fields: [
'id',
'name',
'status',
'photos_relations.directus_files_id'
] as const,
filter: filters
}))
);
};
// Автотипизация - SDK определяет структуру ответа
export type ProjectsAPIResponse = Awaited<ReturnType<typeof useProjectsAPI>['data']>;
// 3. Трансформер - коннектор между API и UI типами
// components/shared/projects-grid/transformer.ts
export const transformToProjectCards = (
apiData: ProjectsAPIResponse
): ProjectCardData[] => {
if (!apiData) return [];
return apiData.map(project => ({
id: project.id,
name: project.name || 'Без названия',
// ✅ Безопасная обработка nullable полей
imageUrl: project.photos_relations?.[0]?.directus_files_id
? `/assets/${project.photos_relations[0].directus_files_id}`
: null,
status: project.status as 'published' | 'draft'
}));
};
// 4. Композитный хук - поднимает требования UI вверх
// components/shared/projects-grid/hooks/use-projects-grid.ts
export const useProjectsGrid = (filters: ProjectFilters) => {
const { data: apiData, isLoading, error } = useProjectsAPI(filters);
const projectCards = useMemo(() =>
transformToProjectCards(apiData), [apiData]
);
return {
projects: projectCards, // ProjectCardData[]
isLoading,
error
};
};
// 5. Родительский компонент - использует готовые типизированные данные
// components/shared/projects-grid/projects-grid.tsx
export const ProjectsGrid = ({ filters }: ProjectsGridProps) => {
const { projects, isLoading, error } = useProjectsGrid(filters);
if (isLoading) return <LoadingState />;
if (error) return <ErrorState />;
return (
<div className="projects-grid">
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);
};✅ Типобезопасность: Автотипизация + явные nullable поля
✅ Разделение ответственности: API ↔ Трансформер ↔ UI
✅ Колокация: Все связанные файлы рядом
✅ Масштабируемость: Легко добавлять новые поля в запрос
✅ Поток требований: UI определяет что нужно запросить
// ❌ До: большой компонент
function UserProfile({ user, posts, settings }) {
return (
<div>
<img src={user.avatar} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<ul>{posts.map(post => <li>{post.title}</li>)}</ul>
<form>...</form>
</div>
);
}
// ✅ После: разделенные компоненты
function UserProfile({ user, posts, settings }) {
return (
<div>
<UserAvatar user={user} />
<UserInfo user={user} />
<UserPosts posts={posts} />
<UserSettings settings={settings} />
</div>
);
}// ✅ Каждый компонент делает одну вещь
const UserAvatar = ({ user }) => (
<img src={user.avatarUrl} alt={`${user.name}'s avatar`} />
);
const UserInfo = ({ user }) => (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);Функциональное программирование - это не про библиотеки, а про принципы: чистые функции, immutability, композиция. Всё это прекрасно работает на обычном JavaScript!
// ✅ Чистая функция - один вход, один выход, без побочных эффектов
const formatUser = (user) => ({
id: user.id,
name: user.name,
email: user.email,
isActive: user.status === 'active'
});
// ✅ Чистая трансформация массива
const formatUsers = (users) => {
return users
.filter(user => user.status === 'active')
.map(formatUser)
.sort((a, b) => a.name.localeCompare(b.name));
};// ✅ Immutable трансформация
const addUserFlags = (user) => ({
...user, // не мутируем исходный объект
hasAvatar: !!user.avatar,
isVerified: user.email_verified && user.phone_verified
});
// ❌ Избегаем мутации
const addUserFlagsBad = (user) => {
user.hasAvatar = !!user.avatar; // мутация!
return user;
};// ✅ Простые функции
const filterActive = (users) => users.filter(u => u.status === 'active');
const addUserFlags = (users) => users.map(u => ({ ...u, hasAvatar: !!u.avatar }));
const sortByName = (users) => users.sort((a, b) => a.name.localeCompare(b.name));
// ✅ Композиция - читабельная цепочка
const processUsers = (users) => {
return sortByName(addUserFlags(filterActive(users)));
};
// ✅ Или через pipe паттерн на чистом JS
const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const processUsersWithPipe = pipe(
filterActive,
addUserFlags,
sortByName
);// ✅ Чистый трансформер
const transformToProjectCards = (apiProjects) => {
if (!apiProjects) return [];
return apiProjects.map(project => ({
id: project.id,
title: project.title || 'Без названия',
// Безопасная обработка nullable полей
imageUrl: project.image_url || null,
status: project.status || 'draft',
createdAt: new Date(project.date_created)
}));
};// ✅ Небольшие, переиспользуемые трансформеры
const normalizeTitle = (item) => ({
...item,
title: item.title?.trim() || 'Без названия'
});
const addTimestamps = (item) => ({
...item,
createdAt: new Date(item.date_created),
updatedAt: new Date(item.date_updated)
});
const addStatus = (item) => ({
...item,
status: item.status || 'draft',
isPublished: item.status === 'published'
});
// ✅ Композиция в трансформере
const transformArticle = (apiArticle) => {
return addStatus(addTimestamps(normalizeTitle(apiArticle)));
};
// ✅ Для массивов
const transformArticles = (apiArticles) => {
if (!apiArticles) return [];
return apiArticles.map(transformArticle);
};// ✅ Трансформация API ответа
const transformSearchResults = (apiResults) => {
return apiResults
.filter(result => result.status === 'published')
.map(result => ({
id: result.id,
title: result.title,
excerpt: result.content?.substring(0, 150) + '...',
url: `/articles/${result.slug}`
}));
};
// ✅ Локализованная трансформация
const transformWithLocale = (items, locale) => {
return items.map(item => {
const translation = item.translations?.find(t => t.locale === locale);
return {
...item,
title: translation?.title || item.title,
content: translation?.content || item.content
};
});
};// ✅ Чистые утилиты
const formatDate = (date) => new Date(date).toLocaleDateString();
const truncateText = (text, maxLength) =>
text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
const extractImageUrl = (mediaRelations) =>
mediaRelations?.[0]?.directus_files_id
? `/assets/${mediaRelations[0].directus_files_id}`
: null;
// ✅ Использование в трансформерах
const transformBlogPost = (apiPost) => ({
id: apiPost.id,
title: apiPost.title,
excerpt: truncateText(apiPost.content, 200),
imageUrl: extractImageUrl(apiPost.images),
publishedAt: formatDate(apiPost.date_published)
});// ❌ Плохо - мутация и побочные эффекты
const processBadData = (data) => {
data.forEach(item => {
item.processed = true; // мутация
console.log(item); // побочный эффект
});
return data;
};
// ✅ Хорошо - чистая функция
const processData = (data) => {
return data.map(item => ({
...item,
processed: true
}));
};Используйте функциональный подход для:
Цель: Предсказуемый, тестируемый, переиспользуемый код без внешних зависимостей!
// components/shared/search-input/hooks/use-search-api.ts
export const useSearchAPI = (query: string, locale: string) => {
return useSWR(
query ? ['search', query, locale] : null,
() => directusClient.request(readItems('articles', {
search: query,
fields: ['id', 'title', 'summary', 'translations.title', 'translations.summary'] as const,
filter: {
translations: {
languages_code: { _eq: locale }
}
},
limit: 10
}))
);
};
export type SearchAPIResponse = Awaited<ReturnType<typeof useSearchAPI>['data']>;
// components/shared/search-input/transformer.ts
export const transformSearchResults = (
apiData: SearchAPIResponse,
locale: string
): SearchResultItem[] => {
if (!apiData) return [];
return apiData.map(article => {
const translation = article.translations?.[0];
return {
id: article.id,
title: translation?.title || article.title || 'Без названия',
summary: translation?.summary || article.summary || 'Нет описания',
href: `/${locale}/articles/${article.id}`
};
});
};
// components/shared/search-input/hooks/use-search-input.ts
import { useLocale } from '@/lib/hooks/use-locale';
export const useSearchInput = () => {
const locale = useLocale();
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const { data: apiData, isLoading } = useSearchAPI(query, locale);
const results = useMemo(() =>
transformSearchResults(apiData, locale), [apiData, locale]
);
return {
query,
setQuery,
results,
isLoading,
isOpen,
setIsOpen
};
};
// components/shared/search-input/search-input.tsx
import { useTranslations } from 'next-intl';
export const SearchInput = () => {
const t = useTranslations('search');
const { query, setQuery, results, isLoading, isOpen, setIsOpen } = useSearchInput();
return (
<div className="relative">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={t('placeholder')}
/>
{isOpen && (
<SearchResults
results={results}
isLoading={isLoading}
onClose={() => setIsOpen(false)}
/>
)}
</div>
);
};// app/[locale]/projects/page.tsx
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { ProjectsGrid } from '@/components/shared/projects-grid/projects-grid';
import { ProjectsFilters } from './_components/projects-filters';
interface ProjectsPageProps {
params: { locale: string };
searchParams: { category?: string; status?: string };
}
// Генерация metadata для SEO
export async function generateMetadata({
params: { locale },
searchParams
}: ProjectsPageProps) {
const t = await getTranslations({ locale, namespace: 'projects' });
return {
title: t('meta.title'),
description: t('meta.description'),
};
}
export default function ProjectsPage({ params, searchParams }: ProjectsPageProps) {
const t = useTranslations('projects');
const filters = {
category: searchParams.category,
status: searchParams.status || 'published'
};
return (
<div className="container mx-auto py-8">
<header className="mb-8">
<h1 className="text-3xl font-bold">{t('title')}</h1>
<p className="text-muted-foreground">{t('description')}</p>
</header>
<ProjectsFilters />
<ProjectsGrid filters={filters} />
</div>
);
}// types/directus/base.ts - Базовые типы без отношений
export interface UserBase {
id: number;
name: string;
email: string;
avatar: string | null; # ✅ NULLABLE в БД - обязательно указываем
}
export interface PostBase {
id: number;
title: string;
content: string;
published_at: string;
}
export interface TagBase {
id: number;
name: string;
color: string;
}
// types/directus/relations.ts - Отношения через forward declarations
import type { UserBase, PostBase, TagBase } from './base';
export interface User extends UserBase {
posts: number[] | Post[];
profile: number | UserProfile;
}
export interface Post extends PostBase {
author: number | User;
tags: number[] | PostTag[];
translations: number[] | PostTranslation[];
}
export interface Tag extends TagBase {
posts: number[] | PostTag[];
}
// Junction коллекция для M2M
export interface PostTag {
id: number;
post_id: number | Post;
tag_id: number | Tag;
created_at: string;
}
export interface PostTranslation {
id: number;
post_id: number | Post;
languages_code: string;
title: string;
summary: string;
}
export interface UserProfile {
id: number;
user_id: number | User;
bio: string;
website: string;
}
// types/directus/schema.ts - Главная схема
import type { User, Post, Tag, PostTag, UserProfile, PostTranslation } from './relations';
export interface DirectusSchema {
// Основные коллекции
users: User[];
posts: Post[];
tags: Tag[];
// Junction коллекции
posts_tags: PostTag[];
// Переводы
posts_translations: PostTranslation[];
// Связанные коллекции
user_profiles: UserProfile[];
// Синглтоны
global_settings: GlobalSettings;
}
// Экспорт для переиспользования
export type { User, Post, Tag, PostTag, UserProfile, PostTranslation };// lib/utils/transforms.ts
// ✅ Чистые функции для трансформации Directus данных
export const formatDirectusItems = (items = []) => {
return items
.map(item => ({
...item,
date_created: new Date(item.date_created),
status: item.status || 'draft',
title: item.title?.trim() || 'Без названия'
}))
.filter(item => item.status !== 'deleted')
.sort((a, b) => b.date_created.getTime() - a.date_created.getTime());
};
// ✅ Локализованная трансформация с чистыми функциями
export const formatDirectusItemsWithLocale = (items, locale) => {
const formattedItems = formatDirectusItems(items);
return formattedItems.map(item => {
const translation = item.translations?.find(t => t.languages_code === locale);
return {
...item,
title: translation?.title || item.title,
summary: translation?.summary || item.summary,
content: translation?.content || item.content
};
});
};
// ✅ Утилиты для работы с Directus медиа
export const extractImageUrl = (mediaRelations) => {
const firstMedia = mediaRelations?.[0];
return firstMedia?.directus_files_id
? `/assets/${firstMedia.directus_files_id}`
: null;
};
export const extractImageUrls = (mediaRelations) => {
return mediaRelations
?.filter(relation => relation.directus_files_id)
.map(relation => `/assets/${relation.directus_files_id}`) || [];
};
// ✅ Композиция трансформеров
export const transformProjectsWithImages = (apiProjects) => {
return formatDirectusItems(apiProjects)
.map(project => ({
...project,
imageUrl: extractImageUrl(project.images_relations),
galleryUrls: extractImageUrls(project.gallery_relations)
}));
};
// ✅ Использование в разных компонентах
const articles = formatDirectusItemsWithLocale(rawArticles, 'ru');
const news = formatDirectusItemsWithLocale(rawNews, 'en');
const events = formatDirectusItemsWithLocale(rawEvents, 'de');// ✅ Простая реализация pipe без зависимостей
const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
// ✅ Использование для сложных трансформаций
const processArticles = pipe(
(articles) => articles.filter(a => a.status === 'published'),
(articles) => articles.map(a => ({ ...a, slug: generateSlug(a.title) })),
(articles) => articles.sort((a, b) => new Date(b.date_created) - new Date(a.date_created))
);
// ✅ Или без pipe - тоже отлично
const processArticlesLinear = (articles) => {
const published = articles.filter(a => a.status === 'published');
const withSlugs = published.map(a => ({ ...a, slug: generateSlug(a.title) }));
return withSlugs.sort((a, b) => new Date(b.date_created) - new Date(a.date_created));
};// scripts/check-circular.js
const madge = require('madge');
madge('./src', {
fileExtensions: ['ts', 'tsx'],
excludeRegExp: [/\.d\.ts$/, /node_modules/]
})
.then((res) => {
const circular = res.circular();
if (circular.length > 0) {
console.log('🚨 Найдены циклические зависимости:');
circular.forEach((circle) => {
console.log(' - ' + circle.join(' → '));
});
process.exit(1);
} else {
console.log('✅ Циклических зависимостей не найдено');
}
})
.catch((err) => {
console.error('❌ Ошибка проверки:', err);
process.exit(1);
});
// package.json
{
"scripts": {
"check-circular": "node scripts/check-circular.js",
"type-check": "tsc --noEmit",
"type-check-strict": "tsc --noEmit --strict --skipLibCheck false",
"quality-check": "npm run type-check && npm run check-circular && npm run lint"
}
}| null для nullable полей в схемах"Лучшая архитектура та, которую легко понять, изменить и поддерживать. Функциональное программирование - это принципы, а не библиотеки. Чистые функции на ванильном JS лучше сложных абстракций."
# Проверка типов
npm run type-check
# Поиск циклических зависимостей
npm run check-circular
# Линтинг
npm run lint
# Все вместе
npm run quality-check| null для всех nullable полей - это основа типобезопасностиnumber | Object | null для nullable отношенийНачинайте просто, усложняйте только при необходимости! Типобезопасность - не компромисс. Чистые функции - основа надежной архитектуры.