Мои любимые паттерны для full-stack и frontend-проектов
- вторник, 20 января 2026 г. в 00:00:06
После работы над множеством фронтенд- и full-stack-проектов (в основном React + TypeScript + какой-нибудь сервер/бэкенд), я постоянно возвращаюсь к одному и тому же небольшому набору паттернов. Они добавляют структуру, снижают когнитивную нагрузку и делают кодовую базу поддерживаемой даже при росте.
Это не революционные идеи — просто прагматичные решения, которые хорошо работают в разных приложениях. Вот текущий набор, который я использую почти всегда.
Я использую 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(...) });
};
Централизация инвалидаций позволяет легко отслеживать и управлять свежестью данных в разных частях приложения (дашборд, списки, детальные страницы) без поисков по компонентам или мутациям. Одно изменение здесь распространяется везде последовательно.
Я почти никогда больше не пишу классические API-роуты. Вместо этого использую серверные экшены/функции, которые предоставляет фреймворк:
Это всё ещё по сути API-подобные эндпоинты — их можно вызывать напрямую (fetch или form POST), поэтому их обязательно нужно защищать аутентификацией, rate limiting, CSRF-токенами (где нужно) и валидацией ввода, как любой API.
Главные преимущества — меньше шаблонного кода и более целенаправленный подход:
Прямые вызовы функций с клиента → без ручного определения эндпоинтов
Автоматическая типобезопасность между клиентом и сервером
Удобная обработка ошибок и ревалидация
Колокейшн логики (форма → экшен → БД → ответ)
Лучшая интеграция с Suspense и transitions в React
Это не магия — просто убирает церемонии, сохраняя все обязанности по безопасности.
В большинстве приложений рано или поздно нужна тонкая настройка прав. Я централизую эту логику с помощью 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 }))) {
// показываем чувствительные данные
}
Логика прав становится декларативной, тестируемой и не мешает бизнес-логике.
Держу папку 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/), а сервисы остаются сосредоточены на оркестрации.
Передаём данные из 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 улучшает воспринимаемую скорость, уменьшает сдвиги и даёт пользователю что-то осмысленное сразу при загрузке страницы. Зачем это выбрасывать?
Я до сих пор люблю эту классическую сепарацию:
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-компоненты становятся тривиальными для изолированного тестирования — не нужно мокать слои данных или авторизацию.
Как только компонент разрастается от состояния + фетчинга + пагинации + обработки ошибок → выносим в кастомный хук.
До: 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 (или похожие) сгруппированные вместе для одной цели → выноси в кастомный хук.
Компоненты остаются сосредоточены на рендеринге.
Когда может понадобиться сменить провайдера (Zoom → Google Meet → другие), скрываем реализацию за единым интерфейсом.
class MeetingService {
static async createMeeting(
input: CreateMeetingInput
) {
// стратегия выбирается по конфигу / env
activeMeetingProvider.create(input);
}
}
Сервисы остаются чистыми и защищёнными от будущего.
Эти паттерны появляются почти в каждом моём проекте. Вместе они дают:
Читаемый, хорошо организованный код
Меньше странных багов в логике (всё на своих местах)
Ниже стоимость поддержки (проще тесты, меньше сюрпризов)
Быстрее разработка фич (меньше времени на борьбу со структурой)
А самое важное: когда всё следует чётким конвенциям (и ты документируешь их в одном ARCHITECTURE.md или подобном), инструменты ИИ вроде Cursor или Copilot внезапно становятся намного точнее. Они сразу «понимают» паттерны и генерируют код, который действительно подходит — без 10 итераций подсказок, чтобы всё оказалось в правильных папках и в правильном формате.
Все классические инженерные плюшки — без лишнего усложнения.