Inicio / Artículos / Reducir al mínimo las propiedades de React, el buceo en propiedades y los componentes “divinos”

Reducir al mínimo las propiedades de React, el buceo en propiedades y los componentes “divinos”

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 limitarse a dividir archivos.

3299 palabras

Hubo un período en el que la complejidad de React se evaluaba únicamente por el número de líneas. Tan pronto como un componente superaba las trescientas líneas, la barra de herramientas se separaba y pasaba a su propio archivo. Al llegar a las cuatrocientas líneas, la tabla también se extraía. El archivo de nivel superior se reducía en tamaño, pero la funcionalidad en sí no se volvía más fácil de comprender. El componente padre seguía conteniendo todas las solicitudes a la red, todos los modales, una serie de campos de formulario y su estado de validación, verificaciones sobre qué acciones podía realizar el usuario actual, así como los diferentes estados por los que pasaba una pantalla al cargarse, editarse, guardarse o, a veces, fallar.

Lo que realmente ocurrió fue una reorganización de los elementos sin ningún cambio en cuanto a quién era el dueño del “casa” (el proyecto).

Esa distinción merece ser tomada en consideración, porque el tamaño por sí solo no es el enemigo. Un componente puede ser legítimamente grande si representa una sola pieza coherente de la interfaz de usuario. Un editor denso, un panel de control o una página de informes pueden justificadamente contener mucha marcación. Los problemas comienzan cuando un componente se convierte en el único lugar donde se toman decisiones completamente irrelevantes.

Ninguno de los patrones que se mencionan a continuación es inherentemente malo en React. Cada uno tiene usos legítimos en entornos más pequeños o mejor planificados. Solo se convierten en un problema cuando aparecen una y otra vez dentro de componentes grandes, ya que cada aparición aumenta el acoplamiento, amplía la superficie afectada por las actualizaciones y obliga a que cada cambio futuro tenga en cuenta toda la pantalla al mismo tiempo.

Deshacerse de ellos no deja un montón de componentes pequeños e insignificantes. En su lugar, se obtienen líneas más claras en cuanto al estado, la obtención de datos, la interacción y el renderizado. El código se vuelve más fácil de modificar simplemente porque menos partes de la funcionalidad pueden interferir entre sí sin querer.

1. Componentes que transmitieron toda la funcionalidad hacia abajo

Un hábito que aparece constantemente en pantallas más grandes es incluir objetos grandes y listas largas de callbacks en casi cada componente hijo.

Imagínese una tabla de resultados que recibe al usuario actual, un objeto completo con permisos, los filtros activos, la fila seleccionada, una bandera de carga, varias funciones de mutación, configuradores de modales y manejadores de notificaciones; además, contiene algunos valores a los que nunca accede directamente. Dicha tabla luego envía parte de estos datos directamente a los componentes de fila, quienes a su vez los transmiten a las celdas y botones individuales.

Al observar el árbol de archivos, todo parece estar bien dividido. Sin embargo, al analizar los datos reales que fluyen entre los componentes se ve una historia diferente: cada componente hijo sigue estando conectado a toda la funcionalidad.

Esto genera dos problemas distintos. Primero, la comprensión se ve afectada. Es difícil razonar sobre un componente que acepta quince propiedades, ya que su función real queda oculta detrás de detalles que en realidad pertenecen a su componente padre. Segundo, los cambios se propagan hacia afuera. Renombrar un campo de permisos o modificar la firma de función de una acción implica editar varios niveles de componentes que, en realidad, no tenían responsabilidad alguna sobre ese comportamiento desde un principio.

La solución consiste en reemplazar las propiedades amplias y orientadas a funcionalidades por contratos más específicos, limitados a lo que cada componente hijo es realmente responsable de manejar. Una tabla solo necesita filas, el estado de selección y algo como una función de callback onRowSelected. Un menú de acciones solo necesita las acciones específicas disponibles para un registro determinado, y no todo el modelo de permisos ni todas las funciones de mutación existentes.

Para lograrlo, generalmente es necesario crear un modelo de vista o un modelo de acción pequeño antes de renderizar. Ese paso adicional de preparación es valioso, ya que obliga al componente padre a transformar el estado bruto de la aplicación en una interfaz más reducida y diseñada específicamente antes de pasarle cualquier información al hijo.

El objetivo nunca debe ser minimizar el número de propiedades por sí mismo. La verdadera ventaja es que los componentes hijos dejan de necesitar comprender cómo funciona toda la página; solo deben saber qué datos mostrar y qué información transmitir hacia arriba.

