Создание модуля React на основе функций: операции CRUD над постами с использованием TanStack Query
Организуйте приложение на React по функциям, а не по типам файлов, затем реализуйте полную функциональность CRUD для записей с использованием TanStack Query и axios, и вы увидите, в какой момент такая структура перестаёт быть масштабируемой.
Большинство проектов на React начинаются с папок, названных в соответствии с техническими ролями: components, hooks, services, types. Такая структура кажется упорядоченной, пока одно изменение не заставляет вам открывать четыре директории, чтобы изменить одну функцию. Архитектура, основанная на функциях, решает эту проблему путем группировки кода по областям, а не по типам файлов. В этой статье создается рабочий модуль для работы с постами с функциями создания, чтения, обновления и удаления на основе открытого API DummyJSON с использованием TypeScript, TanStack Query и axios, после чего честно рассматриваются проблемы, которые эта структура не решает.
Два вопроса, на которые отвечают архитектурные шаблоны
Архитектурные шаблоны React легче сравнивать, когда понимаешь, что они отвечают на два разных вопроса:
- Где находится код? На эти вопросы отвечают структуры, основанные на функциях, и подход Feature-Sliced Design (FSD). Они организуют код по функциям или областям.
Эти аспекты являются ортогональными, а не конкурирующими. Вы можете самостоятельно решить, куда помещать файлы, и отдельно определить, как они могут импортировать друг друга, поэтому в реальных репозиториях эти паттерны часто сочетаются. Организация по функциям — это самый простой ответ на первый вопрос и разумная отправная точка. Для более полного обзора структур см. это сравнение паттернов структуры папок в React.
Почему группировка по типу файлов терпит неудачу
Вот структура, с которой начинают многие проекты:
src/
├── components/ # all components
│ ├── PostList.tsx
│ └── PostForm.tsx
├── hooks/ # all hooks
│ ├── usePosts.ts
│ ├── useCreatePost.ts
│ └── useUpdatePost.ts
├── services/ # all API calls
│ └── postsApi.ts
└── types/ # all types
└── post.ts
Хотя приложение небольшое, его структура кажется организованной. Теперь представьте, что вам нужно изменить способ редактирования поста. Соответствующий код распределен между файлами components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts и types/post.ts. Каждое изменение требует длительного поиска в этой структуре, и ничто не указывает на то, какие файлы относятся к той или иной функциональности. По мере увеличения количества функций каждая папка превращается в длинный смешанный список, и становится всё сложнее понимать последствия внесения изменений.
Группировка по функциональности
Правило группировки по функциональности простое: все, что необходимо для реализации определенной функции — её компоненты, хуки, вызовы API и типы — должны находиться в одной папке. В данном примере речь идет о постах, поэтому логика отображения списка, просмотра деталей, создания, обновления и удаления постов находится в папке features/posts/:
src/
├── features/
│ ├── posts/
│ │ ├── api.ts # all posts CRUD
│ │ ├── usePosts.ts # list (Read)
│ │ ├── useCreatePost.ts # Create
│ │ ├── useUpdatePost.ts # Update
│ │ ├── useDeletePost.ts # Delete
│ │ ├── PostList.tsx # list + delete UI
│ │ ├── PostForm.tsx # shared create/edit form
│ │ └── types.ts # post types
│ └── comments/ # other domains grow with the same shape (e.g., comments)
├── shared/
│ ├── api/client.ts # axios instance (baseURL)
│ └── ui/ # shared components used across features
└── app/
├── providers.tsx # QueryClientProvider, etc.
└── main.tsx
В настоящее время изменение процесса редактирования подразумевает открытие одной папки. Когда появляется новый раздел, такой как комментарии, он получает собственную папку с аналогичной внутренней структурой. Код, который действительно обслуживает все функции, такой как HTTP-клиент или универсальные элементы интерфейса, помещается в папку shared/, а настройки приложения, такие как провайдеры и точка входа, — в папку app/.
Подключение функции публикации постов к реальному API
Для наглядности в примере используется DummyJSON — бесплатный REST-API, предоставляющий ресурс posts (информация о нем доступна в документации DummyJSON posts docs). Состояние сервера управляется с помощью TanStack Query.
Есть один важный момент, касающийся тестирования: DummyJSON лишь имитирует операции добавления, обновления и удаления. Он возвращает реалистичный ответ, включая новый идентификатор для созданных записей, но на сервере ничего не сохраняется. Поэтому после каждой операции происходит аннулирование соответствующей кэш-записи запроса для повторного загрузки данных. С использованием DummyJSON повторная загрузка покажет неизменные данные с сервера; в случае реального бэкенда изменения сохраняются и отображаются так, как ожидается.
Общий HTTP-клиент
Каждая функция использует один экземпляр axios. Он задаёт базовый URL и служит основой для реализации общих механизмов, встроенных в виде интерцепторов, таких как обработка ошибок или добавление заголовков автентификации. Здесь интерцептор представляет собой простой заменитель, который можно доработать.
// shared/api/client.ts
import axios from 'axios';
export const client = axios.create({
baseURL: 'https://dummyjson.com',
});
// cross-cutting concerns like common error handling go here as interceptors
client.interceptors.response.use(
(res) => res,
(error) => Promise.reject(error),
);
Типы для домена записей
Эта функция начинается с определения её типов. Post соответствует структуре, возвращаемой DummyJSON, PostsResponse оборачивает пагинационные списки, CreatePostInput описывает требования к созданию объекта, а UpdatePostInput использует Partial<Pick<...>>, что позволяет при обновлении изменять заголовок, тело или и то, и другое.
// features/posts/types.ts
export interface Post {
id: number;
title: string;
body: string;
tags: string[];
reactions: { likes: number; dislikes: number };
views: number;
userId: number;
}
export interface PostsResponse {
posts: Post[];
total: number;
skip: number;
limit: number;
}
export interface CreatePostInput {
title: string;
body: string;
userId: number;
}
export type UpdatePostInput = Partial<Pick<Post, 'title' | 'body'>>;
Один объект API для всех операций CRUD
Далее один объект postsApi объединяет пять вызовов: получение списка (с параметрами limit и skip для пагинации), получение по ID, создание, обновление и удаление. Каждый метод распаковывает res.data, поэтому вызывающие получают типизированные объекты домена вместо ответов от axios. Обратите внимание, что адресная строка для создания — /posts/add, которая характерна исключительно для DummyJSON.
// features/posts/api.ts — all of CRUD
import { client } from '@/shared/api/client';
import type {
Post,
PostsResponse,
CreatePostInput,
UpdatePostInput,
} from './types';
export const postsApi = {
// Read (list)
list: (limit = 10, skip = 0) =>
client
.get<PostsResponse>('/posts', { params: { limit, skip } })
.then((res) => res.data),
// Read (detail)
get: (id: number) =>
client.get<Post>(`/posts/${id}`).then((res) => res.data),
// Create
create: (body: CreatePostInput) =>
client.post<Post>('/posts/add', body).then((res) => res.data),
// Update
update: (id: number, body: UpdatePostInput) =>
client.put<Post>(`/posts/${id}`, body).then((res) => res.data),
// Delete
remove: (id: number) =>
client.delete<Post>(`/posts/${id}`).then((res) => res.data),
};
Хуки, оборачивающие каждую операцию
Каждая операция реализуется с помощью хука. Для чтения используется useQuery; хук списка кэширует результаты под ключом ['posts'].
// features/posts/usePosts.ts — Read
import { useQuery } from '@tanstack/react-query';
import { postsApi } from './api';
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: () => postsApi.list(),
});
}
Для записи используется useMutation. После успешного создания хук аннулирует действительность ключа ['posts'], в результате чего список перезагружается.
// features/posts/useCreatePost.ts — Create
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Для удаления применяется тот же подход — в качестве параметра мутации используется ID поста.
// features/posts/useDeletePost.ts — Delete
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsApi } from './api';
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => postsApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
Хук для обновления создается аналогичным образом: вызывается метод postsApi.update, после чего аннулируется действительность как ключа списка, так и ключа деталей соответствующего поста. По мере увеличения количества ключей внутри функции используется небольшой фабрикатор ключей, обеспечивающий последовательность и отсутствие опечаток при аннулировании действительности.
Компоненты, составляющие хуки
Компоненты остаются простыми, объединяясь с помощью хуков. Список отображает заголовки с кнопкой удаления для каждого элемента и показывает сообщение о загрузке во время обработки запроса.
// features/posts/PostList.tsx — list (Read) + delete (Delete)
import { usePosts } from './usePosts';
import { useDeletePost } from './useDeletePost';
export function PostList() {
const { data, isLoading } = usePosts();
const deletePost = useDeletePost();
if (isLoading) return <p>Loading…</p>;
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>
<span>{post.title}</span>
<button onClick={() => deletePost.mutate(post.id)}>Delete</button>
</li>
))}
</ul>
);
}
Форма сохраняет локальное состояние заголовка и тела и отправляет запрос на создание записи, отключая кнопку во время обработки запроса. В текущем виде она создает только новые записи; чтобы использовать её для редактирования, необходимо добавить опциональное свойство id, заранее заполнить поля и вызвать хук обновления при наличии id.
// features/posts/PostForm.tsx — shared create (Create) / edit (Update) form
import { useState } from 'react';
import { useCreatePost } from './useCreatePost';
export function PostForm() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const createPost = useCreatePost();
function handleSubmit(e: React.SubmitEvent<HTMLFormElement>) {
e.preventDefault();
createPost.mutate({ title, body, userId: 1 });
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Body" />
<button disabled={createPost.isPending}>
{createPost.isPending ? 'Saving…' : 'Create'}
</button>
</form>
);
}
Здесь важно не отдельные фрагменты кода, а их расположение. Типы, вызовы API, хуки и интерфейс для записей находятся внутри папки features/posts/, и ничто за пределами этой папки не должно знать о внутреннем устройстве записей.
Что даёт структура на основе функций
- Колокация. Весь код, отвечающий за определенную функциональность, находится вместе, поэтому для работы над публикациями достаточно открыть одну папку.
- Простое удаление. Чтобы удалить функцию, часто достаточно удалить ее папку вместе с одним-двумя маршрутами.
- Параллельная работа. Команды могут отвечать за отдельные функции, что снижает конфликты при слиянии кода и избыточные обсуждения.
- Четкое назначение. Названия верхнего уровня папок описывают функции приложения; это подход часто называется «кричащей архитектурой».
Полезной привычкой, усиливающей эти преимущества, является создание для каждой функции файла index.ts, который экспортирует только те элементы, которые могут понадобиться другим частям приложения. Другие модули затем импортируют данные из features/posts, вместо того чтобы обращаться к его внутренним файлам.
Где этот подход теряет эффективность
Разметка на основе функций — это хороший первый шаг, но по мере роста приложения возникают три проблемы.
Неконтролируемая зависимость между функциями
В реальных приложениях добавляются такие функции, как комментарии и профили. Если features/posts импортирует данные из features/comments, а позже comments — из posts, возникает циклическая зависимость, и структура проекта ничего не делает для её предотвращения. Структура папок указывает лишь на местоположение кода, но не определяет, кто может зависеть от кого. Частично это можно компенсировать с помощью правил проверки кода, таких как no-restricted-imports, но сама структура не предусматривает никаких правил.
Общая папка, превращающаяся в хранилище мусора
Когда разработчики не уверены, куда что следует поместить, материалы часто оказываются в папке shared/. Если это происходит достаточно часто, папка shared/ заполняется не связанными между собой фрагментами кода, на которые зависят все функции, что ведет к тому же беспорядку, что и при использовании типов, но уже в одной директории.
Отсутствие понятия слоев
Некоторые функции являются фундаментальными, а другие представляют собой более сложные комбинации нескольких функций, например страницу дашборда. Плоская структура папки features/ не позволяет отразить такую иерархию, поэтому отсутствует четкий структурный признак, указывающий, в каком направлении должны идти импорты.
Короче говоря, архитектура, основанная на функциях, учитывает правильный инстинкт группировки по областям, но не предусматривает способа определения взаимосвязей между функциями. Методологии вроде Feature-Sliced Design решают эту проблему путем введения стандартизированных слоев и правила, согласно которому импорты могут направляться только вниз по иерархии.
Основные выводы
- Разделяйте два вопроса: где находится код и куда указывают зависимости.
- Группировка по типу файла приводит к тому, что одна функциональность распределяется по множеству папок, что скрывает её границы.
- Папка с функциональностью объединяет компоненты, хуки, вызовы API и типы, что упрощает навигацию, удаление элементов и определение ответственных сотрудников.
- Ограничьте использование папки
shared/исключительно кодом, который действительно используется во всех частях проекта, и предоставляйте доступ к каждой функциональности через небольшую публичную точку входа. - Учитывайте ограничения данной схемы: без чётких правил со временем появятся импорты между функциональностями, перегруженная папка shared и отсутствие чётких слоев структуры.