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.
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.
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.
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.
Object.is no mira el contenido: mira si es el mismo objeto. Por eso una prop creada en el render derrota a memo.| Herramienta | Qué se mira | Có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.
“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?
Por qué: memo compara props con Object.is: dos objetos con el mismo contenido son referencias distintas, así que la comparación falla aunque los datos no cambien. Sin props estables, memo es solo un envoltorio.
Un contexto con { usuario, tema } cambia el tema. ¿Quién se vuelve a renderizar?
Por qué: el consumidor se suscribe al contexto completo, no a un campo: el proveedor entrega un valor nuevo y React no adivina qué parte usaste. Partir el contexto o usar selectores crea suscripciones por porción.
¿Por qué una unión discriminada evita el bug de "cargando" y "error" a la vez?
Por qué: con banderas, las combinaciones imposibles existen y solo las evitas con disciplina. Con la unión, "cargando y con error" no se puede escribir y el compilador te obliga a decidir.
El commit de un clic tarda 4 ms. ¿Qué corresponde hacer?
Por qué: el presupuesto de 60 Hz es 16,7 ms por frame; 4 ms dejan margen. Optimizar sin medir cambia un problema conocido por comparaciones y closures que nadie entiende.