Diseñar componentes frontend basados en responsabilidades, no en reutilización
Aprenda por qué organizar los componentes frontend según una asignación clara de responsabilidades, en lugar de priorizar la mayor reutilización posible del código, facilita aislar y mantener los cambios en las funcionalidades.
Un frontend es más fácil de mantener cuando los límites de los componentes se definen en función de responsabilidades claras, y no según cuánto código se pueda compartir teóricamente.
El problema de nuestro frontend nunca fue que los componentes individuales se hubieran vuelto demasiado grandes; la mayoría de ellos eran realmente compactos.
Teníamos una biblioteca sólida con botones reutilizables, tarjetas, modales, elementos de tabla, controles de formulario, ganchos, ayudantes de API, esquemas compartidos y una organización ordenada de las carpetas. Al revisar el repositorio, todo parecía estar bien organizado: la reutilización era abundante, las duplicaciones evidentes eran raras, y el nivel de abstracción confería a la base de código un aire de madurez.
El verdadero problema surgía cada vez que era necesario cambiar alguna funcionalidad.
Incluso un requisito menor podría obligarnos a buscar entre varios niveles no relacionados al mismo tiempo: un componente a nivel de pantalla, un formulario de uso general, algo de lógica compartida envuelta en un hook, un fragmento de código de red, un diálogo destinado a muchos casos de uso y un esquema utilizado para validar la entrada. Un componente que inicialmente se compartió porque dos pantallas se parecían iría acumulando gradualmente indicadores, propiedades de callback, reglas de validación condicionales, diseños alternativos y casos especiales para flujos de trabajo sobre los que nunca estaba diseñado para tener conocimiento.
El código era reutilizable, pero la responsabilidad estaba dispersa por todo el sistema.
Lo que finalmente simplificó el frontend no fue un nuevo esquema de nomenclatura de carpetas, una biblioteca diferente de gestión de estado ni un límite estricto de número de líneas por componente. Fue un cambio en lo que esperábamos que representara realmente el límite de un componente.
En lugar de preguntarnos únicamente:
¿Se puede reutilizar este componente en otro lugar?
Comenzamos a preguntarnos:
¿Qué responsabilidad debería asumir este componente?
Y una segunda pregunta se volvió rápidamente igual de importante:
Cuando esa responsabilidad necesita cambiar, ¿cuánto código no relacionado se ve afectado también?
Esa pregunta transformó nuestra arquitectura en una jerarquía sencilla, donde la capa intermedia soportaba la mayor carga.
Las primitivas reutilizables se encargaban únicamente de la presentación. Los componentes funcionales contenían el conocimiento sobre los flujos de trabajo empresariales. Las páginas y otros límites de composición determinaban cómo se combinaban todas las partes.
Trabajar en la interfaz frontal se volvió más fácil, no porque elimináramos toda duplicación, sino porque los cambios a nivel de funcionalidades se volvieron más localizados y menos partes del códigobase necesitaban entenderse entre sí.
1. Reutilizar no era lo mismo que simplificar
“Evita duplicar componentes” es un consejo que generalmente suena correcto.
Y con frecuencia, lo es.
Cuando dos pantallas comparten el mismo botón, campo de entrada o estructura modal, unificar esa implementación reduce las incoherencias y el mantenimiento adicional.
Los problemas comienzan cuando la similitud visual se confunde con responsabilidades compartidas.
Imagina dos funcionalidades separadas que ambas necesitan un diálogo de confirmación.
La primera implementación podría verse así:
<ConfirmationDialog
title="Delete project?"
onConfirm={deleteProject}
/>
Luego, un flujo de trabajo diferente necesita el mismo diálogo, pero sin botón de cancelar.
Un tercer caso requiere un mensaje de advertencia personalizado.
Otro más necesita realizar una verificación asíncrona antes de que el usuario pueda confirmar.
Pronto, el componente evoluciona hasta convertirse en algo similar a esto:
<ConfirmationDialog
title="Delete project?"
variant="danger"
showCancel
disableConfirm={isDeleting}
customWarning={warning}
onBeforeConfirm={validateDeletion}
onConfirm={deleteProject}
onSpecialAction={archiveInstead}
useLegacyLayout={false}
/>
Técnicamente, el componente sigue siendo reutilizado.
Pero desde el punto de vista arquitectónico, ha pasado a funcionar como un lenguaje de configuración.
Cada nuevo flujo de trabajo pide al componente compartido que admita una variante más. Se vuelve más flexible, pero esa flexibilidad se logra a costa de incluir más comportamientos ocultos detrás de un conjunto cada vez mayor de propiedades.
Es precisamente aquí donde el costo de la abstracción difiere del costo de la duplicación.
La duplicación se manifiesta de inmediato: dos componentes casi idénticos aparecen uno al lado del otro en el repositorio, claramente visibles para cualquiera que lea el código.
Una abstracción mal elegida parece barata a primera vista. Su verdadero costo solo se hace evidente más tarde, una vez que las funcionalidades que sirve comienzan a evolucionar en direcciones diferentes.
La duplicación te cuesta algo que puedes ver de inmediato. Elegir la abstracción incorrecta te cuesta algo que solo notas cuando las funcionalidades “similares” dejan de cambiar al unísono.
Esa comprensión cambió la forma en que evaluábamos la reutilización.
El JSX repetido ya no despertaba sospechas automáticas. La pregunta que importaba más era si dos fragmentos de código compartían realmente una responsabilidad, o simplemente se parecían en ese momento.
2. Empezamos a diseñar componentes en torno a la responsabilidad
El cambio arquitectónico más significativo fue permitir que el árbol de componentes reflejara la responsabilidad real en lugar de buscar potencial de reutilización.
Tomemos como ejemplo un flujo de trabajo de configuraciones.
Cada capa en ese flujo de trabajo existe por una razón específica propia.
UserSettingsPage integra esta funcionalidad en el resto de la aplicación. Puede estar al tanto del enrutamiento, del diseño a nivel de página o de qué cuenta se está editando actualmente.
UserSettingsForm contiene el conocimiento necesario para gestionar el flujo de trabajo. Entiende qué datos pertenecen a la configuración del usuario, cómo se relacionan los campos entre sí, qué significa enviar un formulario y cómo deben mostrarse los errores al usuario.
Button, Input y Checkbox no tienen ni idea de que existan las configuraciones del usuario. Siguen siendo reutilizables precisamente porque sus funciones son realmente de uso general.
Esa capa intermedia de funcionalidades es la que los equipos suelen perder primero.
En un frontend que persigue la reutilización de forma excesiva, los desarrolladores a menudo pasan directamente de una página a componentes genéricos, omitiendo cualquier capa específica para una función concreta.
Cuando eso ocurre, el comportamiento empresarial ya no tiene un lugar evidente donde alojarse.
En su lugar, acaba filtrándose en la configuración.
El formulario genérico comienza a saber que un flujo de trabajo necesita un campo de nombre de usuario mientras que otro no. La tabla genérica empieza a saber qué acciones son válidas para un tipo específico de objeto de dominio. Un modal compartido adquiere comportamientos especiales simplemente porque un flujo de trabajo decide mostrar su contenido dentro de dicho modal.
La capa reutilizable absorbe gradualmente conocimientos sobre el negocio que nunca fue diseñada para manejar.
Diseñar teniendo en cuenta las responsabilidades invierte esa presión.
A los componentes específicos para cada función se les permite comprender la funcionalidad a la que sirven.
Las primitivas reutilizables se mantienen deliberadamente al margen de cualquier aspecto específico de una funcionalidad.
Esa separación facilita la comprensión de toda la arquitectura, ya que cada capa solo necesita conservar una cantidad menor de contexto.
3. Los componentes de propósito específico ganaron su lugar
Uno de los hábitos más difíciles de cambiar fue asumir que un componente utilizado en un solo lugar representaba un defecto de diseño.
Tomemos este ejemplo:
<OrderCancellationDialog />
Solo con el nombre ya se sabe que este componente fue creado para un único flujo de trabajo.
Compárelo con algo como:
<ConfirmationDialog />
Este segundo nombre sugiere mayor reutilización.
Pero cancelar un pedido puede requerir, con el tiempo, mucho más que una simple confirmación.
Puede que el usuario necesite elegir una razón para la cancelación. Es posible que aparezcan en la pantalla los detalles del reembolso. Algunos pedidos podrían no ser elegibles para cancelación. Las verificaciones de permisos pueden variar. El texto de advertencia podría cambiar según el estado de procesamiento. La acción en sí misma podría tener su propio manejo asincrónico de errores.
Al integrar todo eso en un ConfirmationDialog genérico, el componente compartido se convierte gradualmente en un especialista en lo que realmente implica cancelar un pedido.
Una estructura mejor suele ser la siguiente:
OrderCancellationDialog se hace responsable de todo el flujo de trabajo.
Puede conocer los permisos, las razones de cancelación, el estado asincrónico, el texto del reembolso y los estados de error, ya que todo eso pertenece naturalmente al mismo conjunto.
Mientras tanto, los componentes de nivel inferior siguen siendo reutilizables precisamente porque no poseen conocimiento alguno sobre los pedidos.
Esa división cambió la cantidad de decisiones relacionadas con los componentes que se tomaban.
Ya no era necesario justificar la existencia de un componente contando cuántos lugares lo importaban.
La reutilización no es un requisito para todos los componentes. Algunos componentes existen únicamente para dar un lugar adecuado a una parte específica de la lógica empresarial.
Un componente con un único uso aún puede ser útil si facilita la localización, comprensión y modificación posterior de un flujo de trabajo.
Esa claridad local suele superar el costo que implica forzar la inclusión de un segundo flujo de trabajo no relacionado en una abstracción compartida solo porque las APIs superficiales parecen similares.
4. La lógica empresarial permaneció al margen de los componentes de visualización
El mismo problema de responsabilidades surgió nuevamente en lo que respecta al manejo de datos y las cuestiones de infraestructura.
Un componente que comienza siendo puramente visual puede ir asumiendo gradualmente responsabilidades como la obtención de datos, la lectura de parámetros URL, la ejecución de mutaciones, la activación de notificaciones, el manejo de la navegación, la gestión del caché y el seguimiento del estado del flujo de trabajo.
Imagínese un ProjectTable que con el tiempo se encarga de todo esto:
ProjectTable
├── fetch projects
├── read query parameters
├── filter projects
├── manage loading state
├── render rows
├── delete projects
├── show notifications
└── navigate after actions
El nombre sigue sugiriendo “tabla”.
Pero ahora el componente comprende una buena parte de las funcionalidades relacionadas con él.
Eso hace que sea difícil reutilizarlo en otros lugares, ya que al reutilizar la tabla también se transfieren las suposiciones sobre la obtención de datos, las mutaciones, la navegación, el caché y los efectos secundarios.
Una estructura organizada en torno a responsabilidades específicas podría verse más así:
El código a nivel de funcionalidad determina cómo se ve el “cargado”, cómo se manejan los errores, qué efecto tiene la eliminación, qué filtros se aplican a este flujo de trabajo específico y si el usuario debe ser redirigido posteriormente.
ProjectTable se concentra únicamente en mostrar los datos del proyecto e informar sobre las acciones del usuario.
Eso no significa que los componentes de presentación tengan que quedar desprovistos por completo de lógica.
Tratarlo como una regla estricta solo recrea el mismo problema bajo otra forma. Una tabla puede legítimamente contener estado de interacción local, como qué filas están expandidas o qué columnas son visibles, ya que ese estado realmente pertenece a la tabla.
La idea guía más adecuada es:
No permitas que el conocimiento a nivel de infraestructura se extienda más allá del árbol de componentes de lo que realmente necesita la funcionalidad.
El objetivo no es eliminar por completo la lógica de los componentes.
Sino mantenerla cerca de aquella responsabilidad que le da sentido a dicha lógica.
5. Ensamblar piezas en lugar de alternar opciones
Uno de los cambios más notorios ocurrió cuando dejamos de responder a cada nueva exigencia con otro prop.
Los componentes genéricos tienden a crecer mediante la configuración.
Una tabla podría comenzar de forma sencilla:
<DataTable rows={projects} columns={columns} />
Luego las exigencias se acumulan:
<DataTable
rows={projects}
columns={columns}
selectable
sortable
paginated
editableRows
showBulkActions
enableExport
customToolbar={toolbar}
rowActions={rowActions}
emptyState={emptyState}
onSelectionChange={handleSelection}
/>
Ninguno de estos props es irrazonable por sí mismo.
El problema surge cuando el componente debe rastrear qué combinaciones de props son válidas.
¿Pueden las filas ser editables y seleccionables al mismo tiempo?
¿Deberían aparecer acciones en masa si la exportación está desactivada?
¿Un panel de herramientas personalizado reemplaza al predeterminado o se coloca junto a él?
¿La paginación se gestiona en el lado del cliente o por el servidor?
Cada booleano y función de callback adicionados aumentan la cantidad de estados que el componente genérico debe tener en cuenta.
La composición traslada parte de esa responsabilidad al que llama:
<Table>
<TableToolbar>
<ProjectFilters />
<ExportButton />
</TableToolbar>
<ProjectRows projects={projects} />
<Pagination />
</Table>
Ahora es el padre quien decide qué componentes necesita realmente este flujo de trabajo específico.
Las primitivas básicas de la tabla no necesitan incluir todas las variaciones específicas del producto que la aplicación pueda inventar en el futuro.
La configuración espera que un componente anticipe todas las combinaciones posibles. La composición permite al que llama crear solo la combinación que realmente necesita.
La composición no impide automáticamente que alguien cree algo que no tenga sentido. El que llama aún puede generar una combinación defectuosa.
Lo que sí cambia es algo diferente: el componente compartido ya no tiene que codificar por sí mismo cada permutación específica del negocio.
Aún es perfectamente aceptable mantener cierta configuración. Un Button reutilizable, por ejemplo, debería soportar opciones consistentes como el tamaño, el estado deshabilitado o el énfasis visual.
La verdadera pregunta que hay que plantearse es si una propiedad determinada representa otra variante de la misma responsabilidad subyacente, o si está haciendo que un componente maneje simultáneamente varias tareas no relacionadas.
6. Una responsabilidad clara simplifica las decisiones sobre el estado
La mayoría de los problemas relacionados con el estado en el frontend, a primera vista, parecen ser problemas de herramientas.
La conversación suele convertirse en un debate sobre los mecanismos:
¿Debería esto almacenarse en el estado del componente, en Context, Zustand, Redux, en la URL o en una caché del servidor?
En la práctica, sin embargo, muchas de estas preguntas se resuelven solas una vez que sabes quién es realmente el responsable del comportamiento.
El estado tiende a aumentar cada vez que la responsabilidad no está clara:
Un modal comienza con un estado que se encuentra dentro de él.
Luego, algún otro componente también necesita activarlo, por lo que el estado se traslada a otro lugar.
Más tarde, una barra de herramientas ubicada lejos en la estructura también necesita acceso, así que el estado acaba en Context.
En poco tiempo, un único almacén global contiene indicadores de visibilidad de modales, borradores de formularios a medio completar, configuraciones de filtros de tablas, respuestas API almacenadas en caché, selecciones de pestañas activas y diversos detalles de funcionalidades que no tienen relación entre sí.
En ese punto, la gente suele culpar a la biblioteca de gestión de estado por el desorden.
Pero el verdadero problema suele ser la falta de claridad en quién es responsable, no la herramienta en sí.
Una vez que los límites de tus componentes reflejen ya las responsabilidades reales, el estado tiene naturalmente un lugar lógico donde residir.
Lo que sea que el usuario esté escribiendo actualmente en un campo puede permanecer local a ese mismo campo.
Un flujo de cancelación en múltiples pasos debe encontrarse dentro de la función de cancelación.
Cualquier dato obtenido del servidor debe ir allí donde tu aplicación gestione los asuntos relacionados con el estado del servidor.
Un filtro que necesite persistir a través de la navegación o ser compartible mediante un enlace probablemente pertenezca a la URL.
El estado que se comparte realmente entre muchas partes no relacionadas de la aplicación podría justificar un responsable más amplio y centralizado.
Nada de esto hace que las decisiones sobre el estado complejo desaparezcan, pero te proporciona un marco para tomarlas.
Cuando los límites de los componentes reflejan la verdadera responsabilidad, decidir dónde debe residir el estado se vuelve mucho más sencillo.
Ese modelo mental hizo más por el equipo que cualquier biblioteca de estados “correctos” que se pudiera elegir.
7. A veces la duplicación era la mejor opción
Lo más difícil de aceptar en este enfoque era que cierta cantidad de duplicación está bien, incluso es positiva.
Imagínese dos formularios que se ven casi idénticos, compartiendo alrededor del 80 por ciento de su marcado y estructura.
El instinto es fusionarlos de inmediato en un único componente compartido.
Pero ese 20 por ciento restante podría contener una lógica de negocio completamente diferente.
Tal vez uno de los formularios valide de manera distinta.
Puede que enviar uno active una cadena de efectos diferente a la del otro.
Uno podría simplemente guardar un boceto.
El otro podría desencadenar algo irreversible.
Es probable que uno de los formularios se vuelva más complejo con el tiempo, mientras que el otro debería mantenerse mínimo.
Tratar de forzar que ambos se integren en una única implementación compartida tiende a generar algo que parece un laberinto de condicionales y casos especiales incrustados en un componente “reutilizable”.
En ese punto, se ha intercambiado la duplicación del JSX por una carga cognitiva adicional. Cualquiera que trabaje con cualquiera de estos flujos de trabajo debe analizar cómo el componente compartido protege al otro antes de poder modificar algo sin riesgo.
En casos como este, suele ser más adecuado mantener dos elementos separados y con nombres propios:
FeatureAForm
FeatureBForm
aunque parte del código subyacente se repita.
Esto no es un argumento a favor de la duplicación como virtud en general.
Cuando una responsabilidad verdaderamente compartida se vuelva evidente y estable, se puede extraer. Ambas formas podrían llegar a compartir las mismas primitivas de entrada, ayudantes de validación, envoltorios de layout u otros componentes independientes del dominio específico.
La verdadera diferencia radica en el momento oportuno, no en los principios.
En lugar de recurrir a la abstracción en cuanto dos cosas parecen similares, tenía más sentido esperar hasta que un límite común se hubiera demostrado efectivo con el paso del tiempo.
De esto surgió una regla práctica:
Es mejor duplicar código simple que compartir lógica basada en suposiciones que aún están evolucionando.
El código duplicado de forma sencilla es fácil de ver y permanece contenido en un solo lugar.
Por otro lado, una abstracción creada demasiado pronto puede agrupar silenciosamente varias suposiciones específicas de una función bajo un nombre aparentemente ordenado, ocultando precisamente la complejidad que se intentaba eliminar.
8. De qué se trataba realmente: Mantener los cambios locales
Con el tiempo quedó claro que todo este enfoque nunca se refería realmente al tamaño o a la reutilización de un componente.
Se trataba de cómo podría ser el cambio localizado.
La pregunta que vale la pena hacer cada vez era:
Cuando cambiamos una función, ¿cuánto código no relacionado necesitamos entender primero?
Un diseño con más componentes compartidos y generalizados podría técnicamente tener menos líneas duplicadas que uno con más piezas específicas para cada función.
Pero aún así puede obligar a un desarrollador a recordar una porción mucho mayor de toda la aplicación solo para realizar una edición segura.
Ese tipo de costo rara vez se refleja en ninguna métrica de reutilización.
Puede haber un componente maravillosamente reutilizable que, no obstante, cree una barrera terrible para realizar cambios.
Por el contrario, un componente creado para una función específica y utilizado únicamente en un lugar aún puede mejorar la mantenibilidad del códigobase, simplemente porque mantiene ese flujo de trabajo autónomo y fácil de comprender.
Esta resultó ser la formulación más precisa de toda la idea:
Diseña los límites de los componentes en torno al razonamiento local, no en torno a maximizar la reutilización.
Ese único principio une todo lo demás.
Las primitivas reutilizables ganan su lugar porque los patrones de presentación realmente se repiten en todo el códigobase.
Los componentes específicos de una función permanecen así porque los flujos de trabajo empresariales cambian por razones que rara vez coinciden entre sí.
La composición evita que los componentes compartidos tengan que aprender cada variante empresarial posible.
El estado se establece cerca del comportamiento que realmente lo controla.
La duplicación se vuelve aceptable justo cuando compartir el código uniría suposiciones que ya están alejándose por sí solas.
El frontend se simplifica no porque cada componente sea más pequeño o reutilizable, sino porque cada uno exige menos del usuario que lo lee.
El límite adecuado de un componente es aquel que se puede explicar cuando algo cambia
Es tentador juzgar una base de código frontend por indicadores superficiales: gran reutilización de componentes, poca duplicación, carpetas bien organizadas, tamaños de archivo pequeños. Esos aspectos no son insignificantes, pero dicen sorprendentemente poco sobre lo que realmente ocurre en el momento en que cambia un requisito.
Ese resultó ser la prueba mucho más útil para aplicar.
¿A dónde pertenece realmente este comportamiento?
¿Qué componente es el responsable de comprender esta regla de negocio?
¿Dónde debería residir este componente específico del estado?
Si cambia un requisito, ¿qué archivos deberían moverse lógicamente juntos?
¿Qué partes de la interfaz de usuario son realmente reutilizables y cuáles están destinadas a ser específicas de una función en particular?
El patrón que finalmente simplificó esta interfaz frontal no fue alguna técnica de abstracción nueva e ingeniosa.
Se trataba simplemente de hacer que cada capa del código tuviera menos cosas de las que ocuparse.
Las primitivas reutilizables se encargaban de la presentación.
Los componentes de funcionalidades se encargaban de los flujos de trabajo.
Los límites de composición determinaban cómo se integraban las funcionalidades entre sí.
Y a los componentes específicos del negocio se les permitió seguir siendo exactamente eso: específicos.
El código resultante no necesariamente terminó con menos componentes, ni con la cantidad teórica mínima de duplicaciones.
Lo que sí se logró fue una base de código en la que un desarrollador podía trabajar en una sola funcionalidad sin tener que memorizar primero toda la arquitectura del frontend.
Esa resultó ser una definición mucho más práctica de simplicidad que contar componentes o líneas duplicadas.
Según su experiencia, ¿fueron los componentes más reutilizables o los límites más claros entre los componentes existentes los que más contribuyeron a hacer mantenible su frontend?
Lecturas relacionadas
- Reducir React Prop-Drilling y God Components — Aprenda siete patrones concretos de refactorización para descomponer componentes de React excesivamente grandes al aislar el estado, la obtención de datos, los permisos y la lógica de carga en lugar de simplemente dividir archivos.