Lección 07

¿Por qué un clic pequeño repinta 120 componentes?

Renderizar en React es volver a ejecutar una función que describe la interfaz. Un clic puede ejecutar 120 sin que nada visible cambie: la pregunta útil no es cuántas, sino cuánto cuesta y por qué se encendieron.

01Qué es renderizar

Un componente de React es una función que devuelve una descripción de un trozo de interfaz: objetos ligeros, no nodos del DOM. Renderizar es ejecutar esa función otra vez y obtener una descripción nueva; React la compara con la anterior (reconciliación) y escribe en el DOM solo lo que cambió (commit). El navegador pinta al final. Son tres pasos y solo el primero corre tu código.

Un componente se vuelve a renderizar por tres motivos: cambió su propio estado, se renderizó su padre (aunque los datos que recibe, sus props, sean idénticos) o cambió algo a lo que se suscribió. Las props distintas no son un motivo aparte: son la consecuencia de que el padre se renderizó. Por eso subir el estado tres niveles ejecuta también a todos los hermanos de abajo.

Un re-render trivial cuesta microsegundos; 120 no se notan. El problema es cuando la función hace trabajo: filtrar 5.000 filas, crear un objeto, recalcular un gráfico. Ahí 120 ejecuciones pueden ser ~40 ms, más de dos frames a 60 Hz (16,7 ms cada uno), y el clic se siente pegajoso.

Un clic en Filtros enciende las 120 hojas del árbol App Buscador Tabla Panel Barra Filtros Filas Paginado Resumen Totales el estado vive aquí 120 hojas un clic en Filtros: las 120 hojas se ejecutan otra vez
El estado en la raíz obliga a React a recorrer el árbol entero; cada cuadrado es una hoja que vuelve a ejecutar su función.

02Por qué sucede: identidad, contexto y banderas

React compara props con Object.is. Dos objetos con el mismo contenido son referencias distintas: {} no es {}. Si el padre hace <Fila estilo={{ color: 'rojo' }} /> o calcula items.filter(...) en el cuerpo, cada render crea referencias nuevas y memo(Fila) falla siempre. memo no arregla al padre: solo evita el hijo cuando las props son estables.

El contexto es una tubería: el proveedor publica un valor y cada consumidor se suscribe al contexto entero, no a un campo. Si el valor es { usuario, tema } y cambia el tema, también se re-renderizan los que solo leen usuario. Y si el objeto se crea en el render del proveedor, cambia de identidad aunque sus campos sean iguales.

Una máquina de estados enumera las formas que puede tomar la pantalla. Con cuatro banderas (cargando, error, hecho, reintentando) hay 16 combinaciones y 12 son mentiras: "cargando y con error" no existe, pero tu estado lo permite. Una unión discriminada tiene cuatro formas y el campo estado dice cuál; el switch obliga a tratar cada una y el compilador se queja si olvidas una. No es estilo: es hacer imposible lo ilegal.

// Cuatro banderas son 16 estados; cuatro formas, cuatro.
type Trabajo =
  | { estado: 'queued'; id: string; encoladoEn: number }
  | { estado: 'running'; id: string; intento: number }
  | { estado: 'failed'; id: string; error: string }
  | { estado: 'done'; id: string; filas: number };

function resumen(t: Trabajo): string {
  switch (t.estado) {
    case 'queued':
      return 'Espera: ' + minutos(t.encoladoEn) + ' min';
    case 'running':
      // Aquí t.intento existe; en 'queued' no.
      return 'Intento ' + t.intento + ' en curso';
    case 'failed':
      return 'Falló: ' + t.error;
    case 'done':
      return t.filas + ' filas listas';
    default:
      // Si agregas un estado, esto deja de compilar.
      const imposible: never = t;
      return imposible;
  }
}

No todo render es un problema. Si la interacción termina en un commit de 3 ms, no hay nada que arreglar; memoizar solo agrega comparaciones y funciones que se quedaron con valores viejos. Mide el commit y, si cabe en un frame, déjalo.

03Cómo se ataca: bajar, partir y suscribirse

El orden importa: bajar el estado, partir el contexto, suscribirse por porción con selectores (useSyncExternalStore) y, al final, memo. Bajar el estado es moverlo al ancestro común más pequeño; a veces el ancestro es el propio input. Partir el contexto es uno por frecuencia de cambio. Un selector te suscribe a una porción: el componente se re-renderiza solo si el valor elegido cambia según Object.is.

Con el estado en Filas, solo tres hojas se vuelven a ejecutar App Buscador Tabla Panel Barra Filtros Filas Paginado Resumen Totales el estado bajó a Filas 3 hojas solo 3 hojas se ejecutan; el resto conserva su última descripción
El mismo árbol con el estado en Filas: el clic no toca a los hermanos ni a las otras ramas. Cambia el alcance, no la cantidad de código.
Misma forma, otra referencia: memo vuelve a ejecutar la fila cada render crea un objeto nuevo render 1 { color: 'rojo' } render 2 { color: 'rojo' } Object.is false misma forma, otra referencia Fila se ejecuta otra vez con la misma referencia prop estable: #a1 Object.is true memo salta el render la referencia decide; el contenido no alcanza
Comparar con Object.is no mira el contenido: mira si es el mismo objeto. Por eso una prop creada en el render derrota a memo.

04Cómo diagnosticarlo

HerramientaQué se miraCómo se ve el síntoma
React DevTools › Profiler Commits por interacción, componentes y el motivo ("why did this render"). Dice "parent rendered" con props idénticas: el estado está demasiado arriba.
Flamegraph Tiempo propio de cada componente en el commit. Una barra ancha concentra el costo; las finas no explican nada.
Ranked chart Suma de tiempo por componente. Uno se lleva la mayor parte: ese se ataca.
Highlight updates Alcance visual del re-render al interactuar. Un clic en un botón enciende medio árbol.
Performance › Interactions Milisegundos del clic al siguiente frame. Más de 16,7 ms por interacción: se pierde un frame.
console.count() en el cuerpo Ejecuciones del componente por interacción. 120 por clic.

El Profiler separa "muchos" de "caros": 120 componentes de 0,1 ms no necesitan memo; tres de 12 ms sí. Mira el tiempo propio, no el conteo.

Para decirlo en la entrevista

“A component re-renders when its own state changes, when its parent renders, or when a subscribed store changes; new props are a consequence.”

“memo only pays off with referentially stable props, so I fix where the state lives before adding memoization.”

“We model async state as a discriminated union, so loading and failed cannot coexist.”

Comprueba que lo tienes

Cuatro preguntas. Al comprobar se marca la lección como completada.

Envuelves Fila en memo y sigue re-renderizándose en cada tecla. ¿Cuál es la causa más probable?

Un contexto con { usuario, tema } cambia el tema. ¿Quién se vuelve a renderizar?

¿Por qué una unión discriminada evita el bug de "cargando" y "error" a la vez?

El commit de un clic tarda 4 ms. ¿Qué corresponde hacer?