Un componente grande se vuelve frágil en el momento en que cada uno de sus descendientes lleva consigo una parte del mundo interno completo de su padre. Contratos estrechos y diseñados específicamente permiten que las partes de la interfaz de usuario evolucionen de forma independiente, en lugar de arrastrar toda la funcionalidad a cada interacción.

2. Objetos y funciones nuevos creados en cada renderizado

Cada componente de React genera naturalmente nuevos valores mientras se renderiza. Puedes pasar un objeto de opciones a un componente hijo, filtrar un array o escribir un manejador de eventos en línea. La mayoría de las veces, nada de esto tiene importancia.

Pero dentro de un árbol de componentes profundo, una referencia recién creada puede romper silenciosamente la memorización en varios niveles por debajo del componente que la generó.

Considera un componente hijo creado con memo: sigue renderizándose nuevamente cada vez que el objeto que recibe tiene una identidad nueva en cada paso hacia arriba, incluso si los valores reales de sus campos nunca cambiaron. Un efecto se reinicia porque su array de dependencias contiene un objeto de configuración recién creado. Una tabla recalcula sus columnas porque las definiciones de estas se reconstruyeron después de que cambió algún estado modal completamente ajeno.

El código puede parecer perfectamente estable mientras oculta este problema:

<ResultsTable
  columns={[
    { key: "name", label: "Name" },
    { key: "status", label: "Status" },
  ]}
  options={{
    selectable: true,
    compact: false,
  }}
/>

Desde el punto de vista de JavaScript, tanto el array como los objetos que contiene son completamente nuevos en cada renderizado. El hecho de que eso cause problemas depende por completo de qué los está utilizando.

Tampoco es la solución recurrir a useMemo y useCallback como solución universal. Envolver todo en memorización solo añade su propio nivel de complejidad y problemas relacionados con los arrays de dependencias. Un mejor punto de partida es preguntarse si realmente es necesario construir el valor dentro del renderizado.

La configuración estática puede colocarse completamente fuera del componente. La configuración que cambia ocasionalmente puede trasladarse a un hook dedicado. Cuando un objeto existe únicamente para agrupar unas pocas primitivas, pasarlas directamente suele ser más limpio. Los manejadores de eventos solo necesitan estabilizarse con useCallback cuando su identidad realmente sea importante: en el caso de suscripciones, hijos memorizados o cálculos costosos posteriormente.

El objetivo nunca debe ser la pureza referencial por sí misma. Crear valores pequeños durante el renderizado es algo normal y, por lo general, inofensivo. La precaución debe reservarse para los casos en que la identidad de una referencia realice trabajo importante en otra parte del árbol.

En un componente grande, una sola actualización menor de estado puede volver a renderizar al padre y generar todo un conjunto de valores en el proceso. Si cada hijo trata una referencia modificada como datos cambiados, una pequeña actualización local se convierte en la invalidación de toda la página.

3. Eliminar la única consulta que alimentaba toda la pantalla

Las páginas grandes suelen comenzar con una única solicitud diseñada para obtener todo lo que la interfaz podría necesitar. Esa respuesta incluye métricas resumidas, filas de tabla, filtros, registros relacionados, datos de permisos, historial reciente e incluso campos requeridos solo por un modal que el usuario podría nunca abrir.

A primera vista, este enfoque parece eficiente, ya que la pantalla tiene exactamente un estado de carga y una fuente de datos evidente. Pero también vincula cada parte de la página con el segmento más lento y menos fiable de esa respuesta.

Si el servicio de historial falla, es posible que la tabla principal nunca se muestre. Una colección relacionada voluminosa aumenta la carga inicial, incluso para usuarios que nunca abren el panel que la necesita. Además, actualizar solo una sección obliga a una recarga completa, ya que toda la página comparte un único límite de datos.

Un enfoque mejor consiste en reemplazar estas solicitudes del tamaño de toda la página por límites de datos que coincidan con las responsabilidades visibles reales de la pantalla. El contenido principal se carga primero. Los paneles secundarios obtienen sus propios datos solo cuando se vuelven relevantes. Los detalles costosos se recuperan cuando un usuario abre un registro específico, en lugar de incluirse por defecto en cada fila.

Esto no significa tener que iniciar una llamada de red para cada widget trivial. Exagerar este tipo de separación genera sus propios problemas: solicitudes en secuencia, llamadas duplicadas y estados de carga inconsistentes. El límite que realmente importa suele ser una región con su propio ciclo de vida y su propia definición de qué significa “falla”.

