После работы над множеством фронтенд- и 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 итераций подсказок, чтобы всё оказалось в правильных папках и в правильном формате.

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

Комментарии (7)


  1. js2me
    19.01.2026 23:28

    Не соглашусь с автором оригинальной статьи что SSR это must have только потому что классические SPA "устарели"

    До сих пор очень много делают SPAшек без SSR решая огромное количество тяжёлых задач.

    На счёт фабричных ключей для танстак кверей соглашусь - вещь полезная, но в идеале конечно это через кодогенератор разруливать