Галоўная / Артыкулы

Стварэнне модуля 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
    

    Змена праграмы рэдагавання зараз значыць ачыць адзін папаку. Калі з’яўляецца новы домэн, напрыклад comments, ён атрымвае сваю сабэйшую папаку з тым самым внутрашнім структурам. Код, який фактычна служыць для кожнай функцыялкі, такі як HTTP-кліент чыстае прымітівы UI, знаходзится ў 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>
      );
    }
    

    Форма зберагчыць локальны стан для заглавя і тэкста і надасі запчынку create mutation, выключаючы кнопку пакуль запрос у виконанні. У такой форме можна ствараць толькі пасты; каб ўжываць яе для рэдагавання, трэба прыймаць необов’язковыя параметры id, запрацавляць падставы ў полях і вызываць хук update, калі є 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 і відсутныя шары.