Inicio / Artículos

Construcción de un módulo React basado en características: operaciones CRUD para publicaciones con TanStack Query

Organice una aplicación React por funcionalidad, no por tipo de archivo, luego implemente operaciones CRUD completas para publicaciones con TanStack Query y axios, y observe con exactitud dónde deja de ser viable escalar ese patrón.

Editor: REACTAPP.TOP10 min de lectura2263 palabras

Fuentes: React Architecture — The Feature-Based Pattern

La mayoría de los proyectos React comienzan con carpetas nombradas según roles técnicos: components, hooks, services, types. Esa estructura parece ordenada hasta que un solo cambio obliga a abrir cuatro directorios para modificar una sola funcionalidad. La arquitectura basada en características soluciona esto al agrupar el código por dominio en lugar de por tipo de archivo. Este artículo crea un módulo funcional para publicaciones que permite crear, leer, actualizar y eliminar datos mediante la API abierta DummyJSON, utilizando TypeScript, TanStack Query y axios, y luego analiza con honestidad los problemas que esta estructura no resuelve.

Dos preguntas a las que responden los patrones de arquitectura

Es más fácil comparar los patrones de arquitectura de React una vez que se nota que abordan dos preguntas diferentes:

  • ¿Dónde reside el código? Las estructuras basadas en características y el diseño por segmentos de características (Feature-Sliced Design, FSD) responden a esta pregunta. Organizan el código por funcionalidad o dominio.
  • ¿Hacia dónde apuntan las dependencias? La Arquitectura Limpia responde a esto. La lógica de negocio pura se encuentra en el centro, y las capas externas, como los componentes de React y los clientes API, dependen de ella y no al revés.
  • Estos aspectos son ortogonales, no competitivos. Puedes decidir dónde irán los archivos y, de forma independiente, decidir cómo pueden importarse entre sí, por lo que estos patrones a menudo se combinan en repositorios reales. La organización basada en funcionalidades es la respuesta más sencilla a la primera pregunta y un punto de partida razonable. Para un análisis más amplio de las estructuras, consulta esta comparación de patrones de estructura de carpetas en React.

    Por qué el agrupamiento por tipo de archivo falla

    Aquí está la estructura basada en tipos con la que comienzan muchos proyectos:

    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
    

    Aunque la aplicación es pequeña, parece estar bien organizada. Ahora imagina que necesitas cambiar la forma en que se edita una publicación. El código relevante está distribuido en components/PostForm.tsx, hooks/useUpdatePost.ts, services/postsApi.ts y types/post.ts. Cada cambio implica recorrer toda la estructura, y nada en ella indica qué archivos corresponden a cada funcionalidad. A medida que aumentan las características, cada carpeta se convierte en una lista larga y mixta, lo que dificulta comprender el alcance de los cambios.

    Grupación por funcionalidad

    La regla basada en las funcionalidades es sencilla: coloca todo lo que necesita una función, sus componentes, ganchos, llamadas a la API y tipos, en una sola carpeta. En este ejemplo el dominio son las publicaciones, por lo que la lógica de lista, detalle, creación, actualización y eliminación se encuentra en 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
    

    Cambiar el flujo de edición ahora implica abrir una carpeta. Cuando aparece un nuevo dominio como comments, recibe su propia carpeta con la misma estructura interna. El código que realmente sirve para cada funcionalidad, como el cliente HTTP o las primitivas de interfaz genéricas, se coloca en shared/, mientras que la configuración de la aplicación, como los proveedores y el punto de entrada, se ubica en app/.

    Vincular la funcionalidad de publicaciones a una API real

    Para mantener la estructura concreta, el ejemplo utiliza DummyJSON, una API REST gratuita que expone un recurso de publicaciones (documentado en los documentos de DummyJSON sobre publicaciones). El estado del servidor se gestiona con TanStack Query.

    Hay una precaución importante para las pruebas: DummyJSON solo simula operaciones de inserción, actualización y eliminación. Devuelve una respuesta realista, incluyendo un nuevo ID para los artículos creados, pero nada se almacena en el servidor. Por lo tanto, el ejemplo invalida la caché de consultas correspondiente después de cada mutación para provocar una nueva carga. Con DummyJSON, esa nueva carga mostrará los datos del servidor sin cambios; con un backend real, los cambios persisten y se muestran como se espera.

    El cliente HTTP compartido

    Cada funcionalidad utiliza una instancia de axios. Esta establece la URL base y sirve como punto central para comportamientos transversales implementados como interceptores, como el manejo de errores comunes o la adición de encabezados de autenticación. El interceptor aquí es un marcador temporal que se extenderá posteriormente.

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

    Tipos para el dominio de los artículos

    La función comienza con sus tipos. Post refleja la estructura que devuelve DummyJSON, PostsResponse envuelve listas paginadas, CreatePostInput describe los requisitos para la creación y UpdatePostInput utiliza Partial<Pick<...>> para que una actualización pueda cambiar el título, el cuerpo o ambos.

    // 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 objeto API para todas las operaciones CRUD

    A continuación, un único objeto postsApi agrupa las cinco llamadas: listar (con limit y skip para la paginación), obtener por ID, crear, actualizar y eliminar. Cada método desempaca res.data, de modo que quienes lo llaman reciben objetos del dominio con tipos definidos en lugar de respuestas de axios. Tenga en cuenta que el endpoint para la creación es /posts/add, específico de 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),
    };
    

    Hookes que envuelven cada operación

    Cada operación se expone a través de un gancho. Las lecturas utilizan useQuery; el gancho de lista almacena los resultados bajo la clave ['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(),
      });
    }
    

    Las escrituras utilizan useMutation. Después de una creación exitosa, el gancho invalida ['posts'], por lo que cualquier lista montada vuelve a cargar los datos.

    // 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 eliminación sigue el mismo patrón, utilizando el ID de la publicación como variable de mutación.

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

    El gancho de actualización se construye de la misma manera: se llama a postsApi.update e invalidan tanto la clave de la lista como la clave de los detalles de la publicación afectada. A medida que aumenta el número de claves, una pequeña fábrica de claves de consulta dentro de la funcionalidad (por ejemplo, un objeto que genera las claves de lista y detalles) mantiene la invalidación consistente y libre de errores tipográficos.

    Componentes que componen los ganchos

    Los componentes se mantienen simples y combinan únicamente ganchos. La lista muestra los títulos con un botón de eliminar para cada elemento y une un mensaje de carga mientras la consulta está en proceso.

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

    El formulario almacena el estado local del título y el cuerpo, y envía una mutación de creación, desactivando el botón mientras la solicitud está en tránsito. Tal como está escrito, solo crea publicaciones; para reutilizarlo para edición, habría que aceptar un atributo opcional id, completar previamente los campos y llamar al gancho de actualización cuando exista un 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>
      );
    }
    

    Lo importante aquí no son los fragmentos individuales sino su ubicación. Los tipos, las llamadas a la API, los ganchos y la interfaz de usuario relacionados con las publicaciones se encuentran todos dentro de features/posts/, y nada fuera de esa carpeta necesita saber cómo funcionan internamente las publicaciones.

    Qué te ofrece la estructura basada en características

    • Colocación conjunta. Todo el código relacionado con una funcionalidad se encuentra en el mismo lugar, por lo que trabajar en las publicaciones implica abrir una sola carpeta.
    • Eliminación sencilla. Eliminar una función suele significar borrar su carpeta, además de quitar uno o dos enlaces.
    • Trabajo paralelo. Los equipos pueden encargarse de cada función, lo que reduce los conflictos al fusionar código y el ruido generado por las revisiones.
    • Intención clara. Los nombres de las carpetas de nivel superior describen qué hace la aplicación, un concepto a menudo denominado Arquitectura que grita.

    Un hábito útil que potencia estos beneficios es asignar a cada función un archivo index.ts que exporte únicamente lo que otras partes de la aplicación puedan utilizar. De este modo, los demás módulos importan desde features/posts en lugar de acceder directamente a sus archivos internos.

    Dónde deja de ser viable este patrón

    El diseño basado en características es un buen primer paso, pero surgen tres problemas a medida que la aplicación crece.

    Dependencias no controladas entre características

    Las aplicaciones reales añaden funcionalidades como comentarios y perfiles. Si features/posts importa desde features/comments, y posteriormente comments importa desde posts, se genera una dependencia circular, y la estructura actual no impide esto. La organización de carpetas indica dónde se encuentra el código, pero no quién puede depender de quién. Se puede compensar en parte con reglas de validación como no-restricted-imports, pero el patrón en sí no ofrece ninguna regla al respecto.

    Una carpeta compartida que se convierte en un cajón de trastos

    Cuando los desarrolladores no están seguros de dónde debe ir algo, tiende a terminar en shared/. Si esto ocurre con frecuencia, shared/ se llena de código no relacionado y con propiedad poco definida del cual depende cada funcionalidad, lo que recrea el mismo problema de dispersión basada en tipos en un único directorio.

    Falta de concepto de capas

    Algunas funcionalidades son fundamentales, mientras que otras son composiciones de nivel superior formadas por varias funcionalidades, como una página de panel de control. Un directorio plano features/ no puede expresar esa jerarquía, por lo que no existe una indicación estructural sobre en qué dirección deben fluir las importaciones.

    En resumen, la arquitectura basada en funcionalidades capta el instinto correcto al agrupar por dominio, pero no ofrece solución para determinar cómo deben relacionarse las funcionalidades. Metodologías como el Diseño por Rebanadas de Funcionalidades abordan este problema al añadir capas estandarizadas y la regla de que las importaciones solo pueden apuntar hacia abajo.

    Puntos clave

    • Separar las dos preguntas: dónde reside el código y hacia dónde apuntan las dependencias.
    • El agrupamiento por tipo de archivo dispersa una misma funcionalidad en muchas carpetas y oculta sus límites.
    • Una carpeta de funcionalidades reúne componentes, ganchos, llamadas a API y tipos, lo que facilita la navegación, la eliminación y la asignación de responsabilidades al equipo.
    • Mantener shared/ limitado a código realmente transversal, y exponer cada funcionalidad a través de un pequeño punto de entrada público.
    • Planificar los límites del patrón: sin reglas explícitas, aparecerán eventualmente importaciones entre funcionalidades, una carpeta compartida sobrecargada y capas faltantes.

    Lecturas relacionadas

  • React sin un meta-marco: los costos ocultos de un backend en Python — Por qué React junto con Vite en un backend que no es de JavaScript evita el debate sobre Next.js, qué características de los marcos terminas reconstruyendo y cómo gestionar las conexiones entre ellos.
  • Enumers e interfaces en TypeScript como elementos base para el diseño de nivel bajo — Cómo los enumeradores de TypeScript eliminan errores relacionados con cadenas mágicas, cuándo elegir un tipo o una interfaz, y cómo las interfaces, los métodos implements y el tipado estructural definen contratos en el diseño de nivel bajo.
  • Zustand vs TanStack Query Boundaries en un Monorepo de Next.js en tiempo real — Aprenda una regla clara de responsabilidad para el estado de la interfaz de usuario, el estado del servidor y los eventos en tiempo real en un monorepo colaborativo de Next.js que utiliza Zustand, TanStack Query y sockets.
  • Un constructor de peticiones inmutable, basado en cierres, para Angular HttpClient — Reemplace el código genérico repetido de HttpParams con un pequeño constructor de solicitudes basado en cierres, conectado a través de un InjectionToken, que permanece inmutable, tipado y fácil de probar.
  • Qué te cuesta el uso de useEffect Fetching y qué cambia TanStack Query — Por qué la obtención de datos basada en effect genera solicitudes duplicadas, código genérico y cierres obsoletos en React, y cómo las funciones keys, staleTime e invalidation de TanStack Query solucionan estos problemas.
  • Una línea base predecible en React con TypeScript, Zustand y servicios tipados — Un esqueleto sencillo de React, TypeScript y Zustand que separa los almacenes de datos, los servicios API tipados y los componentes, además de convenciones para la estructura, el estado asíncrono y las pruebas.