Головна / Статті

Створення модуля React на основі функцій: CRUD для дописів за допомогою TanStack Query

Організуйте React-додаток за функціями, а не за типом файлів, потім реалізуйте повну систему CRUD для записів за допомогою TanStack Query та axios, і подивіться, де саме ця схема перестає ефективно масштабуватися.

Видавець: REACTAPP.TOP10 хв читання2263 слів

Джерела: React Architecture — The Feature-Based Pattern

Більшість проєктів на React починаються з папок, названих за технічними ролями: components, hooks, services, types. Така структура здається охайною, поки одна-єдина зміна не змушує вам відкривати чотири директорії, щоб внести зміни до однієї функціональності. Архітектура, заснована на функціях, вирішує цю проблему, групуючи код за сферою діяльності, а не за типом файлу. У цій статті створюється працездатний модуль posts із функціями create, read, update та delete для використання з відкритим API DummyJSON, з використанням TypeScript, TanStack Query та axios, а потім чесно розглядаються проблеми, які ця структура не вирішує.

Два питання, на які відповідають архітектурні шаблони

Архітектурні шаблони React легше порівнювати, якщо усвідомити, що вони стосуються двох різних питань:

  • Де знаходиться код? Структури, засновані на функціях, та Feature-Sliced Design (FSD) відповідають на це питання. Вони організовують код за функціями чи сферами діяльності.
  • У якому напрямку спрямовані залежності? Чиста архітектура дає відповідь на це питання. Чиста бізнес-логіка знаходиться в центрі, а зовнішні шари, такі як компоненти React та клієнти API, залежать від неї, а не навпаки.
  • Ці аспекти є ортогональними, а не конкуруючими. Ви можете вирішити, де розміщувати файли, та окремо визначити, як вони можуть імпортувати один одного, саме тому в реальних репозиторіях ці патерни часто поєднуються. Організація за функціями є найпростішою відповіддю на перше питання та розумною відправною точкою. Для більш детального огляду структур дивіться це порівняння патернів структури папок у 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 лише імітує операції додавання, оновлення та видалення. Він повертає реалістичну відповідь, включаючи новий ID для створених записів, але нічого не зберігається на сервері. Тому після кожної зміни відповідний кеш запитів скасовується, щоб ініціювати повторне завантаження даних. За допомогою 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 та відсутні шари.