Un widget de resumen y un registro de auditoría histórico no necesitan tener éxito o fallar como una sola unidad. Una tabla y un panel de edición poco utilizado no tienen por qué compartir el mismo contenido inicial. Una vez que estas partes se separan, la página puede seguir funcionando incluso cuando una sección opcional deja de funcionar.

El propio componente también se vuelve mucho más fácil de seguir, ya que no tiene que modelar una forma de respuesta masiva y extensa. Cada región recibe el contrato de datos que realmente necesita, y la invalidación de caché puede dirigirse únicamente a los registros que cambiaron en lugar de a toda la página.

Los componentes grandes tienden a volverse frágiles precisamente cuando su modelo de datos está determinado por la conveniencia a nivel de página en lugar del ciclo de vida natural de las características individuales que se encuentran dentro de ellos.

4. Eliminación de componentes genéricos controlados por docenas de indicadores

Un instinto común al principio para evitar la duplicación es crear componentes extremadamente configurables. Un único panel puede hacerse buscable, seleccionable, paginable, editable, exportable y plegable, todo controlado a través de una cantidad creciente de propiedades booleanas.

Parece reutilizable porque parece abarcar una amplia gama de escenarios. En realidad, cada pantalla nueva solo significa añadir otra condición al montón.

<DataPanel
  searchable
  selectable
  showToolbar
  allowExport={canExport}
  inlineEdit={mode === "admin"}
  compact={isInsideModal}
  hidePagination={rows.length < 20}
  stickyHeader={!isMobile}
/>

El verdadero problema no es solo la gran cantidad de propiedades. Las banderas interactúan entre sí de maneras impredecibles. La edición en línea se comporta de forma diferente una vez que está activo el modo de selección. El diseño compacto necesita su propia lógica de barra de herramientas. Un encabezado adherente se rompe dentro de un contenedor con desbordamiento específico. La cantidad de combinaciones posibles de banderas crece mucho más rápido de lo que cualquiera puede probarlas realmente.

El componente se convierte silenciosamente en una segunda aplicación oculta dentro de la primera.

Sustituir este reutilización basada en flags implica favorecer piezas más pequeñas y componibles, además de variantes separadas explícitas siempre que las diferencias sean significativas. Una tabla puede combinarse con una barra de herramientas, un control de paginación y un proveedor de selección según sea necesario. Una tabla editable puede existir como una función distinta en sí misma, en lugar de ser solo otra rama booleana oculta dentro de un componente de tabla genérico.

Cuando dos interfaces comparten una estructura subyacente real, esa estructura se reutiliza directamente. Cuando solo se parecen en una captura de pantalla pero siguen flujos de trabajo diferentes, forzarlas a través de una misma abstracción deja de tener sentido.

Esto vuelve a generar cierta duplicación que anteriormente estaba oculta, lo cual al principio puede parecer un paso atrás. En la práctica, esa duplicación suele ser mucho más económica que la arquitectura basada en ramas que reemplaza. Dos componentes pequeños y separados pueden modificarse de forma independiente, sin que cada edición necesite ser verificada contra una docena de combinaciones de flags no relacionadas.

La reutilización rinde frutos cuando protege un contrato único y estable. Se vuelve arriesgada en el momento en que se logra al forzar a un único componente para que represente secretamente varios productos diferentes al mismo tiempo.

5. Eliminar el estado modal de la página que abrió el modal

Los componentes grandes con frecuencia terminan funcionando como gestores de modales. Ellos registran si cada diálogo está abierto, qué registro está editando actualmente, en qué paso del flujo se encuentra, si está en proceso de guardado y qué error apareció por última vez.

A menudo la propia página solo contiene una sola línea que activa la apertura del modal, pero de todos modos se encarga de todo el ciclo de vida de ese diálogo.

Esto genera un estado que permanece activo incluso cuando el diálogo está completamente invisible. Cerrarlo correctamente implica eliminar los registros seleccionados, los valores de los campos de borrador, los errores de validación y las solicitudes pendientes, todo en un orden específico. Abrir otro diálogo posteriormente conlleva el riesgo de reutilizar accidentalmente valores restantes del que estaba abierto antes.

