Content is user-generated and unverified.

Directus + React TSX: Руководство по архитектуре (Next.js App Router)

Оглавление

  1. Принципы архитектуры
  2. Структура проекта Next.js 13+
  3. Локализация с [locale]
  4. Типизация с Directus SDK
  5. API хуки и трансформеры
  6. Rule of Three: Когда и как рефакторить
  7. Рефакторинг компонентов
  8. Чистые функции и трансформеры
  9. Примеры реализации
  10. Заключение

Rule of Three: Когда и как рефакторить

🎯 Что такое Rule of Three

Rule of Three - принцип, который гласит: не создавайте абстракцию пока код не повторился 3 раза. Это защита от преждевременных абстракций и оверинжиниринга.

📋 Этапы применения Rule of Three

1️⃣ Первое повторение - копируем код

typescript
// 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>
  );
};

2️⃣ Второе повторение - дублируем с изменениями

typescript
// 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>
  );
};

3️⃣ Третье повторение - СТОП! Время рефакторинга

typescript
// 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>
  );
};

🚨 Критически важно: Рефакторинг = отдельная задача

НЕ делайте рефакторинг в рамках feature/bug

❌ Плохо:
- Задача: "Добавить форму сброса пароля"
- В процессе: создали useAuthForm хук и отрефакторили все формы
- Результат: сложный PR, смешанные изменения, трудно ревьюить

Правильный подход

✅ Хорошо:
1. Задача: "Добавить форму сброса пароля"
   - Создаем ResetPasswordForm по аналогии с существующими
   - Комментируем в коде: TODO: рефакторинг после 3го повторения

2. Отдельная задача: "Рефакторинг auth форм"
   - Создаем useAuthForm хук
   - Атомарно применяем ко всем формам
   - Один PR, один фокус

🔄 Процесс рефакторинга (пошагово)

Шаг 1: Анализ повторений

typescript
// Анализируем что общего в трех компонентах
Общее:
- useState для полей и ошибок
- handleSubmit логика
- Обработка ошибок
- Редирект после успеха

Разное:
- Набор полей
- API вызовы
- Пути редиректа

Шаг 2: Создание абстракции

typescript
// 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
  };
};

Шаг 3: Атомарное применение

typescript
// Рефакторим все формы в одном 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>
  );
};

📊 Преимущества подхода

Отдельная задача рефакторинга

  • Фокус: одна задача = один тип изменений
  • Ревью: легче анализировать изменения
  • Откат: можно откатить рефакторинг независимо от feature
  • Тестирование: проще тестировать каждый тип изменений

Rule of Three

  • Избегает преждевременных абстракций
  • Видны реальные паттерны после 3 повторений
  • Лучше понимание требований к абстракции

🎯 Практические рекомендации

Когда создавать задачу на рефакторинг:

  1. После 3го повторения - обязательно
  2. Код сложно поддерживать - изменения в одном месте требуют правок в других
  3. Баги повторяются - одинаковые ошибки в похожих местах

Как оформить задачу:

markdown
## Задача: Рефакторинг auth форм

### Проблема:
- Дублирование логики в LoginForm, RegisterForm, ResetPasswordForm
- Одинаковые ошибки в 3 местах
- Сложно добавлять новые поля

### Решение:
- Создать useAuthForm хук
- Вынести общую логику обработки форм
- Применить ко всем auth формам

### Критерии готовности:
- [ ] Создан useAuthForm хук
- [ ] Отрефакторены все 3 формы
- [ ] Тесты покрывают новую логику
- [ ] Документация обновлена

⚠️ Исключения из правила

Rule of Three НЕ применяется для:

  • Утилитарных функций (formatDate, validateEmail)
  • Конфигурационных констант (API_URL, COLORS)
  • Типов и интерфейсов - их можно выносить сразу

🔄 Пример до и после рефакторинга

До (3 повторения):

typescript
// 120 строк дублированного кода в трех файлах
LoginForm.tsx    - 40 строк
RegisterForm.tsx - 40 строк  
ResetForm.tsx    - 40 строк

После (рефакторинг):

