Startseite / Artikel

Erstellung eines featurebasierten React-Moduls: Posts CRUD mit TanStack Query

Organisieren Sie eine React-Anwendung nach Funktionen und nicht nach Dateitypen, implementieren Sie anschließend eine vollständige CRUD-Funktionalität für Beiträge mit TanStack Query und axios und erkennen Sie genau, ab wann dieses Muster nicht mehr skalierbar ist.

Verlag: REACTAPP.TOP10 Min. Lesezeit2263 Wörter

Quellen: React Architecture — The Feature-Based Pattern

Die meisten React-Projekte beginnen mit Ordnern, die nach technischen Rollen benannt sind: components, hooks, services, types. Diese Struktur wirkt ordentlich – bis eine einzige Änderung dazu führt, dass man vier Verzeichnisse öffnen muss, um an einer einzigen Funktion zu arbeiten. Eine auf Features basierende Architektur löst dieses Problem, indem sie den Code nach Domänen statt nach Dateitypen gruppiert. In diesem Artikel wird ein funktionsfähiges Posts-Modul mit den Operationen create, read, update und delete unter Verwendung der DummyJSON-Open-API entwickelt, wobei TypeScript, TanStack Query und axios eingesetzt werden. Anschließend wird ehrlich darauf hingewiesen, welche Probleme diese Struktur nicht löst.

Zwei Fragen, auf die Architekturmuster antworten