Tratar cualquier diálogo complejo como una función en sí misma, en lugar de como JSX condicional añadido a la página, cambia esta dinámica. La tarea de la página pasa a ser determinar sobre qué registro pretende actuar el usuario. El diálogo en sí se encarga de los datos de borrador, la lógica de validación, los pasos internos y el ciclo de vida de envío.

En algunos casos, basta con montar el diálogo únicamente mientras está abierto para que su estado temporal se reinicie automáticamente. En otros, el estado debe persistir tras los cierres, por lo que pasa a un propietario de borrador dedicado en lugar de quedar vinculado al estado de la tabla y los filtros de la página.

Esta separación también hace que el comportamiento asíncrono sea notablemente más seguro. Un diálogo de edición puede cancelar o descartar las solicitudes obsoletas en el momento en que se cierra. Una acción de guardado puede decidir por sí misma si el diálogo permanece abierto en caso de error. La página ya no tiene que coordinar las mecánicas internas del formulario que en realidad no comprende.

La página sigue siendo responsable de la conexión entre la tabla y el diálogo: sabe qué registro está seleccionado y qué debe ocurrir una vez que la edición tiene éxito. Pero ya no controla todos los campos y transiciones solo porque el diálogo se abrió desde esa página.

Un modal puede aparecer visualmente superpuesto sobre una pantalla, pero esa superposición visual no implica que todo su ciclo de vida esté dentro del componente de la pantalla.

6. Extraer las verificaciones de permisos del JSX disperso

La lógica de autorización tiende a infiltrarse en los componentes poco a poco: un botón se oculta para los usuarios normales, un elemento del menú se desactiva una vez que un registro se archiva, y una sección solo se muestra para los administradores.

Estas condiciones se multiplican porque JSX facilita enormemente añadir otra verificación:

{user.role === "admin" && record.status !== "archived" && (
  <DeleteButton />
)}

Cuando un componente crece en tamaño, puede contener varias versiones ligeramente diferentes de lo que supuestamente debería ser la misma regla. Una condición determina si algo es visible, otra determina si su manejador se activa y una tercera determina si un elemento del menú queda atenuado. Con el paso del tiempo, estas copias se separan y dejan de coincidir entre sí.

Esa divergencia es principalmente un error de corrección, pero también afecta la legibilidad. El marcado de diseño se entrelaza con las políticas empresariales, y cualquiera que lea el componente debe evaluar mentalmente las expresiones de permisos dispersas por todo el árbol solo para comprender qué hace la página.

En lugar de dispersar verificaciones de políticas directamente en JSX, puedes calcular un conjunto explícito de capacidades de antemano:

const capabilities = getRecordCapabilities({
  user,
  record,
  organization,
});

A partir de ahí, el componente puede simplemente preguntar si al usuario actual se le permite editar, archivar, exportar o eliminar el registro en cuestión. Los nombres describen decisiones reales relacionadas con los productos, en lugar de exponer campos brutos de la base de datos o cadenas de roles.

Para que quede claro, esto no traslada la aplicación de medidas de seguridad al cliente. El backend sigue siendo el verdadero límite de autorización; eso no cambia. El objeto de capacidades en el frontend existe únicamente para proporcionar a la interfaz una fuente de información consistente, en lugar de tener que volver a derivar la misma regla en cinco ramas visuales diferentes que podrían desincronizarse sin que se note.

También hace que las propias reglas sean mucho más fáciles de probar. Puedes aplicar la lógica de permisos a diferentes roles, configuraciones de propiedad, estados y ajustes organizativos sin tener que renderizar todo el árbol de componentes. Cuando cambia la política subyacente, por lo general los componentes circundantes no necesitan modificarse en absoluto.

Los grandes árboles JSX se vuelven frágiles cuando también son el lugar donde se crean reglas de negocio de forma dinámica. El rendering debería centrarse principalmente en utilizar decisiones que ya se hayan tomado, y no en reconstruir esas decisiones desde cero dentro de cada rama condicional.

7. Eliminación de la carga en toda la página y de las banderas de error

La mayoría de los componentes grandes comienzan de forma sencilla con un único valor isLoading y un único error. Eso está bien siempre y cuando la página solo haga una cosa. Pero a medida que una función se expande, esas mismas dos indicaciones terminan intentando describir varias actividades no relacionadas al mismo tiempo.