typescript
// 60 строк общего кода + 30 строк специфики
useAuthForm.ts   - 30 строк (переиспользуемая логика)
LoginForm.tsx    - 10 строк (только UI)
RegisterForm.tsx - 10 строк (только UI)
ResetForm.tsx    - 10 строк (только UI)

📋 Чек-лист для рефакторинга

Перед рефакторингом:

  • Код повторился 3+ раза
  • Есть отдельная задача на рефакторинг
  • Понятны общие и уникальные части
  • Есть тесты на существующий код

Во время рефакторинга:

  • Один PR = один рефакторинг
  • Абстракция покрывает все случаи использования
  • Сохранена обратная совместимость
  • Обновлены тесты

После рефакторинга:

  • Код работает как раньше
  • Проще добавлять новые случаи
  • Обновлена документация
  • Команда знает о новой абстракции

Главное правило: Рефакторинг ради качества кода, а не ради рефакторинга!


Принципы архитектуры

🎯 Основные принципы

  • YAGNI (You Ain't Gonna Need It) - не добавляйте сложность пока не нужна
  • KISS (Keep It Simple Stupid) - простое решение лучше сложного
  • Rule of Three - рефакторьте после третьего повторения в отдельной задаче
  • Single Responsibility - один компонент = одна ответственность
  • Колокация - держите связанные файлы рядом

⚠️ Антипаттерны

Преждевременная оптимизация - FP везде "на всякий случай"
Оверинжиниринг - сложные абстракции для простых задач
Универсальные решения - попытка решить все проблемы сразу
Магические типы - сложная типизация без очевидной пользы


Структура проекта Next.js 13+

📁 Рекомендуемая структура с App Router

├── 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.json

📋 Принципы организации Next.js App Router

app/ директория - только роутинг

App директория принимает колокацию, но используйте её только для файлов связанных с роутингом

typescript
// ✅ В app/ директории
page.tsx           // Страница роута  
layout.tsx         // Layout для роута
loading.tsx        // Loading UI
error.tsx          // Error UI
not-found.tsx      // 404 страница
_components/       // Приватные компоненты (префикс _)

Приватные папки с префиксом _

Папки с префиксом _ не участвуют в роутинге и подходят для колокации UI логики

typescript
app/
├── projects/
│   ├── page.tsx
│   └── _components/        // ✅ Не влияет на роутинг
│       ├── project-grid.tsx
│       └── project-card.tsx

Route Groups для организации

Папки в скобках () используются для организации без влияния на URL

typescript
app/
├── (marketing)/           // ✅ Группа для маркетинга
│   ├── about/
│   └── contact/
├── (shop)/               // ✅ Группа для магазина  
│   ├── products/
│   └── cart/
└── (auth)/               // ✅ Группа для аутентификации
    ├── login/
    └── register/

Глобальные компоненты отдельно

Переиспользуемые компоненты лучше держать в src/components или components на корневом уровне


Локализация с [locale]

🌍 Структура для многоязычности

Корневой динамический сегмент [locale]

Используйте app/[locale]/ как корневой сегмент для поддержки URL вида /en/about, /ru/projects

typescript
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

🔧 Конфигурация i18n

Middleware для определения локали

typescript
// 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|.*\\..*).*)'
  ]
};

Конфигурация роутинга

typescript
// 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];

Layout с i18n провайдерами

typescript
// 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>
  );
}

🔗 Типизированная навигация

typescript
// 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

📝 Переводы

json
// 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": "Загрузить ещё"
  }
}

Типизация с Directus SDK

🎯 Колокация с автотипизацией (рекомендуемый подход)

typescript
// 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']>;

📋 Требования к схеме Directus

✅ Схема должна быть "чистой"

typescript
// 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: {}; // ❌ пустые типы
}

🔗 Типизация отношений

typescript
// 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;
}

⚠️ Правильная типизация Nullable полей

ВАЖНО: Directus SDK НЕ автоматически добавляет null к типам! Вы должны явно указать nullable поля в схеме.

typescript
// ✅ Правильно - отражаем реальную схему БД
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; ... }[]

