javascript

Мои любимые паттерны для full-stack и frontend-проектов

  • вторник, 20 января 2026 г. в 00:00:06
https://habr.com/ru/articles/986622/

После работы над множеством фронтенд- и full-stack-проектов (в основном React + TypeScript + какой-нибудь сервер/бэкенд), я постоянно возвращаюсь к одному и тому же небольшому набору паттернов. Они добавляют структуру, снижают когнитивную нагрузку и делают кодовую базу поддерживаемой даже при росте.

Это не революционные идеи — просто прагматичные решения, которые хорошо работают в разных приложениях. Вот текущий набор, который я использую почти всегда.

1. React Query + фабрика ключей запросов (Query Key Factory)

Я использую TanStack Query (React Query) почти в каждом проекте. Чтобы ключи запросов были последовательными, читаемыми и удобными для рефакторинга, я следую подходу с фабрикой ключей.

Централизованные фабрики делают ключи предсказуемыми и дают отличное автодополнение:

export const bookingKeys = {
  all: ['bookings'] as const,
  detail: (id: string) => [...bookingKeys.all, id] as const,
  upcoming: (filters: { patientId?: string; page: number }) => [
    ...bookingKeys.all,
    'upcoming',
    filters,
  ] as const,
};

Использование в компонентах:

useQuery({
  queryKey: bookingKeys.detail(bookingId),
  queryFn: () => getBooking(bookingId),
});

Этот же файл становится единственным источником правды для инвалидаций. Можно определить карту инвалидаций и вызывать queryClient.invalidateQueries() из одного места:

// Тот же файл или соседний invalidations.ts
export const invalidateOnBookingChange = (queryClient: QueryClient) => {
  queryClient.invalidateQueries({
    queryKey: bookingKeys.all,
  });
  // Или более гранулярно:
  // queryClient.invalidateQueries({ queryKey: bookingKeys.upcoming(...) });
};

Централизация инвалидаций позволяет легко отслеживать и управлять свежестью данных в разных частях приложения (дашборд, списки, детальные страницы) без поисков по компонентам или мутациям. Одно изменение здесь распространяется везде последовательно.

2. Server Actions / Server Functions

Я почти никогда больше не пишу классические API-роуты. Вместо этого использую серверные экшены/функции, которые предоставляет фреймворк:

Это всё ещё по сути API-подобные эндпоинты — их можно вызывать напрямую (fetch или form POST), поэтому их обязательно нужно защищать аутентификацией, rate limiting, CSRF-токенами (где нужно) и валидацией ввода, как любой API.

Главные преимущества — меньше шаблонного кода и более целенаправленный подход:

  • Прямые вызовы функций с клиента → без ручного определения эндпоинтов

  • Автоматическая типобезопасность между клиентом и сервером

  • Удобная обработка ошибок и ревалидация

  • Колокейшн логики (форма → экшен → БД → ответ)

  • Лучшая интеграция с Suspense и transitions в React

Это не магия — просто убирает церемонии, сохраняя все обязанности по безопасности.

3. Управление правами / авторизацией с помощью CASL

В большинстве приложений рано или поздно нужна тонкая настройка прав. Я централизую эту логику с помощью CASL.

Определяем abilities один раз (часто на основе пользователя/сессии):

import {
  AbilityBuilder,
  createMongoAbility,
} from '@casl/ability';
export const defineAbilitiesFor = (
  user: User | null
) => {
  const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
  if (user?.role === 'admin') {
    can('manage', 'all');
  } else if (user) {
    can('read', 'Booking', {
      patientId: user.id,
    });
    can('create', 'Booking');
    can('update', 'Booking', {
      patientId: user.id,
    });
    cannot('delete', 'Booking');
    // явный запрет как пример
  }
  build();
};

Использование в сервисах через простые условия:

class BookingService {
  static async updateBooking(
    user: User,
    bookingId: string,
    data: Partial<Booking>
  ) {
    const ability = defineAbilitiesFor(user);
    const booking = await getBookingDetails(bookingId);
    // из queries
    if (!ability.can('update', booking)) {
      throw new Error('Нет прав на обновление этой брони');
    }
    // Продолжаем обновление...
    await updateBooking(bookingId, data);
  }
}

Или инлайн:

if (ability.can('read', subject('Booking', { ownerId: user.id }))) {
  // показываем чувствительные данные
}

Логика прав становится декларативной, тестируемой и не мешает бизнес-логике.

4. Лёгкий паттерн Repository / Query

Держу папку queries/ с простыми асинхронными функциями — чисто запросы к БД, и ничего больше:

export async function getBookingDetails(
  id: string
): Promise<Booking | null> {
  // Только запрос Drizzle/Prisma/etc.
  db.select().from(bookings).where(eq(bookings.id, id)).limit(1);
}
export async function updateBooking(
  id: string,
  data: Partial<Booking>
): Promise<void> {
  // Чистое обновление, без побочных эффектов
  await db.update(bookings).set(data).where(eq(bookings.id, id));
}