React-Architekturmuster lassen sich leichter vergleichen, sobald man erkennt, dass sie zwei unterschiedliche Fragen beantworten:

  • Wo befindet sich der Code? Auf diese Frage antworten featurebasierte Strukturen sowie Feature-Sliced Design (FSD). Sie ordnen den Code nach Funktionen oder Domänen.
  • In welche Richtung weisen die Abhängigkeiten? Die Saubere Architektur gibt darauf eine Antwort. Die reine Geschäftslogik befindet sich im Zentrum, während die äußeren Schichten wie React-Komponenten und API-Cliente von ihr abhängig sind – und nicht umgekehrt.
  • Diese Aspekte sind orthogonal und nicht miteinander konkurrierend. Man kann entscheiden, wohin die Dateien gelegt werden, und unabhängig davon festlegen, wie sie gegenseitig importiert werden können – weshalb diese Muster in echten Repositorien oft kombiniert werden. Eine organisatorische Struktur nach Funktionen ist die einfachste Antwort auf die erste Frage und ein sinnvoller Ausgangspunkt. Für eine umfassendere Übersicht über verschiedene Layouts siehe diese Vergleichstabelle der React-Ordnerstruktur-Muster.

    Warum die Gruppierung nach Dateitypen nicht funktioniert

    Hier ist das auf Typen basierende Layout, mit dem viele Projekte beginnen:

    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
    

    Auch wenn die App noch klein ist, wirkt sie hier organisiert. Stellen Sie sich nun vor, Sie müssten die Funktionsweise der Bearbeitung eines Beitrags ändern. Der relevante Code ist über components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts und types/post.ts verteilt. Jede Änderung erfordert dann einen Durchgang durch die gesamte Struktur, und nichts in der Organisation macht deutlich, zu welcher Funktion welche Dateien gehören. Mit zunehmender Anzahl an Funktionalitäten verwandelt sich jede Ordnerstruktur in eine lange, gemischte Liste, wodurch es immer schwieriger wird, die Auswirkungen einer Änderung einzuschätzen.

    Gruppierung nach Funktionalität

    Die Regel basierend auf Funktionalitäten ist einfach: Legen Sie alles, was eine Funktion benötigt – ihre Komponenten, Hooks, API-Aufrufe sowie Typen – in einen Ordner. In diesem Beispiel betrifft die Funktionalität Beiträge, weshalb die Logik für Liste anzeigen, Detailansicht, Erstellen, Aktualisieren und Löschen alle unter features/posts/ abgelegt wird:

    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
    

    Derzeit bedeutet die Änderung des Bearbeitungsflusses das Öffnen eines Ordners. Wenn ein neuer Bereich wie Kommentare hinzukommt, erhält er einen eigenen Ordner mit derselben internen Struktur. Code, der tatsächlich alle Funktionen unterstützt – wie beispielsweise der HTTP-Client oder allgemeine UI-Primitiven – wird in shared/ abgelegt, während die Anwendungsverbindungen, wie z. B. Provider und der Eingangspunkt, in app/ untergebracht werden.

    Vereinigung der Posts-Funktion mit einer echten API

    Um die Struktur konkret zu halten, verwendet das Beispiel DummyJSON, eine kostenlose REST-API, die eine Posts-Ressource bereitstellt (dokumentiert unter DummyJSON posts docs). Der Serverzustand wird mit TanStack Query verwaltet.

    Eine wichtige Einschränkung bei den Tests: DummyJSON simuliert lediglich die Operationen hinzufügen, aktualisieren und löschen. Es gibt eine realistische Antwort zurück, einschließlich einer neuen ID für neu erstellte Beiträge, doch auf dem Server wird nichts gespeichert. Daher löscht das Beispiel nach jeder Änderung den entsprechenden Abfragecache, um eine erneute Abfrage auszulösen. Bei DummyJSON zeigt diese erneute Abfrage die unveränderten Serverdaten; bei einem echten Backend bleiben die Änderungen bestehen und erscheinen wie erwartet.

    Der gemeinsame HTTP-Client

    Jede Funktion verwendet eine einzige axios-Instanz. Sie legt die Basis-URL fest und dient als natürlicher Ort für durchgängiges Verhalten, das als Interceptor umgesetzt wird – beispielsweise für die gemeinsame Fehlerbehandlung oder das Hinzufügen von Auth-Headern. Der Interceptor hier ist ein Platzhalter, den man erweitern kann.

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

    Typen für das Posts-Domänengebiet

    Diese Funktion beginnt mit ihren Typen. Post spiegelt die Struktur wider, die DummyJSON zurückgibt, PostsResponse umschließt paginierte Listen, CreatePostInput beschreibt die Anforderungen für eine Erstellung und UpdatePostInput verwendet Partial<Pick<...>>, sodass eine Aktualisierung den Titel, den Inhalt oder beides ändern kann.

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

    Ein API-Objekt für alle CRUD-Operationen

    Anschließend gruppiert ein einzelnes postsApi-Objekt die fünf Aufrufe: Liste anlegen (mit limit und skip zur Paginierung), Eintrag nach ID abrufen, erstellen, aktualisieren und löschen. Jede Methode entpackt res.data, sodass die Aufrufer typisierte Domänenobjekte statt Axios-Antworten erhalten. Beachten Sie, dass der Endpunkt für die Erstellung /posts/add ist, was spezifisch für DummyJSON gilt.

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

    Hooks, die jede Operation umschließen

    Jede Operation wird über einen Hook bereitgestellt. Lesen erfolgt mit useQuery; der List-Hook speichert die Ergebnisse unter dem Schlüssel ['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(),
      });
    }
    

    Schreiben erfolgt mit useMutation. Nach einem erfolgreichen Erstellen wird der Hook ['posts'] ungültig gemacht, sodass jede geladene Liste neu abgerufen wird.

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

    Das Löschen folgt demselben Muster und verwendet die Post-ID als Mutation-Variable.

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

    Auch ein Update-Hook wird auf dieselbe Weise erstellt: Es wird postsApi.update aufgerufen und sowohl der List-Schlüssel als auch der Detail-Schlüssel der betroffenen Post ungültig gemacht. Da die Anzahl der Schlüssel zunimmt, sorgt eine kleine Query-Schlüssel-Factory innerhalb der Funktion dafür, dass die Ungültigmachung konsistent und fehlerfrei bleibt.

    Komponenten, die die Hooks bilden

    Die Komponenten bleiben schlank und kombinieren einfach Hooks miteinander. Die Liste zeigt die Titel zusammen mit einem Lösch-Button pro Eintrag an und gibt eine Ladeanzeige aus, solange die Anfrage noch aussteht.

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

    Das Formular speichert den lokalen Zustand für Titel und Inhalt und sendet eine Create-Mutation ab, wobei der Button während des Übertragungsprozesses deaktiviert wird. So wie es geschrieben ist, werden nur Beiträge erstellt; um es auch zum Bearbeiten zu verwenden, müsste man ein optionales id-Attribut akzeptieren, die Felder vorfüllen und bei Vorhandensein eines id-Werts den Update-Hook aufrufen.

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

    Wichtig ist hier nicht die einzelne Struktur, sondern deren Lage. Typen, API-Aufrufe, Hooks sowie die Benutzeroberfläche für Beiträge befinden sich alle innerhalb von features/posts/, und nichts außerhalb dieses Verzeichnisses muss wissen, wie Beiträge intern funktionieren.

    Was die auf Features basierende Struktur bietet

    • Kolokation. Der gesamte Code für eine Funktion befindet sich zusammen, sodass die Arbeit an Beiträgen bedeutet, nur einen Ordner zu öffnen.
    • Einfache Entfernung. Das Löschen einer Funktion bedeutet oft auch das Löschen ihres Ordners sowie das Entfernen von ein oder zwei Routen.
    • Parallelarbeit. Teams können sich Funktionen zuweisen, was Kollisionskonflikte bei der Zusammenführung sowie überflüssige Überprüfungen verringert.
    • Lesebare Absicht. Die Namen der obersten Ordner beschreiben, was die Anwendung tut – ein Konzept, das oft als „Screaming Architecture“ bezeichnet wird.

    Eine nützliche Gewohnheit, die diese Vorteile verstärkt, ist es, jeder Funktion einen index.ts zu geben, der nur das exportiert, was andere Teile der Anwendung verwenden können. Andere Module importieren dann von features/posts anstelle direkt in die internen Dateien zu greifen.

    Wo das Muster nicht mehr skalierbar ist

    Ein layoutbasiertes Vorgehen ist ein guter erster Schritt, doch mit dem Wachstum der Anwendung treten drei Probleme auf.

    Unkontrollierte Abhängigkeiten zwischen Funktionen

    In echten Anwendungen werden Funktionen wie Kommentare und Profile hinzugefügt. Wenn features/posts von features/comments importiert wird und comments später wieder von posts, entsteht eine zyklische Abhängigkeit, und die Struktur verhindert dies nicht. Die Ordnerstruktur gibt lediglich an, wo der Code liegt, nicht jedoch, wer von wem abhängen darf. Man kann dies teilweise mit Lint-Regeln wie no-restricted-imports ausgleichen, doch das Muster selbst bietet keine solchen Regeln.

    Ein gemeinsamer Ordner, der zu einem Schrottkorb wird

    Wenn Entwickler unsicher sind, wohin etwas gehört, landet es oft in shared/. Wenn das häufig genug passiert, füllt sich shared/ mit unzusammenhängendem, locker verwaltetem Code, von dem jede Funktion abhängt – was die ursprüngliche, typbasierte Verwirrung in einem einzigen Verzeichnis erneut hervorruft.

    Keine Vorstellung von Schichten

    Manche Funktionen sind grundlegend, während andere aus mehreren Funktionen zusammengesetzte, höherwertige Strukturen darstellen, wie beispielsweise eine Dashboard-Seite. Ein einfaches features/-Verzeichnis kann diese Hierarchie nicht darstellen, wodurch es keine strukturellen Anhaltspunkte gibt, in welche Richtung Importe erfolgen sollten.

    Kurz gesagt: Die auf Funktionen basierende Architektur trifft mit der Gruppierung nach Domänen den richtigen Instinkt, hat aber keine Lösung dafür, wie Funktionen miteinander in Beziehung stehen sollten. Methoden wie Feature-Sliced Design beheben dies, indem sie standardisierte Schichten hinzufügen und vorschreiben, dass Importe nur nach unten verweisen dürfen.

    Haupterkenntnisse

    • Trennen Sie die beiden Fragen: Wo befindet sich der Code, und in welche Richtung weisen die Abhängigkeiten?
    • Durch Gruppierung nach Dateitypen wird eine Funktion über viele Ordner verteilt, wodurch ihre Grenzen verborgen werden.
    • Ein Funktionsoverordner platziert Komponenten, Hooks, API-Aufrufe und Typen zusammen, wodurch die Navigation, Löschung sowie die Zuordnung zu einem Team erleichtert werden.
    • Lassen Sie shared/ ausschließlich für wirklich cross-cuttingen Code verwendet werden und stellen Sie jede Funktion über einen kleinen öffentlichen Eingangspunkt bereit.
    • Bereiten Sie sich auf die Grenzen dieses Musters vor: Ohne klare Regeln werden letztendlich cross-funktionale Importe, ein überfüllter shared-Ordner sowie fehlende Schichten entstehen.