Accueil / Articles

Création d’un module React basé sur des fonctionnalités : CRUD pour les articles avec TanStack Query

Organisez une application React par fonctionnalité, et non par type de fichier, puis mettez en place des opérations CRUD complètes pour les articles à l’aide de TanStack Query et d’axios, afin de déterminer précisément où ce modèle cesse de fonctionner efficacement.

Éditeur : REACTAPP.TOP10 min de lecture2263 mots

Sources: React Architecture — The Feature-Based Pattern

La plupart des projets React commencent avec des dossiers nommés d’après des rôles techniques : components, hooks, services, types. Cette organisation semble ordonnée jusqu’à ce qu’un simple changement vous oblige à ouvrir quatre répertoires pour modifier une seule fonctionnalité. L’architecture basée sur les fonctionnalités résout ce problème en regroupant le code par domaine plutôt que par type de fichier. Cet article crée un module fonctionnel pour gérer les articles, permettant d’effectuer des opérations de création, de lecture, de mise à jour et de suppression en utilisant l’API ouverte DummyJSON, avec TypeScript, TanStack Query et axios, avant d’examiner honnêtement les problèmes que cette structure ne résout pas.

Deux questions auxquelles répondent les patterns architecturaux

Il est plus facile de comparer les patterns architecturaux React une fois qu’on se rend compte qu’ils répondent à deux questions différentes :

  • Où se trouve le code ? Les architectures basées sur les fonctionnalités et le Feature-Sliced Design (FSD) répondent à cette question. Elles organisent le code par fonctionnalité ou par domaine.
  • De quel côté pointent les dépendances ? L’Architecture Propre y répond. La logique métier pure se trouve au centre, et les couches extérieures, telles que les composants React et les clients API, dépendent d’elle plutôt que l’inverse.
  • Ces aspects sont orthogonaux, non concurrents. Vous pouvez décider où placer les fichiers et, indépendamment, déterminer comment ils peuvent s’importer mutuellement, c’est pourquoi ces modèles sont souvent combinés dans des dépôts réels. L’organisation par fonctionnalités constitue la réponse la plus simple à la première question et un point de départ judicieux. Pour une étude plus approfondie des structures, consultez cette comparaison des modèles de structure de dossiers React.

    Pourquoi le regroupement par type de fichier échoue

    Voici la structure basée sur les types avec laquelle de nombreux projets commencent :

    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
    

    Même si l’application est petite, elle semble bien organisée. Imaginez maintenant que vous deviez modifier la façon dont s’effectue l’édition d’une publication. Le code pertinent est réparti dans components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts et types/post.ts. Chaque modification devient alors une exploration approfondie de l’arborescence, et rien dans la structure ne permet de savoir quels fichiers correspondent à quelle fonctionnalité. À mesure que les fonctionnalités augmentent, chaque dossier se transforme en une longue liste mélangée, rendant plus difficile la compréhension des conséquences d’une modification.

    Grouper par fonctionnalité

    La règle basée sur les fonctionnalités est simple : placer tout ce dont une fonctionnalité a besoin — ses composants, ses hooks, ses appels API et ses types — dans un seul dossier. Dans cet exemple, le domaine concerné est les publications, donc la logique de liste, de détail, de création, de mise à jour et de suppression se trouve tous dans 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
    

    Changer le flux d’édition signifie actuellement ouvrir un dossier. Lorsqu’un nouveau domaine comme les commentaires apparaît, il reçoit son propre dossier ayant la même structure interne. Le code qui sert réellement chaque fonctionnalité, comme le client HTTP ou les primitives d’interface utilisateur génériques, se trouve dans shared/, tandis que les connexions de l’application, telles que les fournisseurs et le point d’entrée, se trouvent dans app/.

    Connexion de la fonctionnalité des articles à une API réelle

    Afin de maintenir une structure concrète, l’exemple interagit avec DummyJSON, une API REST gratuite qui expose une ressource articles (documentée sur les docs de DummyJSON). L’état du serveur est géré à l’aide de TanStack Query.

    Un point important à considérer pour les tests : DummyJSON ne simule que les opérations d’ajout, de mise à jour et de suppression. Il renvoie une réponse réaliste, y compris un nouveau ID pour les publications créées, mais rien n’est stocké sur le serveur. Par conséquent, l’exemple invalide le cache de requêtes concerné après chaque mutation afin de déclencher un nouveau chargement. Avec DummyJSON, ce nouveau chargement affichera les données du serveur inchangées ; avec un vrai backend, les modifications persistent et apparaissent comme prévu.

    Le client HTTP partagé

    Toute fonctionnalité utilise une seule instance d’axios. Celle-ci définit l’URL de base et constitue le point central pour les comportements transversaux mis en œuvre sous forme d’intercepteurs, tels que le traitement des erreurs courantes ou l’ajout d’en-têtes d’autorisation. L’intercepteur ici est un placeholder de passage que vous pourrez étendre.

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

    Types pour le domaine des publications

    Cette fonctionnalité commence par ses types. Post reflète la structure renvoyée par DummyJSON, PostsResponse contient des listes paginées, CreatePostInput décrit les exigences pour la création, et UpdatePostInput utilise Partial<Pick<...>> afin qu’une mise à jour puisse modifier le titre, le corps ou les deux.

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

    Un seul objet API pour toutes les opérations CRUD

    Ensuite, un seul objet postsApi regroupe les cinq appels : la liste (avec limit et skip pour la pagination), la récupération par ID, la création, la mise à jour et la suppression. Chaque méthode déballle res.data, de sorte que les appels reçoivent des objets du domaine typés plutôt que des réponses d’axios. Notez que l’endpoint pour la création est /posts/add, qui est spécifique à 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),
    };
    

    Hooks qui enveloppent chaque opération

    Chaque opération est mise à disposition via un hook. Les lectures utilisent useQuery ; le hook de liste met en cache les résultats sous la clé ['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(),
      });
    }
    

    Les écritures utilisent useMutation. Après une création réussie, le hook invalide ['posts'], ce qui force toute liste affichée à se rafraîchir.

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

    La suppression suit le même schéma, en prenant l’ID de la publication comme variable de mutation.

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

    Un hook de mise à jour est construit de la même manière : on appelle postsApi.update et on invalide à la fois la clé de la liste et la clé des détails de la publication concernée. À mesure que le nombre de clés augmente, une petite fonction génératrice de clés de requête au sein du composant (par exemple, un objet qui produit les clés de liste et de détails) assure une invalidation cohérente et sans faute d’orthographe.

    Composants qui constituent les hooks

    Les composants restent légers et se contentent de combiner des hooks. La liste affiche les titres accompagnés d’un bouton de suppression pour chaque élément, ainsi qu’un message de chargement tant que la requête est en cours.

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

    Le formulaire conserve l’état local pour le titre et le corps, puis envoie une mutation de création, désactivant le bouton pendant que la requête est en transit. Tel quel, il ne crée que des publications ; pour le réutiliser à des fins d’édition, il faudrait accepter une propriété id optionnelle, remplir automatiquement les champs et appeler le hook d’actualisation lorsque l’id est présent.

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

    Ce qui importe ici, ce n’est pas les fragments individuels mais leur emplacement. Les types, les appels API, les hooks et l’interface utilisateur relatives aux publications se trouvent tous à l’intérieur de features/posts/, et rien en dehors de ce dossier n’a besoin de connaître le fonctionnement interne des publications.

    Ce que vous offre la structure basée sur les fonctionnalités

    • Colocalisation. Tout le code relatif à une fonctionnalité se trouve ensemble, ce qui signifie qu’il suffit d’ouvrir un seul dossier pour travailler sur les articles.
    • Suppression facile. Supprimer une fonctionnalité revient souvent à supprimer son dossier, ainsi qu’une ou deux routes associées.
    • Travail en parallèle. Les équipes peuvent être responsables de différentes fonctionnalités, ce qui réduit les conflits de fusion et le bruit lié aux revues.
    • Intention lisible. Les noms des dossiers de niveau supérieur décrivent ce que fait l’application, une approche souvent appelée architecture criante.

    index.ts qui n’exporte que ce dont d’autres parties de l’application peuvent avoir besoin. Les autres modules importent alors depuis features/posts au lieu d’accéder directement à ses fichiers internes.

    Où ce modèle cesse de fonctionner

    Un agencement basé sur les fonctionnalités constitue un bon premier pas, mais trois problèmes apparaissent à mesure que l’application se développe.

    Dépendances non contrôlées entre fonctionnalités

    Les applications réelles ajoutent des fonctionnalités telles que les commentaires et les profils. Si features/posts importe depuis features/comments, et que comments importe ensuite depuis posts, on se retrouve face à une dépendance circulaire, sans que la structure ne permette de l’éviter. L’organisation des dossiers indique simplement où se trouve le code, mais pas qui peut dépendre de qui. On peut en partie compenser cela avec des règles de vérification comme no-restricted-imports, mais le modèle lui-même ne prévoit aucune règle à cet égard.

    Un dossier partagé qui devient un tiroir à ordures

    Lorsque les développeurs ne savent pas où placer quelque chose, cela a tendance à atterrir dans shared/. Si cela se reproduit suffisamment souvent, shared/ se remplit de code non lié entre eux et dont la propriété est floue, duquel dépendent toutes les fonctionnalités, ce qui recrée le même problème de dispersion basé sur les types dans un seul répertoire.

    Aucune notion de couches

    features/ plat ne peut pas exprimer cette hiérarchie, il n’y a donc aucun signal structurel indiquant dans quelle direction les importations doivent se faire.

    Points clés

    • Séparez les deux questions : où se trouve le code, et dans quelle direction pointent les dépendances.
    • Le regroupement par type de fichier dissémine une même fonctionnalité dans de nombreux dossiers et cache ses limites.
    • Un dossier dédié à une fonctionnalité regroupe les composants, les hooks, les appels API et les types, ce qui facilite la navigation, la suppression et l’attribution aux équipes concernées.
    • Restreignez le dossier shared/ aux codes véritablement transversaux, et exposez chaque fonctionnalité via un petit point d’entrée public.
    • Pensez aux limites de ce modèle : en l’absence de règles explicites, des importations entre fonctionnalités apparaîtront, le dossier partagé deviendra surchargé et des couches manquantes se formeront inévitablement.