Cuando los componentes React reutilizables salen mal: la explosión de props y su solución
Vea cómo el reuso prematuro convierte a un componente React simple en una carga excesiva de props, y cómo la duplicación, los componentes compuestos y la Regla de Tres lo evitan.
Se supone que los componentes compartidos ahorran tiempo, pero muchos conjuntos de código frontend tienen un componente que todos temen editar: un <Modal /> o <Card /> con decenas de propiedades, donde arreglar un error en una pantalla afecta negativamente a otras tres. Ese resultado rara vez se debe a descuido; es el punto final natural de reutilizar la interfaz de usuario demasiado pronto. Este artículo explica cómo un componente se convierte en una carga, detalla por qué la duplicación de la interfaz suele ser la opción más económica y presenta dos herramientas prácticas para evitarlo: los componentes compuestos y la Regla de los Tres.
Cómo un atajo inofensivo se convierte en un componente con treinta y dos propiedades
El patrón suele comenzar con una solicitud razonable. Un diseñador entrega una pantalla de pago con un diálogo que casi coincide con el diálogo de confirmación en la página de configuraciones. Las diferencias son pequeñas: las acciones se encuentran a la izquierda, el título tiene una insignia naranja junto a él y un divisor delgado separa los botones del contenido.
Durante la revisión, alguien señala que ya existe un <Modal /> y sugiere reutilizarlo. Unas horas después llega una solicitud de integración que agrega cuatro propiedades: hasOrangeBadge, alignActionsLeft, showDividerLine y badgeText.
Repite eso una docena de veces al año. El mismo modal ahora requiere treinta y dos propiedades, contiene quince expresiones ternarias anidadas y doce flags booleanos, y depende de tres hooks useEffect para mantener las animaciones internas sincronizadas con diversas combinaciones de propiedades. Luego, alguien ajusta una línea relacionada con el relleno para corregir un error de facturación y, sin querer, daña el modal en cuatro pantallas no relacionadas. El esfuerzo por mantener el código limpio y reutilizable ha generado un componente del que nadie quiere hacerse responsable.
Por qué DRY se aplica de manera diferente a la interfaz de usuario
“No te repitas” es un consejo sensato por una razón. Cuando la lógica de negocio, como el cálculo de pagos o la verificación de permisos, se duplica, una corrección aplicada a una copia deja las demás dañadas, y estas copias van perdiendo coherencia.
Los componentes de interfaz cambian por diferentes razones. Las reglas empresariales se modifican cuando cambia el dominio. Las interfaces evolucionan con las experiencias de los usuarios, los puntos de ruptura responsivos, los requisitos de accesibilidad, las decisiones sobre el producto y los experimentos de diseño, y estas fuerzas actúan de forma independiente en cada pantalla.
Dos elementos que se parecen no son necesariamente el mismo concepto. Fusionar dos patrones de interfaz en un único componente compartido antes de que sus diseños estén definidos crea una interconexión entre funcionalidades que antes no existía. A partir de entonces, un pequeño ajuste en el diseño de la onboarding puede requerir volver a probar los parámetros de facturación y análisis, simplemente porque estos muestran el mismo componente. En ese caso, la reutilización termina siendo perjudicial.
Anatomía de la explosión de props
Es útil observar cómo se produce el cambio poco a poco, sprint por sprint. El punto de partida es una tarjeta con un contrato claro y mínimo: un título, una descripción y un manejador de clic opcional.
interface CardProps {
title: string;
description: string;
onClick?: () => void;
}
La implementación es igualmente sencilla y fácil de leer:
export function Card({ title, description, onClick }: CardProps) {
return (
<div className="card" onClick={onClick}>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}
No hay nada incorrecto en este componente. Luego, en el sprint 4, el departamento de marketing quiere tarjetas de blog con una imagen en la parte superior, por lo que aparecen dos propiedades opcionales para imágenes:
interface CardProps {
// ...
imageUrl?: string;
imageAlt?: string;
}
En el sprint 7, el equipo del panel de control solicita un botón de acción en la esquina, visible solo en los elementos activos. Eso requiere una bandera, un ícono y un manejador:
interface CardProps {
// ...
hasTopRightAction?: boolean;
topRightActionIcon?: React.ReactNode;
onTopRightAction?: () => void;
}
Para el sprint 12, el equipo de análisis desea una segunda línea debajo del título, un distintivo de estado en tres colores y un pie de página que pueda expandirse, lo que añade seis propiedades más:
interface CardProps {
// ...
subtitle?: string;
badgeText?: string;
badgeVariant?: "success" | "warning" | "danger";
isExpandable?: boolean;
expandedContent?: React.ReactNode;
defaultExpanded?: boolean;
}
Para el sprint 20, la función de renderizado se ha convertido en una serie de condiciones. El elemento raíz calcula su clase a partir de una bandera, y la imagen se renderiza solo cuando existe una URL:
export function Card(props: CardProps) {
return (
<div
className={`card ${
props.isExpandable ? "card-expandable" : ""
}`}
>
{props.imageUrl && (
<img src={props.imageUrl} alt={props.imageAlt} />
)}
El contenedor del encabezado se abre con el título:
<div className="card-header">
<div>
<h3>{props.title}</h3>
seguido por un subtítulo que solo aparece si se proporciona:
{props.subtitle && <h4>{props.subtitle}</h4>}
</div>
La acción en la esquina superior derecha depende de una bandera booleana en lugar de de si existe un manejador, por lo que es posible establecer la bandera y olvidarse del ícono, o pasar un ícono y olvidarse de la bandera:
{props.hasTopRightAction && (
<button onClick={props.onTopRightAction}>
{props.topRightActionIcon}
</button>
)}
</div>
La insignia construye un nombre de clase a partir de su variante y recurre silenciosamente a una variante default que el tipo ni siquiera enumera:
{props.badgeText && (
<span
className={`badge badge-${
props.badgeVariant || "default"
}`}
>
{props.badgeText}
</span>
)}
La descripción, el único elemento restante del diseño original, se encuentra en el medio:
<p>{props.description}</p>
Y el pie de página expandible cierra el componente. Obsérvese que isExpandable controla tanto la clase raíz como el pie de página, mientras que defaultExpanded de la interfaz no se utiliza en ningún lugar del marcado:
{props.isExpandable && (
<div className="card-footer">
{props.expandedContent}
</div>
)}
</div>
);
}
Esas pequeñas incoherencias son típicas. Una vez que un componente tiene tantas flags, nadie puede ver todas las combinaciones al mismo tiempo, y aparecen estados inválidos o parcialmente implementados. Cada usuario de <Card /> debe aprender una lista cada vez más larga de opciones y averiguar qué combinaciones son compatibles. La abstracción resulta ahora más difícil de entender que el marcado simple que se suponía reemplazar.
A menudo, duplicar es más económico que recurrir a una abstracción incorrecta
Una frase muy conocida en el diseño de software, popularizada por Sandi Metz, lo expresa sin rodeos: "La duplicación es mucho más económica que una abstracción incorrecta." El trabajo en el frontend es donde ese consejo rinde más frutos.
Cuando dos componentes solo se parecen entre sí, mantenerlos separados suele ser la opción más segura. Supongamos que BillingModal y OnboardingModal son componentes independientes:
- Un cambio en
BillingModalno puede afectar aOnboardingModal. - Al eliminar la función de onboarding, también se elimina su modal y toda la lógica específica de esa función.
- Cada modal evoluciona según sus propios requisitos.
- Un pequeño cambio visual no requiere comprender docenas de propiedades que pertenecen a otras funciones.
La duplicación de JSX cuesta unas pocas líneas adicionales. La abstracción incorrecta cuesta mucho más en tiempo de depuración, pruebas de regresión y mantenimiento continuo. No todo fragmento repetido de marcado merece convertirse en un componente compartido.
Componentes compuestos: composición sobre configuración
La reutilización genuina sigue teniendo su lugar, especialmente en un sistema de diseño. La clave está en cómo el componente compartido ofrece flexibilidad. En lugar de un único componente controlado por una lista cada vez más larga de valores booleanos, un componente compuesto ofrece un conjunto de partes pequeñas y relacionadas que los usuarios pueden armar por sí mismos.
Aquí hay un diálogo de facturación creado de esta manera. El componente responsable mantiene el estado abierto localmente:
export function BillingSettings() {
const [isOpen, setIsOpen] = useState(false);
El elemento raíz <Modal> recibe únicamente lo que realmente controla: el estado abierto y una función de callback para los cambios, mientras que la capa superpuesta es un componente independiente:
return (
<Modal open={isOpen} onOpenChange={setIsOpen}>
<Modal.Overlay />
El área de contenido contiene un encabezado, y el encabezado incluye un título:
<Modal.Content>
<Modal.Header>
<Modal.Title>Update Billing Plan</Modal.Title>
Un distintivo es simplemente otro elemento hijo del encabezado, siendo su variante expresada como una propiedad exclusiva del distintivo:
<Modal.Badge variant="warning">
Action Required
</Modal.Badge>
</Modal.Header>
El cuerpo alberga contenido arbitrario, comenzando con un mensaje:
<Modal.Body>
<p>
Please update your payment method to avoid account suspension.
</p>
y continuando con un formulario específico de una función del cual el modal en sí no sabe nada:
<CreditCardForm />
</Modal.Body>
El pie de página controla su propio alineamiento y contiene botones comunes, siendo el primero de ellos el que cierra el diálogo:
<Modal.Footer align="right">
<Button
variant="ghost"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
La acción principal completa el pie de página y toda la estructura:
<Button variant="primary">
Save Changes
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
La diferencia estructural es importante. Cuando un modal necesita una insignia, no existe la propiedad showBadge que agregar; se renderiza <Modal.Badge />. Cuando una pantalla necesita contenido personalizado, se coloca donde corresponde en lugar de inventar otra flag. Las ventajas son evidentes:
- Sin sobrecarga de propiedades. Un modal sin insignia ni pie de página simplemente no renderiza
<Modal.Badge />ni<Modal.Footer />. - Más flexibilidad. Un ícono junto al título se coloca dentro de
<Modal.Header>; no se necesita ninguna propiedad nueva. - Estilización aislada. Modificar
<Modal.Badge />no implica tocar el contenedor principal.
En el fondo, los componentes compuestos suelen compartir estado como open a través del contexto de React, lo que permite que <Modal.Footer> o un botón de cierre accedan a él sin necesidad de seguir la cadena de propiedades. La composición transfiere el control al consumidor sin convertir el componente en un objeto de configuración. Este enfoque no es gratuito: el sistema de diseño debe documentar dónde se pueden anidar las partes, y los consumidores deben escribir un poco más de marcado por uso. Para conocer otra perspectiva sobre esta refactorización, consulte nuestra guía sobre cómo solucionar la sobrecarga de propiedades con composición y slots.
La regla de los tres para extraer componentes compartidos
Una heurística sencilla ayuda a decidir cuándo está justificada una abstracción: espere hasta la tercera aparición real.
Primera aparición: escríbala en su lugar
Ponga el marcado directamente dentro de la vista que lo necesita. Resista la tentación de abstraer y mantenga los estilos y la estructura junto a la función correspondiente.
Segunda aparición: copie y adapte
Cuando otra pantalla necesite algo similar, resulta muy tentador crear un componente global. En lugar de eso, copie el marcado y ádíguelo al nuevo contexto. Ahora cuenta con dos ejemplos concretos, y con el tiempo podrá observar dónde realmente difieren en lugar de especular sobre requisitos futuros.
Tercera aparición: extraiga con evidencia
Cuando una tercera pantalla distinta necesita el mismo patrón visual y de comportamiento, finalmente se cuenta con suficientes evidencias para determinar qué es realmente compartido y qué difiere. Esperar tanto tiempo permite identificar las verdaderas invariantes, es decir, las partes que permanecen iguales en todo momento, y las verdaderas variaciones que deben mantenerse flexibles. Esas variaciones son candidatas ideales para los espacios de composición en lugar de propiedades booleanas.
El objetivo no es evitar los componentes reutilizables, sino evitar crear abstracciones basadas en suposiciones.
Puntos clave
- Esté atento a la proliferación de propiedades. Cuando un componente sigue ganando propiedades de configuración para casos de uso no relacionados, cuestione la abstracción antes de agregar otra.
- Prefiera la composición sobre la configuración. Los hijos, los espacios y los componentes compuestos brindan flexibilidad sin necesidad de crear una nueva propiedad booleana cada vez que cambia el diseño.
Una buena arquitectura de frontend no se mide por el menor número de líneas de código, sino por cuán seguramente puede modificarse el código sin causar efectos secundarios en toda la aplicación. A veces, el mejor componente no es aquel que se reutiliza en todas partes, sino aquel que se deja tranquilo.
Lecturas relacionadas
- Cortar los componentes React con demasiadas propiedades y God Components hasta un tamaño manejable — Aprenda siete patrones concretos de refactoring para descomponer componentes React sobrecargados al aislar el estado, la obtención de datos, los permisos y la lógica de carga en lugar de simplemente dividir archivos.
- Arreglar la sobrecarga de propiedades en React con composición y slots — Entienda por qué las propiedades de React con mucha configuración generan deuda de mantenimiento, y cómo la inversión de control, la composición y los slots permiten crear componentes verdaderamente reutilizables.