Strona główna / Artykuły

Tworzenie modułu React opartego na funkcjach: operacje CRUD dla postów z TanStack Query

Zorganizuj aplikację React według funkcji, a nie typu pliku, następnie zaimplementuj pełne operacje CRUD dla postów za pomocą TanStack Query i axios, i sprawdź dokładnie, w którym momencie ten model przestaje być skalowalny.

Wydawca: REACTAPP.TOP10 min czytania2263 słów

Źródła: React Architecture — The Feature-Based Pattern

Większość projektów React zaczyna się od folderów nazwanych od roli technicznych: components, hooks, services, types. Taka struktura wydaje się uporządkowana, dopóki jedna zmiana nie zmusza do otwarcia czterech katalogów, aby edytować jedną funkcjonalność. Architektura oparta na funkcjonalnościach rozwiązuje ten problem, grupując kod według domeny zamiast według typu pliku. Ten artykuł tworzy działający moduł posts z funkcjami create, read, update i delete przy użyciu otwartej API DummyJSON, wraz z TypeScript, TanStack Query i axios, a następnie szczerze omawia problemy, których ta struktura nie rozwiązuje.

Dwie pytania, na które odpowiadają wzorce architektury

Wzorce architektury React są łatwiejsze do porównania, gdy zauważymy, że odpowiadają na dwa różne pytania:

  • Gdzie znajduje się kod? Na to odpowiadają struktury oparte na funkcjonalnościach oraz Feature-Sliced Design (FSD). One organizują kod według funkcjonalności lub domeny.
  • W którą stronę wskazują zależności? Czysta architektura daje na to odpowiedź. W centrum znajduje się czysta logika biznesowa, a warstwy zewnętrzne, takie jak komponenty React i klienci API, od niej zależą, a nie odwrotnie.
  • Te kwestie są ortogonalne, a nie konkurencyjne. Możesz zdecydować, gdzie umieścić pliki, a oddzielnie określić, w jaki sposób mogą one się wzajemnie importować, dlatego w rzeczywistych repozytoriach często łączy się te wzorce. Organizacja oparta na funkcjach to najprostsza odpowiedź na pierwsze pytanie i rozsądny punkt wyjścia. Aby zapoznać się z szerszym przeglądem struktur, zobacz tę porównanie wzorców struktury folderów w React.

    Dlaczego grupowanie według typu pliku nie działa

    Oto struktura oparta na typie, od której zaczyna się wiele projektów:

    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
    

    Mimo że aplikacja jest niewielka, jej struktura wydaje się uporządkowana. Teraz wyobraźmy sobie, że musimy zmienić sposób edycji posta. Odpowiedni kod jest rozproszony między plikami components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts oraz types/post.ts. Każda zmiana wymaga przeglądania całej struktury, a sama organizacja nie wskazuje, które pliki odpowiadają za jaką funkcjonalność. W miarę dodawania nowych funkcji każda folder staje się długim, mieszanką różnych elementów, co utrudnia zrozumienie wpływu dokonywanych zmian.

    Grupowanie według funkcjonalności

    Zasada oparta na funkcjonalnościach jest prosta: wszystko, czego potrzebuje dana funkcjonalność – jej komponenty, haki, wywołania API oraz typy – umieszcza się w jednym folderze. W tym przykładzie obszarem działania są posty, więc logika listowania, szczegółów, tworzenia, aktualizacji i usuwania znajduje się w folderze 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
    

    Zmiana procesu edycji oznacza obecnie otwarcie jednej folderu. Gdy pojawia się nowy element, np. komentarze, otrzymuje on własny folder o identycznej strukturze wewnętrznej. Kod, który faktycznie służy wszystkim funkcjom, takim jak klient HTTP czy ogólne elementy interfejsu użytkownika, znajduje się w katalogu shared/, natomiast elementy konfiguracji aplikacji, takie jak dostawcy i punkt wejścia, znajdują się w katalogu app/.

    Podłączenie funkcji publikacji do rzeczywistego API

    Aby struktura była konkretna, przykład korzysta z DummyJSON – bezpłatnego API REST, które udostępnia zasób publikacji (opisany w dokumentacji DummyJSON posts docs). Stan serwera jest zarządzany za pomocą TanStack Query.

    Jedna kwestia ma znaczenie podczas testowania: DummyJSON symuluje jedynie operacje dodawania, aktualizacji i usuwania. Zwraca realistyczną odpowiedź, w tym nowy ID dla utworzonych wpisów, ale nic nie jest przechowywane na serwerze. Dlatego przy każdej mutacji przykład unieważnia odpowiedni cache zapytań, aby wywołać ponowne pobranie danych. W przypadku DummyJSON to ponowne pobranie pokaże niezmienione dane serwera; natomiast w przypadku rzeczywistego backendu zmiany pozostają i pojawiają się zgodnie z oczekiwaniami.

    Wspólny klient HTTP

    Każda funkcjonalność korzysta z jednej instancji axios. Określa ona adres bazowy i stanowi naturalne miejsce dla zachowań wspólnych dla wielu funkcji, implementowanych jako interceptorzy – takie jak ogólne zarządzanie błędami czy dodawanie nagłówków autoryzacji. Interceptor w tym przypadku to tylko placeholder, który można rozszerzyć.

    // 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),
    );
    

    Typy dla domeny wpisów

    Ta funkcjonalność zaczyna się od jej typów. Post odpowiada strukturze zwracanej przez DummyJSON, PostsResponse zawiera paginowane listy, CreatePostInput opisuje wymagania dotyczące tworzenia, a UpdatePostInput wykorzystuje Partial<Pick<...>>, dzięki czemu aktualizacja może zmienić tytuł, treść lub oba elementy.

    // 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'>>;
    

    Jeden obiekt API do wszystkich operacji CRUD

    Następnie pojedynczy obiekt postsApi grupuje pięć wywołań: listowanie (z limit i skip do paginacji), pobieranie według ID, tworzenie, aktualizację i usuwanie. Każda metoda odsłania res.data, więc użytkownicy otrzymują typowane obiekty domeny zamiast odpowiedzi od axios. Należy zauważyć, że endpoint do tworzenia to /posts/add, który jest specyficzny dla 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),
    };
    

    Haki, które otaczają każdą operację

    Każda operacja jest dostępna poprzez hook. Dla odczytu używa się useQuery; hook listy przechowuje wyniki pod kluczem ['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(),
      });
    }
    

    Dla zapisu używa się useMutation. Po pomyślnym utworzeniu wpisu hook unieważnia ['posts'], dzięki czemu lista zostaje ponownie załadowana.

    // 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'] });
        },
      });
    }
    

    Operacja usuwania follows the same pattern, przy czym jako zmienna mutacji używa się ID wpisu.

    // 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'] });
        },
      });
    }
    

    Hook aktualizacji jest budowany w ten sam sposób: wywołuje się postsApi.update i unieważnia zarówno klucz listy, jak i klucz szczegółów danego wpisu. W miarę wzrostu liczby kluczy mała funkcja generująca klucze zapytania wewnątrz modułu (na przykład obiekt tworzący klucze listy i szczegółów) zapewnia spójność i brak błędów pisowni przy unieważnianiu.

    Komponenty składające się na hooks

    Komponenty pozostają proste i łączą się za pomocą haczyków. Lista wyświetla tytuły wraz z przyciskiem usuwania przy każdym elemencie oraz komunikat ładowania, dopóki zapytanie nie zostanie zrealizowane.

    // 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>
      );
    }
    

    Formularz przechowuje lokalny stan tytułu i treści oraz wysyła żądanie utworzenia nowego wpisu, dezaktywując przycisk podczas trwania żądania. W obecnej formie tworzy on tylko nowe wpisy; aby móc go wykorzystać do edycji, należałoby przyjąć opcjonalną właściwość id, wcześniej wypełnić pola i wywołać haczyk aktualizacji, gdy id jest dostępny.

    // 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>
      );
    }
    

    Tutaj ważna nie jest sama część kodu, lecz jej lokalizacja. Typy, wywołania API, haczyki oraz interfejs użytkownika dla wpisów znajdują się wszystkie w katalogu features/posts/, a nic poza tym folderem nie musi wiedzieć, jak wpisy funkcjonują wewnętrznie.

    Co daje struktura oparta na funkcjach

    • Kolokacja. Wszystki kod odpowiadający za określoną funkcjonalność znajduje się razem, więc praca nad postami oznacza otwarcie jednej folderu.
    • Latwe usunięcie. Usunięcie funkcji często polega na usunięciu jej folderu oraz jednej lub dwóch tras.
    • Praca równoległa. Zespoły mogą być odpowiedzialne za poszczególne funkcje, co zmniejsza konflikty przy łączeniu kodu i zbędny hałas podczas przeglądania.
    • Czytelna intencja. Nazwy folderów najwyższego poziomu opisują to, co robi aplikacja – koncepcja ta jest często nazywana architekturą krzyczącą.

    Cennym nawykiem, który wzmacnia te zalety, jest nadanie każdej funkcji pliku index.ts, który eksportuje tylko to, co może być wykorzystane przez inne części aplikacji. Wtedy pozostałe moduły importują dane z features/posts, zamiast korzystać bezpośrednio z jego wewnętrznych plików.

    Gdzie ten model przestaje się sprawdzać

    Rozkład oparty na funkcjach to dobry pierwszy krok, ale w miarę rozwoju aplikacji pojawiają się trzy problemy.

    Niekontrolowane zależności między funkcjami

    Rzeczywiste aplikacje dodają takie funkcje jak komentarze i profile. Jeśli features/posts importuje dane z features/comments, a później comments importuje dane z posts, powstaje zależność cykliczna, a struktura nie zapobiega temu w żaden sposób. Układ folderów wskazuje tylko, gdzie znajduje się kod, a nie kto może od kogo zależeć. Można to częściowo skompensować regułami lintingowymi, takimi jak no-restricted-imports, ale sam wzorzec nie dostarcza żadnych reguł.

    Wspólny folder, który staje się schowkiem na śmieci

    Gdy programiści nie są pewni, gdzie coś powinno się znaleźć, zazwyczaj trafia to do katalogu shared/. Jeśli tak się dzieje często, katalog ten wypełnia się niespowiązanym kodem o niejasnej przynależności, od którego zależy każda funkcjonalność, co powoduje ponowne pojawienie się pierwotnego bałaganu opartego na typach w jednym katalogu.

    Część funkcjonalności ma charakter podstawowy, a inne stanowią bardziej złożone kombinacje kilku funkcji, takie jak strona panelu sterowania. Płaski katalog features/ nie może oddać tej hierarchii, więc nie ma żadnego strukturalnego wskazówki co do kierunku, w którym powinny iść importy.

    Krótko mówiąc, architektura oparta na funkcjonalnościach odzwierciedla właściwy instynkt grupowania elementów według domeny, ale nie ma rozwiązania na pytanie, jak te funkcjonalności powinny ze sobą współpracować. Metodyki takie jak Feature-Sliced Design rozwiązują ten problem poprzez dodanie standaryzowanych warstw oraz zasady, zgodnie z którą importy mogą wskazywać jedynie w dół.

    Główne wnioski

    • Rozdziel dwie kwestie: miejsce przechowywania kodu oraz kierunek, w którym skierowane są zależności.
    • Grupowanie według typu pliku rozpraszają jedną funkcjonalność po wielu folderach i ukrywa jej granice.
    • Folder funkcjonalności umieszcza obok siebie komponenty, hooki, wywołania API oraz typy, co ułatwia nawigację, usuwanie i określenie odpowiedzialności zespołu.
    • Zachowaj katalog shared/ wyłącznie dla kodu rzeczywiście wspólnego dla wszystkich funkcjonalności i udostępniaj każdą z nich za pośrednictwem małego publicznego punktu wejścia.
    • Planuj ograniczenia tego wzorca: bez wyraźnych zasad pojawią się importy między funkcjonalnościami, przeciążony katalog shared oraz brakujące warstwy.

    Literatura pokrewna

  • React bez meta-frameworka: ukryte koszty backendu w Pythonie — Dlaczego React w połączeniu z Vite na backendzie nieopartym na JavaScriptu omija dyskusję o Next.js, jakie funkcje frameworka musisz odtworzyć i jak zarządzać połączeniami między komponentami.
  • Enumy i interfejsy w TypeScript jako elementy budulcowe projektowania na niskim poziomie — Jak enumy w TypeScript eliminują błędy związane z „magicznymi” łańcuchami tekstowymi, kiedy wybrać typ lub interfejs oraz jak interfejsy, implementacje i typowanie strukturalne definiują umowy w projektowaniu na niskim poziomie.
  • Zustand vs TanStack Query Boundaries w monorepo Next.js w realtime — Poznaj jasną zasadę zarządzania stanem interfejsu użytkownika, stanem serwera oraz zdarzeniami w czasie rzeczywistym w współpracy w monorepo Next.js przy użyciu Zustand, TanStack Query i sokietów.
  • Budownik zapytań oparty na niezmiennych i zamknięciach dla Angular HttpClient — Zastąp powtarzający się kod HttpParams prostym budownikiem zapytań opartym na zamknięciach, połączonym za pomocą InjectionToken, który pozostaje niezmienny, typowany i łatwy do testowania.
  • Ile kosztuje użycie effect-fetching i co zmienia TanStack Query — Dlaczego pobieranie danych w oparciu o effect powoduje duplikowane żądania, zbędny kod szablonowy oraz przestarzałe zamykania w React, oraz jak klucze, staleTime i mechanizm unieważniania w TanStack Query rozwiązują te problemy.
  • Przewidywalna baza React z TypeScriptem, Zustand i usługami z type-mi — Mały szkielet aplikacji React, TypeScript i Zustand, który oddziela magazyny danych, usługi API z type-mi oraz komponenty, wraz z zasadami dotyczącymi struktury, stanu asynchronicznego i testowania.