🔍 Как определить nullable поля

1. Проверьте схему БД:

sql
-- PostgreSQL/MySQL
DESCRIBE users;
-- Смотрите колонку "Null": YES = nullable, NO = not null

-- Или через Directus API
GET /fields/users
-- Поле "schema.is_nullable": true/false

2. Тестируйте в runtime:

typescript
// В трансформере всегда проверяйте на 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
  };
};

⚠️ Проблема циклических ссылок

🚨 Распространенная ошибка

typescript
// ❌ Создает циклическую ссылку
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 itself

✅ Решения циклических ссылок

1. Разделение файлов и forward declarations

typescript
// 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

typescript
// 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'>;

🔧 Проблемы с редакторами

TypeScript Compiler vs IDE

bash
# TSC покажет циклические ссылки
$ npx tsc --noEmit
error TS2315: Type alias 'User' circularly references itself

# Но Cursor/VSCode могут не показывать из-за:
# - Ограниченной области видимости (открытые файлы)
# - Кеширования типов
# - Разных версий TypeScript Language Server

Диагностика проблем

bash
# Проверить циклические зависимости
npx madge -c --extensions ts ./src

# Принудительная проверка типов
npx tsc --noEmit --skipLibCheck false

# Очистить кеш TypeScript в IDE
# Command Palette: "TypeScript: Restart TS Server"

⚡ Преимущества автотипизации

Единственный источник истины - запрос определяет тип
Автоматическое обновление - изменили поля → обновились типы
Защита от рассинхронизации - невозможно забыть обновить тип
Официально рекомендовано Directus
⚠️ Требует правильной схемы - nullable поля должны быть указаны явно


API хуки и трансформеры

🎯 Архитектура "снизу вверх"

Ключевая идея: Требуемый тип поднимается из глубины наверх, к более общим компонентам, определяя перечень требуемых данных. Трансформеры - коннекторы между API и UI.

🔄 Поток данных и типов

UI компонент (требует ProjectCardData[]) 
         ↑
Композитный хук (поднимает требования)
         ↑  
Трансформер (коннектор: API → UI типы)
         ↑
API Hook (определяет структуру ответа сервера)
         ↑
Directus SDK (ReturnType автотипизация)

📝 Пример полной реализации

typescript
// 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 определяет что нужно запросить


Рефакторинг компонентов

🚨 Сигналы к рефакторингу

Размер и сложность

  • Компонент больше 300+ строк
  • Render возвращает больше 10 элементов
  • Множество useState/переменных состояния

Нарушение принципов

  • Компонент делает больше одной вещи
  • Сложно тестировать из-за множества ответственностей
  • Новым разработчикам сложно понять логику

✅ Техники разделения

1. Анализ JSX структуры

typescript
// ❌ До: большой компонент
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>
  );
}

2. Правило единственной ответственности

typescript
// ✅ Каждый компонент делает одну вещь
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>
);

Чистые функции и трансформеры

🎯 Ключевой принцип: Функциональный подход на ванильном JS

Функциональное программирование - это не про библиотеки, а про принципы: чистые функции, immutability, композиция. Всё это прекрасно работает на обычном JavaScript!

✅ Принципы чистых функций

1. Чистые функции - предсказуемые и безопасные

typescript
// ✅ Чистая функция - один вход, один выход, без побочных эффектов
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));
};

2. Immutability - не мутируем исходные данные

typescript
// ✅ Immutable трансформация
const addUserFlags = (user) => ({
  ...user,  // не мутируем исходный объект
  hasAvatar: !!user.avatar,
  isVerified: user.email_verified && user.phone_verified
});

// ❌ Избегаем мутации
const addUserFlagsBad = (user) => {
  user.hasAvatar = !!user.avatar;  // мутация!
  return user;
};

3. Композиция функций - строим сложную логику из простых блоков

typescript
// ✅ Простые функции
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
);

🔄 Трансформеры как чистые функции

Трансформер - функция API → UI типы

typescript
// ✅ Чистый трансформер
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)
  }));
};

Композиция трансформеров