Una página podría estar cargando sus datos iniciales, actualizando una tabla en segundo plano, guardando un formulario, eliminando una fila y exportando un informe; a veces todo ello en la misma sesión. Un único booleano de carga compartido no permite saber cuál de esas operaciones está realmente en ejecución. Una solicitud finaliza y cambia el valor del flag a falso mientras otra completamente diferente sigue en curso. Un error generado por una mutación sobrescribe el error que debía describir la carga inicial de la página. Toda la interfaz se bloquea solo porque por casualidad está en ejecución alguna actualización de fondo no relacionada.

Un enfoque mejor reemplaza estas banderas de estado a nivel de página por un estado que corresponda a la operación específica que describen.

Eso significa que la consulta inicial puede estar cargándose mientras la tabla existente permanece completamente visible e interactiva. Una sola fila puede estar en proceso de eliminación sin que se desactive cada otra fila de la página. El editor puede estar en medio del envío mientras los filtros de la página siguen siendo utilizables. Una exportación puede mostrar su propio indicador de progreso sin dar a entender que toda la pantalla se ha vuelto inaccesible.

El resultado son valores de estado más individuales, pero cada uno tiene un significado inequívoco. Nada en el código necesita adivinar a qué se refiere realmente un genérico isLoading en un momento dado.

La misma lógica se aplica a los fallos: no es necesario que todos ellos converjan en una única pantalla de error de nivel superior. Un panel opcional que haya fallado puede mostrar su propia opción de intento nuevamente en su lugar. Un error de mutación puede quedar confinado al flujo de trabajo que lo provocó. La página principal solo deja de estar disponible cuando los datos necesarios para renderizarla específicamente no han podido cargarse.

Esto genera una interfaz más resistente y un modelo de componentes más fácil de comprender. El estado deja de considerarse global solo porque la operación se encuentra dentro de un componente de página grande.

Un componente grande a menudo parece inestable porque varios flujos de trabajo distintos se ven obligados a compartir una única señal. Darle a cada flujo de trabajo su propio estado permite que la interfaz sea precisa respecto a lo que funciona y lo que no en un momento dado.

Nunca se trató solo de archivos más pequeños

Una vez que se eliminan estos patrones, muchos componentes terminan siendo más cortos, pero eso es un efecto secundario, no el objetivo real.

La verdadera ventaja es que se toman menos decisiones no relacionadas dentro de un mismo límite de renderizado. Los componentes hijos reciben contratos limitados a sus propias responsabilidades en lugar del estado de toda la funcionalidad. La identificación por referencia deja de invalidar silenciosamente grandes subárboles en cada renderizado. Los datos se obtienen según lo que realmente está visible en la pantalla, en lugar de mediante una consulta del tamaño de toda la página. Los componentes reutilizables dejan de acumular una nueva marca para cada variación posible que alguien pueda necesitar.

Los modales asumen el control de su propio estado. Las reglas de permisos se convierten en capacidades con nombre en lugar de condicionales incrustados. Los estados de carga y error pertenecen a las operaciones específicas que los generan.

Algunas pantallas siguen siendo grandes simplemente porque la interfaz en sí es realmente extensa, y eso está bien. El código se vuelve más fácil de trabajar con no porque sea más pequeño, sino porque su tamaño refleja una estructura real y visible en lugar de una lógica de coordinación oculta.

En este punto, la pregunta relevante sobre un componente de React no es cuántas líneas tiene, sino cuántas razones independientes podrían forzarlo a cambiar. Si para modificar el editor también hay que entender la consulta de la tabla, el estado de exportación, el modelo de permisos y cada modal de la página, el problema no es el formato ni la longitud del archivo. Los límites de responsabilidad simplemente están definidos en el lugar incorrecto.

Los componentes grandes de React siguen siendo manejables en cuanto dejan de intentar comportarse como toda la aplicación por sí solos.

Nunca fue el objetivo seguir dividiendo un archivo hasta que cada función fuera diminuta.

El objetivo es asegurarse de que cada parte de una funcionalidad solo conozca las decisiones por las cuales es realmente responsable de tomar.

Lecturas relacionadas

  • Rendimiento del frontend: desde el punto ciego en la revisión de código hasta la métrica del producto — Aprenda por qué pasar la revisión de código no es suficiente, cuáles son los Core Web Vitals que realmente importan y cómo medir y solucionar problemas reales de rendimiento en React.
  • Solucionando condiciones de carrera: el retardo no puede resolver problemas en interfaces de búsqueda — Entienda por qué el retardo por sí solo no puede evitar que las respuestas obsoletas de la API sobrescriban el estado actual de la interfaz, y explore cuatro soluciones prácticas para garantizar el orden de las solicitudes.