Por qué la estructura del proyecto importa
Una buena estructura en React reduce el “costo de cambio”: encontrar archivos rápido, aislar responsabilidades, evitar duplicación y hacer que el código sea más fácil de probar. La idea central es organizar el proyecto para que el lector del código entienda dónde vive cada cosa (UI, lógica, estilos, assets) y por qué está ahí.
Estructura recomendada: carpetas por feature (dominio)
La organización por feature (también llamada por dominio) agrupa todo lo necesario para una funcionalidad en un mismo lugar: componentes, hooks locales, utilidades, estilos y assets. Esto mejora la modularidad y permite que cada feature evolucione con menos fricción.
Ejemplo de árbol de carpetas
src/ app/ App.tsx routes.tsx providers/ QueryProvider.tsx ThemeProvider.tsx layout/ MainLayout.tsx features/ auth/ components/ LoginForm/ LoginForm.tsx LoginForm.test.tsx LoginForm.module.css index.ts hooks/ useLogin.ts api/ authApi.ts utils/ validators.ts assets/ logo-auth.png types.ts index.ts products/ components/ ProductCard/ ProductCard.tsx ProductCard.module.css index.ts hooks/ useProducts.ts api/ productsApi.ts utils/ formatPrice.ts types.ts index.ts shared/ components/ Button/ Button.tsx Button.module.css Button.test.tsx index.ts Modal/ Modal.tsx Modal.module.css index.ts hooks/ useDebounce.ts utils/ cn.ts assertNever.ts styles/ tokens.css globals.css assets/ icons/ cart.svg lib/ http/ client.ts storage/ localStorage.ts main.tsxQué va en cada carpeta
- app/: composición de alto nivel (rutas, layouts, providers). Evita meter lógica de negocio aquí.
- features/<feature>/: todo lo específico del dominio (auth, products, checkout…).
- shared/: piezas reutilizables y agnósticas del dominio (UI genérica, hooks genéricos, utilidades comunes, estilos globales).
- lib/: infraestructura y adaptadores (cliente HTTP, storage, wrappers de APIs). Suele ser “sin UI”.
Guía práctica paso a paso para migrar a estructura por feature
Paso 1: identifica features reales
Lista dominios que tengan sentido para el usuario o el negocio: auth, products, cart, profile. Evita features demasiado genéricas como forms o helpers (eso suele ir a shared).
Paso 2: mueve componentes “cerca” de su dominio
Si un componente solo se usa en una feature, colócalo dentro de esa feature. Si se usa en varias, evalúa extraerlo a shared/components.
Paso 3: separa UI, lógica y acceso a datos dentro de la feature
- components/: UI específica del dominio.
- hooks/: lógica reutilizable dentro de la feature (por ejemplo, orquestación de llamadas, validaciones, estado derivado).
- api/: funciones que hablan con el backend para esa feature.
- utils/: funciones puras (formateo, validadores, mapeos).
- types.ts: tipos del dominio (interfaces, unions).
Paso 4: define un “public API” por feature
Evita imports profundos y frágiles como features/auth/components/LoginForm/LoginForm. En su lugar, expón lo necesario desde un index.ts de la feature.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
// src/features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { useLogin } from './hooks/useLogin';
export type { AuthUser } from './types';Y dentro del componente, usa un index.ts local:
// src/features/auth/components/LoginForm/index.ts
export { default as LoginForm } from './LoginForm';Paso 5: crea reglas claras para “shared”
Una regla útil: shared no depende de features. Así evitas ciclos y acoplamientos. Si algo en shared empieza a necesitar tipos o lógica de una feature, probablemente no es shared.
Alternativa para proyectos pequeños: estructura por tipo
Cuando el proyecto es pequeño (pocas pantallas, un solo dominio), una estructura por tipo puede ser suficiente. La clave es no sobre-ingenierizar. Si crece, migrar a “por feature” suele ser natural.
src/ components/ Button/ Modal/ hooks/ useDebounce.ts pages/ Home.tsx Login.tsx services/ httpClient.ts authService.ts utils/ format.ts styles/ globals.css assets/ icons/Señales para migrar a “por feature”: carpetas components y services empiezan a llenarse con nombres de dominio (ProductCard, CartSummary, authService), o cuesta encontrar qué archivos cambian juntos.
Convenciones de nombres y co-locación
Nombres de archivos y carpetas
- Componentes React:
PascalCasepara carpetas y archivos principales:ProductCard/ProductCard.tsx. - Hooks:
camelCasecon prefijouse:useProducts.ts. - Utilidades: verbos o intención clara:
formatPrice.ts,mapApiProduct.ts. - Estilos: si usas CSS Modules:
Component.module.css. Si usas otra solución, mantén consistencia por proyecto.
Co-locación recomendada
Coloca juntos los archivos que cambian juntos. Un patrón común por componente:
ProductCard/ ProductCard.tsx ProductCard.module.css ProductCard.test.tsx ProductCard.stories.tsx (si aplica) index.tsEsto reduce el “salto” entre carpetas y facilita refactors.
Manejo de assets (imágenes, íconos, fuentes)
Reglas prácticas
- Assets globales (logo general, íconos compartidos):
src/shared/assets. - Assets específicos de una feature (imágenes solo de auth):
src/features/auth/assets. - Evita una carpeta
assetsgigante sin estructura; termina siendo un “cajón desastre”.
Importación consistente
Mantén una convención: o bien importas assets desde módulos (recomendado en bundlers modernos) o los sirves desde una carpeta pública. Sea cual sea, documenta la regla y aplícala igual en todo el proyecto.
Patrones para componentes compartidos (shared)
1) Componentes UI “tontos” (presentacionales)
En shared/components, prioriza componentes con responsabilidades claras: renderizar UI a partir de props, sin conocer el dominio. Ejemplo: un botón reutilizable.
// src/shared/components/Button/Button.tsx
type ButtonProps = {
variant?: 'primary' | 'secondary';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
};
export function Button({ variant = 'primary', disabled, onClick, children }: ButtonProps) {
return (
<button data-variant={variant} disabled={disabled} onClick={onClick}>
{children}
</button>
);
}2) “Adapters” de dominio fuera de shared
Si una feature necesita adaptar un componente shared a su dominio (por ejemplo, un Button con tracking o permisos), crea un wrapper dentro de la feature:
// src/features/products/components/AddToCartButton/AddToCartButton.tsx
import { Button } from '@/shared/components/Button';
type AddToCartButtonProps = {
productId: string;
onAdd: (productId: string) => void;
};
export function AddToCartButton({ productId, onAdd }: AddToCartButtonProps) {
return <Button onClick={() => onAdd(productId)}>Añadir</Button>;
}Así shared se mantiene genérico y la feature conserva su lógica.
3) Evita “shared” como basurero
Si algo solo se usa en un lugar, no lo subas a shared “por si acaso”. Extrae a shared cuando haya reutilización real o una intención clara de estandarización (por ejemplo, un sistema de diseño).
Pautas para componentes fáciles de probar y mantener
Props explícitas y contratos claros
Haz que el componente reciba lo que necesita por props, en lugar de leerlo de lugares implícitos. Esto mejora testabilidad y reusabilidad.
| Preferible | Evitar |
|---|---|
Recibir datos y callbacks por props | Depender de variables globales o imports con estado |
Tipos de props concretos | Props “bolsa” tipo |
// Bien: contrato explícito
type PriceProps = { amount: number; currency: 'EUR' | 'USD' };
export function Price({ amount, currency }: PriceProps) {
return <span>{currency} {amount.toFixed(2)}</span>;
}Funciones puras cuando sea posible
Extrae lógica a funciones puras en utils/. Son más fáciles de testear porque no dependen del entorno.
// src/features/products/utils/formatPrice.ts
export function formatPrice(cents: number) {
return (cents / 100).toFixed(2);
}Luego el componente solo renderiza:
import { formatPrice } from '../utils/formatPrice';
export function ProductPrice({ cents }: { cents: number }) {
return <span>€ {formatPrice(cents)}</span>;
}Evitar dependencias implícitas
Dependencias implícitas típicas: leer directamente de localStorage dentro del componente, usar singletons globales, o acoplarse a rutas/servicios sin inyección. En su lugar, crea adaptadores en lib/ y pásalos como dependencias o encapsúlalos en funciones específicas.
// lib/storage/localStorage.ts
export const storage = {
get(key: string) {
return window.localStorage.getItem(key);
},
set(key: string, value: string) {
window.localStorage.setItem(key, value);
}
};// feature: usar una función que puedas mockear en tests
import { storage } from '@/lib/storage/localStorage';
export function getAuthToken() {
return storage.get('token');
}Separar “container” y “presentational” cuando aporte claridad
Sin convertirlo en dogma: si un componente mezcla demasiada orquestación con UI, divide en:
- Container: obtiene datos, llama hooks, decide qué pasar.
- Presentational: recibe props y renderiza.
// Presentational
type LoginFormViewProps = {
email: string;
password: string;
onEmailChange: (v: string) => void;
onPasswordChange: (v: string) => void;
onSubmit: () => void;
isLoading: boolean;
error?: string;
};
export function LoginFormView(props: LoginFormViewProps) {
// render UI usando props
return <div>...</div>;
}Reglas de importación y límites entre módulos
Dependencias permitidas (sugerencia)
apppuede importar defeatures,shared,lib.featurespuede importar desharedylib.sharedsolo importa desharedylib(idealmente).
Esto evita ciclos y mantiene una dirección clara del acoplamiento.
Aliases para imports más limpios
Configura un alias (por ejemplo @/ apuntando a src/) para evitar rutas relativas largas:
import { Button } from '@/shared/components/Button';
import { useProducts } from '@/features/products';