Жёсткие правила для этих функций:

  • Только доступ к данным (SELECT, INSERT, UPDATE, DELETE)

  • Никакой бизнес-логики

  • Никаких проверок авторизации

  • Никаких писем, очередей, внешних вызовов или побочных эффектов

  • Переиспользуемы из любых сервисов

Такой тонкий Data Access Layer делает смену ORM тривиальной (меняем только папку queries/), а сервисы остаются сосредоточены на оркестрации.

5. Optimistic Initial Data в React Query

Передаём данные из SSR/SSG как initialData, чтобы избежать вспышек загрузки:

useQuery({
  queryKey: bookingKeys.upcoming({
    page: 1,
  }),
  queryFn: () =>
    actions.bookings.getUpcomingBookings({
      page: 1,
    }),
  initialData: page === 1 ? initialUpcoming : undefined,
});

SSR сегодня — это must-have. Эра чистых SPA на Create React App закончилась. Команда React официально устарела CRA для новых проектов в начале 2025 и рекомендует фреймворки. Современные фреймворки с файловой маршрутизацией (Next.js, TanStack Start, Astro и др.) все имеют встроенный SSR/SSG. Использование initial data улучшает воспринимаемую скорость, уменьшает сдвиги и даёт пользователю что-то осмысленное сразу при загрузке страницы. Зачем это выбрасывать?

6. Container / Presentational (Smart / Dumb Components)

Я до сих пор люблю эту классическую сепарацию:

  • Presentational (dumb): только пропсы, без хуков/состояния/фетчинга → чистый UI, очень легко юнит-тестировать и понимать

  • Container (smart): управляет данными, состоянием, оркестрацией, передаёт пропсы вниз

Пример:

// Presentational – отлично для снапшот- и визуального тестирования
function BookingListView({
  bookings,
  isLoading,
  page,
  totalPages,
  onPageChange,
}) {
  if (isLoading) <Skeleton />;
  <>
    <ul>
      {bookings.map(b => (
        <BookingItem key={b.id} booking={b} />
      ))}
    </ul>
    <Pagination
      page={page}
      total={totalPages}
      onChange={onPageChange}
    />
  </>;
}
// Container
function BookingList() {
  const {
    bookings,
    isLoading,
    page,
    setPage,
    totalPages,
  } = useBookings();
  <BookingListView
    {...{
      bookings,
      isLoading,
      page,
      totalPages,
      onPageChange: setPage,
    }}
  />;
}

Dumb-компоненты становятся тривиальными для изолированного тестирования — не нужно мокать слои данных или авторизацию.

7. Custom Hook pattern

Как только компонент разрастается от состояния + фетчинга + пагинации + обработки ошибок → выносим в кастомный хук.

До: 50+ строк useQuery/useState/сессии внутри компонента.

После:

function PatientDashboard({
  initialUpcoming,
  initialPast,
}) {
  const {
    upcoming,
    past,
    isLoadingUpcoming,
    upcomingPage,
    setUpcomingPage,
    // ...
  } = useDashboard({
    initialUpcoming,
    initialPast,
  });
  (
    <div className="space-y-8">
      <booking={} isLoading={}/>
      <BookingList
        bookings={upcoming.data}
        page={upcomingPage}
        onPageChange={setUpcomingPage}
      />
      {/* ... */}
    </div>
  );
}

Правило: Если видишь useState, useEffect, useQuery (или похожие) сгруппированные вместе для одной цели → выноси в кастомный хук.

Компоненты остаются сосредоточены на рендеринге.

8. Strategy pattern (например, для сторонних провайдеров)

Когда может понадобиться сменить провайдера (Zoom → Google Meet → другие), скрываем реализацию за единым интерфейсом.

class MeetingService {
  static async createMeeting(
    input: CreateMeetingInput
  ) {
    // стратегия выбирается по конфигу / env
    activeMeetingProvider.create(input);
  }
}

Сервисы остаются чистыми и защищёнными от будущего.

Заключение

Эти паттерны появляются почти в каждом моём проекте. Вместе они дают:

  • Читаемый, хорошо организованный код

  • Меньше странных багов в логике (всё на своих местах)

  • Ниже стоимость поддержки (проще тесты, меньше сюрпризов)

  • Быстрее разработка фич (меньше времени на борьбу со структурой)

А самое важное: когда всё следует чётким конвенциям (и ты документируешь их в одном ARCHITECTURE.md или подобном), инструменты ИИ вроде Cursor или Copilot внезапно становятся намного точнее. Они сразу «понимают» паттерны и генерируют код, который действительно подходит — без 10 итераций подсказок, чтобы всё оказалось в правильных папках и в правильном формате.

Все классические инженерные плюшки — без лишнего усложнения.