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.
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.
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
- Diseño Fragmentado de Características para React: Capas, Reglas de Importación y Cuándo Parar — Aprenda cómo las capas del diseño fragmentado de características, la regla de importación unidireccional, las APIs públicas simplificadas y las importaciones cruzadas @x descomponen los conjuntos de código de React, y cuándo adoptar solo una parte de él.
- Estructurando una Capa de Datos TanStack Query, desde queryOptions hasta Rollbacks — Construya una capa de datos TanStack Query paso a paso: queryOptions compartidos, generadores de claves, selectores, paginación, precarga, invalidación centralizada y actualizaciones óptimas seguras.