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