Tema
Patrones y estructura
Una app de 5 componentes se organiza sola. Una de 200 componentes, no. En esta lección aprenderás una estructura de carpetas por features que escala, y los patrones de componentes que usan los equipos profesionales: composición, contenedor/presentación, compound components y más.
Concepto
La estructura del proyecto es como el orden de una cocina profesional: cada cosa en su lugar para encontrarla rápido, y las cosas que se usan juntas, juntas.
Dos formas de organizar:
| Por tipo | Por feature |
|---|---|
components/, hooks/, services/... cada uno con archivos de todas las funcionalidades | features/carrito/, features/productos/... cada uno con sus componentes, hooks y servicios |
| Fácil al inicio | Escala mucho mejor |
| Para cambiar "el carrito" tocas 5 carpetas | Todo lo del carrito vive junto |
Estructura recomendada
txt
src/
├── app/ ← arranque de la aplicación
│ ├── App.jsx
│ ├── router.jsx
│ └── providers.jsx ← QueryClientProvider, AuthProvider, etc.
├── components/ ← componentes genéricos reutilizables (sin lógica de negocio)
│ └── ui/
│ ├── Boton.jsx
│ ├── Modal.jsx
│ ├── Input.jsx
│ └── Spinner.jsx
├── features/ ← funcionalidades del negocio
│ ├── auth/
│ │ ├── components/LoginForm.jsx
│ │ ├── hooks/useAuth.js
│ │ ├── api.js
│ │ └── index.js ← API pública de la feature
│ ├── productos/
│ │ ├── components/
│ │ │ ├── TarjetaProducto.jsx
│ │ │ └── ListaProductos.jsx
│ │ ├── hooks/useProductos.js
│ │ ├── api.js
│ │ └── index.js
│ └── carrito/
│ ├── components/ResumenCarrito.jsx
│ ├── store.js
│ └── index.js
├── pages/ ← componen features para cada ruta (delgadas)
│ ├── InicioPage.jsx
│ ├── ProductoPage.jsx
│ └── CarritoPage.jsx
├── hooks/ ← hooks genéricos (useDebounce, useMediaQuery)
├── lib/ ← utilidades y clientes (api.js, cn.js, formato.js)
├── styles/
│ └── index.css
└── main.jsxReglas que mantienen el orden
components/uino conoce el negocio: unBotonno sabe qué es un producto.- Cada feature expone lo necesario en su
index.jsy el resto del código importa desde ahí:
js
// src/features/carrito/index.js
export { default as ResumenCarrito } from './components/ResumenCarrito'
export { useCarritoStore, selectTotal } from './store'jsx
// En una página
import { ResumenCarrito } from '@/features/carrito'- Las páginas son delgadas: unen features, no contienen lógica compleja.
- Una feature no importa archivos internos de otra; si lo necesita, usa su
index.js. - Si algo se usa en una sola feature, vive dentro de esa feature. Sólo lo que se comparte sube a
components/,hooks/olib/.
Patrones de componentes
1. Composición con children
En lugar de componentes con decenas de props, deja que el padre decida el contenido:
jsx
// ❌ Rígido: cada caso nuevo requiere otra prop
<Tarjeta titulo="Plan Pro" subtitulo="Popular" icono="⭐" botonTexto="Elegir" onBoton={...} />
// ✅ Flexible: compón las piezas que necesites
<Tarjeta>
<Tarjeta.Encabezado>⭐ Plan Pro</Tarjeta.Encabezado>
<p>Todo lo que necesitas para crecer.</p>
<Boton onClick={elegir}>Elegir</Boton>
</Tarjeta>2. Contenedor / Presentación
Separa qué datos (contenedor) de cómo se ven (presentación). Los componentes de presentación son fáciles de reutilizar y probar.
jsx
// Sólo recibe datos por props y dibuja. No sabe de dónde vienen.
export function ListaUsuarios({ usuarios, onSeleccionar }) {
if (usuarios.length === 0) return <p>Sin usuarios</p>
return (
<ul>
{usuarios.map((u) => (
<li key={u.id}>
<button onClick={() => onSeleccionar(u)}>{u.name}</button>
</li>
))}
</ul>
)
}jsx
import { useQuery } from '@tanstack/react-query'
import { ListaUsuarios } from './ListaUsuarios'
// Obtiene los datos y maneja los estados; delega el dibujo
export function UsuariosContainer() {
const { data, isPending, error } = useQuery({
queryKey: ['usuarios'],
queryFn: () => fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json())
})
if (isPending) return <p>Cargando…</p>
if (error) return <p>Error</p>
return <ListaUsuarios usuarios={data} onSeleccionar={(u) => alert(u.email)} />
}Hoy, los custom hooks cumplen el papel del contenedor: la lógica va en useUsuarios() y el componente sólo dibuja.
3. Compound components
Componentes que trabajan juntos compartiendo estado por Context, como select y option en HTML. Dan una API muy legible:
jsx
import { createContext, useContext, useId, useState } from 'react'
const TabsContext = createContext(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Los componentes de Tabs deben ir dentro de <Tabs>')
return ctx
}
export function Tabs({ defaultValue, children }) {
const [activa, setActiva] = useState(defaultValue)
const id = useId() // ids únicos para accesibilidad
return <TabsContext value={{ activa, setActiva, id }}>{children}</TabsContext>
}
function Lista({ children }) {
return <div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid #ddd' }}>{children}</div>
}
function Pestana({ value, children }) {
const { activa, setActiva, id } = useTabs()
const seleccionada = activa === value
return (
<button
role="tab"
id={`${id}-tab-${value}`}
aria-selected={seleccionada}
aria-controls={`${id}-panel-${value}`}
onClick={() => setActiva(value)}
style={{
padding: '8px 16px', border: 0, background: 'none', cursor: 'pointer',
borderBottom: seleccionada ? '2px solid #6d28d9' : '2px solid transparent',
fontWeight: seleccionada ? 700 : 400
}}
>
{children}
</button>
)
}
function Panel({ value, children }) {
const { activa, id } = useTabs()
if (activa !== value) return null
return (
<div role="tabpanel" id={`${id}-panel-${value}`} aria-labelledby={`${id}-tab-${value}`} style={{ padding: 16 }}>
{children}
</div>
)
}
Tabs.Lista = Lista
Tabs.Pestana = Pestana
Tabs.Panel = Paneljsx
import { Tabs } from '@/components/ui/Tabs'
export function DetalleProducto() {
return (
<Tabs defaultValue="descripcion">
<Tabs.Lista>
<Tabs.Pestana value="descripcion">Descripción</Tabs.Pestana>
<Tabs.Pestana value="specs">Especificaciones</Tabs.Pestana>
<Tabs.Pestana value="resenas">Reseñas</Tabs.Pestana>
</Tabs.Lista>
<Tabs.Panel value="descripcion">Café 100% de altura, tueste medio.</Tabs.Panel>
<Tabs.Panel value="specs">Peso: 500 g · Origen: Chiapas</Tabs.Panel>
<Tabs.Panel value="resenas">⭐⭐⭐⭐⭐ "El mejor café que he probado"</Tabs.Panel>
</Tabs>
)
}4. Render props y componentes como props
Pasar una función que decide qué dibujar. Útil en listas, tablas o componentes genéricos:
jsx
function Lista({ items, renderItem, vacio = 'Sin elementos' }) {
if (items.length === 0) return <p>{vacio}</p>
return <ul>{items.map((item) => <li key={item.id}>{renderItem(item)}</li>)}</ul>
}
// Mismo componente, presentaciones distintas
<Lista items={usuarios} renderItem={(u) => <strong>{u.nombre}</strong>} />
<Lista items={productos} renderItem={(p) => `${p.nombre} — $${p.precio}`} vacio="No hay productos" />5. Componentes de estado: carga, error y vacío
Estandariza cómo se ven esos estados en toda la app:
jsx
// src/components/ui/EstadoConsulta.jsx
export function EstadoConsulta({ query, vacio, children }) {
if (query.isPending) return <div className="spinner" aria-label="Cargando" />
if (query.isError) {
return (
<div role="alert">
<p>No se pudo cargar la información.</p>
<button onClick={() => query.refetch()}>Reintentar</button>
</div>
)
}
if (vacio?.(query.data)) return <p>No hay nada por aquí todavía.</p>
return children(query.data)
}
// Uso
// <EstadoConsulta query={productosQuery} vacio={(d) => d.products.length === 0}>
// {(datos) => <ListaProductos productos={datos.products} />}
// </EstadoConsulta>6. Error Boundaries
Si un componente lanza un error al renderizar, toda la app queda en blanco. Un Error Boundary atrapa el error y muestra una interfaz alternativa. La forma más simple es la librería react-error-boundary:
bash
npm i react-error-boundaryjsx
import { ErrorBoundary } from 'react-error-boundary'
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert" style={{ padding: 24 }}>
<h2>Algo salió mal 😕</h2>
<pre style={{ color: 'crimson' }}>{error.message}</pre>
<button onClick={resetErrorBoundary}>Intentar de nuevo</button>
</div>
)
}
export default function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback} onError={(e) => console.error(e) /* o Sentry */}>
<Dashboard />
</ErrorBoundary>
)
}Coloca boundaries en puntos estratégicos (cada sección o widget) para que un error no tumbe toda la página.
Convenciones de nombres
| Elemento | Convención | Ejemplo |
|---|---|---|
| Componentes y sus archivos | PascalCase | TarjetaProducto.jsx |
| Hooks | camelCase con use | useCarrito.js |
| Utilidades | camelCase | formatearPrecio.js |
| Constantes | MAYÚSCULAS | MAX_ITEMS |
| Props de eventos | on + Evento | onSeleccionar |
| Manejadores | manejar/handle + Evento | manejarClic |
| Booleanos | es, tiene, puede | esVisible, tieneStock |
Buena práctica
- No sobre-estructures un proyecto pequeño. Empieza simple y mueve a
features/cuando una funcionalidad crezca. - Un componente con más de ~200 líneas o más de ~8 props suele pedir dividirse o usar composición.
- Configura ESLint + Prettier y un alias
@/desde el inicio.
Error común
Crear abstracciones "por si acaso". Un componente genérico con 20 props configurables que se usa en un solo lugar es más difícil de mantener que dos componentes simples. Abstrae cuando tengas tres usos reales.
Ejercicios
- Reorganiza mentalmente (o en tu proyecto) una app con login, lista de productos, carrito y perfil de usuario usando la estructura por features. Escribe el árbol de carpetas.
- Crea un compound component
AcordeonconAcordeon.Item,Acordeon.TituloyAcordeon.Contenido, donde sólo un ítem puede estar abierto.
Ver solución
txt
// 1
src/
├── app/ (App.jsx, router.jsx, providers.jsx)
├── components/ui/ (Boton, Input, Modal, Spinner)
├── features/
│ ├── auth/ (components/LoginForm.jsx, hooks/useAuth.js, api.js, index.js)
│ ├── productos/ (components/, hooks/useProductos.js, api.js, index.js)
│ ├── carrito/ (components/ResumenCarrito.jsx, store.js, index.js)
│ └── perfil/ (components/FormPerfil.jsx, hooks/usePerfil.js, api.js, index.js)
├── pages/ (InicioPage, ProductoPage, CarritoPage, PerfilPage, LoginPage)
├── hooks/ (useDebounce.js)
└── lib/ (api.js, cn.js)jsx
// 2
import { createContext, useContext, useState } from 'react'
const AcordeonCtx = createContext(null)
const ItemCtx = createContext(null)
export function Acordeon({ children }) {
const [abierto, setAbierto] = useState(null)
return <AcordeonCtx value={{ abierto, setAbierto }}>{children}</AcordeonCtx>
}
function Item({ value, children }) {
return <ItemCtx value={value}><div style={{ borderBottom: '1px solid #ddd' }}>{children}</div></ItemCtx>
}
function Titulo({ children }) {
const { abierto, setAbierto } = useContext(AcordeonCtx)
const value = useContext(ItemCtx)
const estaAbierto = abierto === value
return (
<button
aria-expanded={estaAbierto}
onClick={() => setAbierto(estaAbierto ? null : value)}
style={{ width: '100%', textAlign: 'left', padding: 12 }}
>
{children} {estaAbierto ? '−' : '+'}
</button>
)
}
function Contenido({ children }) {
const { abierto } = useContext(AcordeonCtx)
const value = useContext(ItemCtx)
return abierto === value ? <div style={{ padding: 12 }}>{children}</div> : null
}
Acordeon.Item = Item
Acordeon.Titulo = Titulo
Acordeon.Contenido = Contenido
// Uso:
// <Acordeon>
// <Acordeon.Item value="envios">
// <Acordeon.Titulo>¿Hacen envíos?</Acordeon.Titulo>
// <Acordeon.Contenido>Sí, a todo el país.</Acordeon.Contenido>
// </Acordeon.Item>
// </Acordeon>Resumen
- Organiza por features: todo lo de una funcionalidad vive junto y se expone por su
index.js. components/uipara piezas genéricas;pages/delgadas que componen features.- Patrones clave: composición con
children, contenedor/presentación (o custom hooks), compound components, render props. - Usa Error Boundaries para que un error no deje la app en blanco.