Dirigiendo agentes de codificación con IA mediante cuatro patrones arquitectónicos React nombrados
Aprenda cuatro patrones de React (composición de secciones, ganchos personalizados, componentes polimórficos y reductores) y cómo nombrarlos en las instrucciones le permite obtener un código generado por IA más limpio.
Fuentes: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns
Los agentes de programación basados en IA como Claude o OpenCode pueden generar una función funcional de React con solo unas pocas instrucciones, y el código suele hacer lo que se le pide. El punto débil es la estructura: los componentes generados suelen tener cientos de líneas, mezclando marcado, datos, efectos y manejadores en un mismo archivo, y pedirle al agente que “lo haga modular” tiende a producir fragmentos específicos para cada página en lugar de piezas realmente reutilizables. Este artículo aborda cuatro patrones que solucionan ese problema estructural y, lo que es igualmente importante, muestra cómo nombrarlos en las instrucciones para que el agente construya la arquitectura que usted desea en lugar de elegir una por usted.
Por qué el código funcional no es lo mismo que una buena arquitectura
Una guía común para el código limpio es mantener un componente de React en aproximadamente 100 líneas, con unos 200 como techo práctico. Se trata de una heurística y no de una regla estricta, pero los archivos con 500 líneas o más son precisamente donde el resultado generado por la IA se desvía cuando se deja actuar por sí solo.
La respuesta obvia es pedirle al agente que divida el contenido. Por lo general, cumplirá solicitando dividir la interfaz de usuario en varios componentes más pequeños dentro de una carpeta dedicada a esa página específica. Esos componentes son más pequeños, pero siguen estando vinculados a una sola funcionalidad y rara vez son reutilizables en otro lugar. Eso es descomposición, no modularidad.
Para un proyecto pequeño, esto apenas importa. Pero en el caso de software que debe evolucionar durante años, cada cambio afecta al código que nunca fue diseñado para ser reutilizado o ampliado. La solución no es dejar de usar la IA, sino conocer los patrones de diseño que permiten que una base de código React crezca y utilizarlos para guiar al agente. La generación de código se está volviendo económica; lo que aún debes decidir es cómo estructurarlo. Si deseas una visión más amplia de cómo los resultados generados por la IA suelen ignorar los principios del código limpio, consulta cuando la IA escribe tu aplicación React pero ignora los principios del código limpio.
Patrón 1: dividir las secciones en cuatro componentes
Si pides a un desarrollador que cree una sección de interfaz de usuario, normalmente incluirá en un único componente el contenedor, el encabezado, la renderización de la lista y los elementos individuales. La duplicación se vuelve evidente a medida que la aplicación crece, ya que la misma estructura aparece casi en todas partes: blogs, tiendas en línea, portafolios, directorios, páginas de aterrizaje de SaaS y paneles de control.
Imagina una página principal de comercio electrónico con secciones para productos nuevos, populares y más vendidos. Cada sección tiene el mismo aspecto; solo varían los datos. Cada una puede dividirse en cuatro partes, cada una con una función específica:
- un contenedor para la sección
- un encabezado para la sección
- una lista de tarjetas
- una sola tarjeta
La sección
La sección es el componente padre. Recibe datos a través de propiedades y los transfiere a sus hijos; por lo tanto, conoce la estructura de composición pero no los detalles de estilo.
export function ProductSection({ title, subtitle, products }) {
return (
<section>
<SectionHeading
title={title}
subtitle={subtitle}
/>
<ProductList products={products} />
</section>
)
}
El encabezado
Un componente de encabezado dedicado mantiene el estilo del título y el subtítulo en un solo lugar, de modo que cada sección de la aplicación tiene un aspecto consistente sin que nadie tenga que volver a crear el marcado.
export function SectionHeading({ title, subtitle }) {
return (
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
)
}
La lista
La lista tiene una única responsabilidad: iterar sobre la colección y renderizar una tarjeta para cada elemento, con una key estable.
export function ProductList({ products }) {
return (
<div className="grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
)
}
Fíjese que no tiene idea de cómo se ve un producto. Solo sabe cómo organizar una lista, lo que facilita su reutilización con una tarjeta diferente más adelante.
La tarjeta
La tarjeta muestra un único elemento y nada más.
export function ProductCard({ product }) {
return (
<div>
<img src={product.image} />
<h3>{product.title}</h3>
<p>${product.price}</p>
</div>
)
}
La presentación es su única preocupación. En el código real también se le daría a la imagen un atributo alt significativo, algo que el ejemplo mínimo omite.
Comparación de los dos enfoques
Dejado a sus valores predeterminados, un agente tiende a generar algo similar a esto:
function Products({ products }) {
return (
<section>
<h2>Latest Products</h2>
<div className="grid">
{products.map(product => (
<article key={product.id}>
<img src={product.image} />
<h3>{product.title}</h3>
<p>${product.price}</p>
</article>
))}
</div>
</section>
)
}
Técnicamente no hay nada incorrecto en ello. El problema surge más tarde, cuando otra página necesita el mismo estilo de encabezado, diseño de tarjeta o cuadrícula. En ese momento hay que duplicar el marcado o extraer componentes de una base de código que ya se ha desarrollado alrededor de esa duplicación.
Con la división en cuatro componentes, la página misma se reduce a datos y composición:
function HomePage() {
return (
<>
<ProductSection
title="Hot Products"
subtitle="Most popular products right now"
products={hotProducts}
/>
<ProductSection
title="New Products"
subtitle="Recently added to our store"
products={newProducts}
/>
<ProductSection
title="Trending Products"
subtitle="What everyone is buying"
products={trendingProducts}
/>
<ProductSection
title="Discounted Products"
subtitle="Best deals this week"
products={discountedProducts}
/>
</>
)
}
La estructura del archivo permanece pequeña y predecible:
components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx
Cada bloque constructivo ahora tiene un lugar definido. El mismo encabezado funciona en cualquier sección, otra fila de producto es un ProductSection más, y se puede crear una página completamente nueva con las mismas partes.
Estímulo para ello
En lugar de pedirle al agente que cree una sección de producto, especifique la estructura: “Use aquí el patrón UI de 4 componentes: una ProductSection que compone un SectionHeading y una ProductList, la cual muestra los elementos ProductCard; cada componente debe realizar exactamente una función”. Esa sola oración cambia la forma en que se genera el resultado, y este resulta mucho más fácil de leer, reutilizar y mantener.
Patrón 2: mover la lógica a hooks personalizados
La mayoría de los desarrolladores de React conocen los hooks personalizados, pero cuando es el agente quien escribe el código, estos se olvidan con facilidad. Al solicitar una nueva pantalla, el agente suele guardar el estado, los efectos, las llamadas a API y los manejadores de eventos dentro del componente. Después de unas pocas iteraciones, el archivo supera las 300 líneas debido a que la interfaz de usuario y la lógica están entrelazadas.
A veces está bien mantener la lógica dentro del componente. Sin embargo, cuando se necesita esa misma lógica en otro lugar o comienza a dificultar la comprensión de lo que muestra el componente, extraerla en forma de hook suele ser la mejor opción. Algunos ejemplos típicos son:
useDebounce()para retrasar la entrada de búsqueda antes de enviar una solicituduseApi()para gestionar la obtención de datos junto con los estados de carga y erroruseLocalStorage()para conservar el estado en el navegadoruseClickOutside()para cerrar menús desplegables o modales cuando el usuario hace clic en otro lugarusePagination()para administrar el estado de las páginasuseToggle()para manejar estados booleanos como una barra lateral abierta
Lógica de búsqueda dentro del componente
Considere un cuadro de búsqueda de productos. Una versión típica genera y mantiene en estado tanto la consulta original como una copia con retardo, utiliza un efecto con un temporizador para actualizar el valor con retardo y otro efecto para obtenerlo cada vez que cambia:
function SearchProducts() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/products?search=${debouncedQuery}`);
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
)
}
Esto funciona. Pero si tres pantallas necesitan el mismo comportamiento de retardo, o bien hay que pegarlo tres veces o pagar más tarde, tanto en tiempo como en tokens, para extraerlo. También hay dos observaciones sobre la robustez: la consulta debe pasar por encodeURIComponent antes de ser incluida en una URL, y una solicitud real debe manejar respuestas fuera de orden, por ejemplo con un AbortController en la limpieza del efecto.
Extraer un gancho de retardo
La lógica del temporizador se traslada a su propio gancho, que recibe un valor y un retraso y devuelve el valor con retardo:
// hooks/useDebounce.ts
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
La función de limpieza borra el temporizador pendiente cada vez que el valor cambia antes de que transcurra el retraso, y eso es lo que la convierte en un mecanismo de atenuación de eventos en lugar de un simple retraso. Incluir delay en el array de dependencias mantiene al hook funcional si alguien lo modifica.
Luego, el componente se reduce a su propósito real:
function SearchProducts() {
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query)
useEffect(() => {
if (!debouncedQuery) return
fetch(`/api/products?search=${debouncedQuery}`)
}, [debouncedQuery])
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
El renderizado permanece en el componente y la atenuación de eventos se maneja en el hook, cada uno con una responsabilidad específica.
Cuándo vale la pena usar un hook
No toda llamada a useState() merece tener su propio hook. Son buenas señales para hacerlo:
- la misma lógica aparece o aparecerá en varios componentes;
- el componente se está volviendo demasiado largo;
- la funcionalidad combina varias llamadas a
useState(), efectos o manejadores de eventos;
Solicitarlo explícitamente
Dado que los agentes tienden por defecto a incluir la lógica directamente en el código, indícalo claramente: “Coloca el comportamiento reutilizable de esta función en ganchos personalizados, como por ejemplo useApi(), useDebounce() o useLocalStorage(), y deja que el componente se encargue únicamente del marcado”. Esta regla general aplica a los cuatro patrones: cuanto más contexto arquitectónico proporciones, mejor será el código generado.
Patrón 3: un componente polimórfico en lugar de casi duplicados
El objetivo de los componentes reutilizables no es tener más de ellos, sino evitar duplicados. Con frecuencia, los agentes generan familias como esta:
Button.tsx
LinkButton.tsx
IconButton.tsx
Estos comparten casi todo su estilo. Lo que difiere es el elemento que renderizan: a veces un verdadero <button>, a veces un enlace, a veces un enlace de router y a veces un control que solo muestra una icono. El patrón polimórfico reemplaza a toda la familia por un único componente que puede renderizarse como el elemento que requiera la situación.
La versión duplicada
Una pareja generada típica se ve así:
// Button.tsx
export function Button({ children }) {
return (
<button className="btn">
{children}
</button>
)
}
// LinkButton.tsx
export function LinkButton({ href, children }) {
return (
<a href={href} className="btn">
{children}
</a>
)
}
Los estilos se repiten únicamente porque el elemento HTML subyacente cambió.
Un Button polimórfico en TypeScript
La versión escrita acepta una propiedad opcional as, cuyo tipo es un ElementType genérico que toma como valor por defecto "button". Sus demás propiedades se derivan del elemento o componente al que hace referencia as, utilizando ComponentPropsWithoutRef; se omite a as mismo para evitar conflictos.
type ButtonProps<E extends ElementType = "button"> = {
as?: E
children: ReactNode
variant?: "primary" | "outline"
} & Omit<React.ComponentPropsWithoutRef<E>, "as">
const Button = <E extends ElementType = "button">({
as,
children,
variant = "primary",
...rest
}: ButtonProps<E>) => {
const Component = as || "button"
return (
<Component {...rest}>
{children}
</Component>
)
}
Dado que los tipos de las propiedades siguen el formato E, pasar as="a" hace que href sea válido, y pasar un elemento Link del router hace que su propiedad to sea válida, todo ello con verificación de tipos. El ejemplo está simplificado: variant se acepta pero aún no se aplica, por lo que una versión completa lo asociaría a un nombre de clase y combinaría cualquier className proporcionado. También es recomendable establecer como valor por defecto type="button" al renderizar un botón nativo, para evitar que envíe accidentalmente el formulario circundante.
El mismo componente cubre ahora todos los casos:
<Button>
Save
</Button>
<Button
variant="outline"
>
Cancel
</Button>
<Button
as="a"
href="/pricing"
>
Pricing
</Button>
<Button
as={Link}
to="/dashboard"
>
Dashboard
</Button>
<Button
as="button"
onClick={handleDelete}
>
Delete
</Button>
Guardar, Cancelar, un enlace de precios, una ruta de panel de control y una acción de eliminación comparten el mismo diseño; solo cambia el elemento o comportamiento que se muestra.
Cuándo utilizarlo
Una prueba sencilla: si la interfaz de usuario permanece igual pero el elemento subyacente cambia, el componente es un buen candidato para ser polimórfico. Ejemplos comunes:
- botones que a veces funcionan como enlaces
- componentes tipográficos que renderizan
h1,h2,pospan - tarjetas que a veces son un
articley otras veces un enlace clickeable - contenedores de layout que se renderizan como
section,divomain
Esto no es una preferencia idiosincrática. Bibliotecas de componentes como Radix UI, Chakra UI y Flexy UI dependen en gran medida de la misma idea para crear sistemas de diseño flexibles.
Fomentarlo
Los desarrolladores tienden a crear un componente nuevo para cada pequeña variación, así que indíqueles el patrón por su nombre: “Escriban un Button polimórfico que acepte una propiedad as y pueda renderizar un button nativo, una etiqueta a o un Link de React Router; gestionen las variantes y tamaños dentro de ese único componente en lugar de crear otros nuevos”. De esta manera se obtienen menos componentes, menos duplicación y un sistema de diseño más coherente.
Patrón 4: consolidar el estado relacionado con un reducer
Probablemente este sea el patrón que más pasan por alto los desarrolladores. Su opción predeterminada es usar un useState() separado para cada valor, lo cual está bien para componentes simples. Sin embargo, a medida que una función crece, uno acaba lidiando con cinco o seis elementos de estado que dependen unos de otros:
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState([]);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("latest");
Una vez que están en uso la paginación, el filtrado, el ordenamiento, la carga y el manejo de errores, una acción del usuario suele tener que actualizar varios de estos elementos al mismo tiempo, lo que hace que el componente sea difícil de seguir.
Setters coordinados
Así es como se ve una función fetch con estados separados:
setLoading(true)
try {
const data = await fetchProducts()
setProducts(data)
setError(null)
} catch {
setError("Something went wrong")
} finally {
setLoading(false)
}
Varios setters se activan simultáneamente para un único evento lógico. Ese agrupamiento es una señal clásica de que un reducer expresaría mejor esa lógica.
Modelado de transiciones como acciones
Con un reducer, describes la estructura del estado una sola vez y listas las transiciones que puede realizar. La sintaxis le resultará familiar si has utilizado Redux:
const initialState = {
loading: false,
products: [],
error: null,
};
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return {
...state,
loading: true,
};
case "FETCH_SUCCESS":
return {
loading: false,
products: action.payload,
error: null,
};
case "FETCH_ERROR":
return {
...state,
loading: false,
error: action.payload,
};
default:
return state;
}
}
FETCH_START establece la bandera de carga, FETCH_SUCCESS almacena los productos y elimina tanto la bandera de carga como el error, mientras que FETCH_ERROR registra el error. En el caso de éxito se devuelve un objeto nuevo completo; si más adelante agrega campos como page o sortBy al estado, recuerde usar ...state allí también, de lo contrario esos valores se perderán.
Luego el componente envía acciones en lugar de llamar a los setters:
const [state, dispatch] = useReducer(
reducer,
initialState
)
dispatch({ type: "FETCH_START" })
dispatch({
type: "FETCH_SUCCESS",
payload: products,
})
Toda actualización ahora pasa por una sola función. Para comprender cómo funciona esta característica, se lee el reducer en lugar de buscar llamadas a setState por todo el componente, y como el reducer es una función pura resulta sencillo probarlo con pruebas unitarias.
Cuándo es adecuado usar un reducer
Los reducers no son adecuados para todos los componentes. Son útiles cuando:
- Varias modificaciones del estado ocurren al mismo tiempo;
- La funcionalidad cuenta con muchas acciones distintas;
- Las transiciones de estado se vuelven difíciles de rastrear;
- La funcionalidad es un carrito, un asistente de múltiples pasos, un flujo de inicio de sesión, un formulario grande o un panel de control complejo.
Para un modal o un botón de alternar, useState() sencillo sigue siendo la mejor opción.
Solicitar su uso
Sin orientación, un agente casi siempre generará una serie de llamadas a useState(). Pida la estructura deseada: “Implemente esto con useReducer: defina tipos de acción con nombre, coloque cada cambio de estado dentro de la función del reducer y haga que el componente solo se encargue de renderizar y enviar acciones”. Cuanto mayor sea la funcionalidad, más beneficios tendrá este enfoque.
Puntos clave
- Los agentes de IA generan de forma fiable código React funcional, pero tienden a usar componentes monolíticos, lógica incrustada, variantes casi idénticas y estado disperso.
- Cuatro patrones abordan estos comportamientos por defecto: composición de secciones, ganchos personalizados, componentes polimórficos y reducidores.
- Cada patrón tiene un desencadenante claro; aplíquelo cuando aparezca dicho desencadenante, y no por reflejo.
- Asignar nombres al patrón y a los elementos que espera en su solicitud es la forma más sencilla de obtener una arquitectura mejor del agente.
- Generar código se vuelve cada vez más fácil año tras año; decidir cómo debe estructurarse sigue siendo tarea del desarrollador, y conocer estos patrones es lo que le permite hacerlo.
Lecturas relacionadas
- Tres patrones de TypeScript que mejoran la arquitectura de React App — Aprenda cómo los patrones Repository, Observer y Builder utilizan el sistema de tipos de TypeScript para crear bases de código en React y Next.js más limpias y fáciles de mantener.
- Arreglando la sobrecarga de propiedades en React con composición y slots — Entienda por qué las propiedades de React con muchas configuraciones generan deudas de mantenimiento, y cómo la inversión de control, la composición y los slots permiten crear componentes verdaderamente reutilizables.