typescript
// ✅ Небольшие, переиспользуемые трансформеры
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);
};

🎯 Когда использовать функциональный подход

Всегда для трансформаций данных

typescript
// ✅ Трансформация 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
    };
  });
};

Переиспользуемые утилиты

typescript
// ✅ Чистые утилиты
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)
});

🚫 Избегайте мутаций и побочных эффектов

typescript
// ❌ Плохо - мутация и побочные эффекты
const processBadData = (data) => {
  data.forEach(item => {
    item.processed = true;  // мутация
    console.log(item);      // побочный эффект
  });
  return data;
};

// ✅ Хорошо - чистая функция
const processData = (data) => {
  return data.map(item => ({
    ...item,
    processed: true
  }));
};

📏 Правило применения

Используйте функциональный подход для:

  1. Трансформации данных - всегда
  2. Утилиты - форматирование, валидация, вычисления
  3. Фильтрация и сортировка - работа с массивами
  4. Композиция логики - строительство сложного из простого

Цель: Предсказуемый, тестируемый, переиспользуемый код без внешних зависимостей!


Примеры реализации

🔍 Поисковый компонент с i18n

typescript
// 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>
  );
};

📄 Страница проектов с локализацией

typescript
// 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>
  );
}

🔗 Схема с отношениями (без циклических ссылок)

typescript
// 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 };

📊 Переиспользуемые трансформации

typescript
// 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 паттерн на чистом JS

typescript
// ✅ Простая реализация 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));
};

🛠️ Отладка циклических ссылок

typescript
// 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"
  }
}

Заключение

✅ Ключевые принципы

  1. Next.js App Router - app/ только для роутинга, components/ на корневом уровне
  2. Локализация через [locale] - корневой динамический сегмент для i18n
  3. Колокация + автотипизация - основа архитектуры с Directus
  4. Типобезопасность - обязательно | null для nullable полей в схемах
  5. Архитектура "снизу вверх" - UI определяет требования к данным
  6. Трансформеры как коннекторы - между API типами и UI типами
  7. Чистые функции - предсказуемые, тестируемые, без побочных эффектов
  8. Функциональный подход - трансформации данных на ванильном JS
  9. Rule of Three - рефакторьте после третьего повторения в отдельной задаче
  10. Single Responsibility - один компонент = одна задача

🎯 Помните

"Лучшая архитектура та, которую легко понять, изменить и поддерживать. Функциональное программирование - это принципы, а не библиотеки. Чистые функции на ванильном JS лучше сложных абстракций."

🛠️ Инструменты контроля качества

bash
# Проверка типов
npm run type-check

# Поиск циклических зависимостей  
npm run check-circular

# Линтинг
npm run lint

# Все вместе
npm run quality-check

⚠️ Особенности работы с Directus + Next.js + i18n

  • Nullable поля: КРИТИЧНО указывать | null для всех nullable полей - это основа типобезопасности
  • Автотипизация: ReturnType на API запросах - единственный источник истины
  • Архитектура типов: UI типы → трансформеры → API типы (поток снизу вверх)
  • Чистые функции: Используйте для всех трансформаций данных - предсказуемо и тестируемо
  • Трансформеры: Всегда чистые функции без побочных эффектов и мутаций
  • Отношения: Используйте union типы number | Object | null для nullable отношений
  • Junction коллекции: Включайте в схему как обычные коллекции
  • Циклические ссылки: Активно мониторьте с помощью madge и TSC
  • Редакторы: При проблемах с типами перезапускайте TS Server
  • Локализация: Middleware + [locale] динамический сегмент
  • Структура папок: app/ для роутинга, components/ на корневом уровне
  • Rule of Three: Рефакторьте только после третьего повторения в отдельной задаче
  • Отдельные задачи: Никогда не смешивайте рефакторинг с feature/bug задачами
  • Композиция: Стройте сложную логику из простых, переиспользуемых функций

Начинайте просто, усложняйте только при необходимости! Типобезопасность - не компромисс. Чистые функции - основа надежной архитектуры.

Content is user-generated and unverified.
    Directus + React TSX: Руководство по архитектуре | Claude