Velocidad: cuándo se vuelve a renderizar un componente
En resumen: En React un componente se vuelve a renderizar cuando cambia su estado o su padre; primero mides, y después memo, useMemo y useCallback detienen los renders innecesarios.
Problema
Cada letra renderiza toda la lista
Arriba hay un campo de búsqueda y un botón «Buscar». La lista se filtra solo después de «Buscar». ProductList escribe en la consola cada vez que se renderiza. Escribe «leche» en el campo y mira la consola.
Consola
Cinco letras, cinco líneas nuevas «renderizo ProductList». La lista es la misma: todavía no has pulsado «Buscar». Aquí hay ocho productos y no lo notas. En un catálogo real hay 2000, cada uno con imagen y precio, y el campo empieza a ir por detrás de tus dedos.
La idea
Cuándo se vuelve a renderizar un componente
Un componente se vuelve a renderizar cuando cambian los datos que muestra. Pero qué son «sus datos» lo deciden de forma distinta las dos bibliotecas. Las dos dan el resultado correcto; la diferencia es cuánto trabajo hacen.
React vuelve a renderizar el componente cuyo estado cambió y todos sus hijos, hacia abajo por todo el árbol. Por defecto no comprueba si las props de un hijo son las mismas. Aquí draft está en App, así que cada letra renderiza App, ProductList y cada tarjeta.
Esto suele ser rápido: React compara el resultado con la página antigua y cambia en el DOM solo la diferencia. Se vuelve caro cuando el subárbol es grande o el cálculo que hay en él es pesado.
Primero mide, después arregla. Un contador en la consola, como aquí, es la medición más sencilla. Las React DevTools tienen una pestaña Profiler: grabas, escribes en el campo y ves qué componente se renderizó cuántas veces y en cuántos milisegundos. Arreglas solo un render que sea a la vez innecesario y lento.
Míralo
Mira quién se ilumina
const ProductList = memo(function ProductList({ products, onAdd }) { console.log(`renderizo ProductList: ${++renders}`); return products.map((p) => <ProductCard key={p.id} product={p} onAdd={onAdd} />);});
function App() { const [draft, setDraft] = useState(''); const [query, setQuery] = useState(''); const [count, setCount] = useState(0); const visible = useMemo(() => filterByName(PRODUCTS, query), [query]); const add = useCallback(() => setCount((c) => c + 1), []); return ( <> <input value={draft} onChange={(e) => setDraft(e.target.value)} /> <button onClick={() => setQuery(draft)}>Buscar</button> <ProductList products={visible} onAdd={add} /> </> );}Paso a paso, en palabras
- App guarda el texto del campo (draft) y la búsqueda (query). ProductList dibuja las tarjetas. La consola cuenta cuántas veces se renderiza la lista.
- Cada tecla cambia draft. React vuelve a renderizar App, y con ella cada hijo: ProductList y cada tarjeta, aunque la lista es la misma.
- memo(ProductList) compara las props nuevas con las antiguas. useMemo conserva el mismo array mientras query sea la misma, y useCallback conserva la misma add.
- Ahora una tecla renderiza solo App y el campo. Las props de ProductList son las mismas, así que se omite, y el contador se queda.
- «Buscar» cambia query. El array es nuevo, así que ProductList se renderiza. Este render es necesario: la lista muestra ahora dos productos.
- Primero mide: un contador en la consola o el Profiler de las DevTools. Arregla solo los renders que son a la vez innecesarios y lentos.
memo. Y la comparación tiene sentido solo si las props realmente son las mismas: el array y la función deben conservarse entre renders.Pruébalo
Solo «Buscar» renderiza la lista
Haz que escribir en el campo y «Añadir» no vuelvan a renderizar ProductList. Solo «Buscar» debe renderizarla, porque solo entonces cambia la lista. La comprobación escribe «leche», añade dos productos, pulsa «Buscar» y cuenta las líneas «renderizo ProductList» de la consola.
Consola
Solución
App.jsximport { useCallback, useMemo, useState } from 'react';
import ProductList from './ProductList.jsx';
import { PRODUCTS } from './products.js';
export default function App() {
const [draft, setDraft] = useState('');
const [query, setQuery] = useState('');
const [count, setCount] = useState(0);
const visible = useMemo(() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())), [query]);
const add = useCallback(() => setCount((c) => c + 1), []);
function search(e) {
e.preventDefault();
setQuery(draft);
}
return (
<>
<header>
<form role="search" onSubmit={search}>
<input type="search" aria-label="Búsqueda" value={draft} onChange={(e) => setDraft(e.target.value)} />
<button>Buscar</button>
</form>
<p>En el carrito: {count}</p>
</header>
<main>
<ProductList products={visible} onAdd={add} />
</main>
</>
);
}
ProductList.jsximport { memo } from 'react';
import { money } from './products.js';
let renders = 0;
function ProductList({ products, onAdd }) {
console.log(`renderizo ProductList: ${++renders}`);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
<button type="button" aria-label={`Añadir ${p.name}`} onClick={() => onAdd(p)}>
Añadir
</button>
</li>
))}
</ul>
);
}
export default memo(ProductList);
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Pan integral', price: 1.9 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 6, name: 'Leche desnatada', price: 0.9 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
{ id: 8, name: 'Queso kashkaval', price: 9.5 },
{ id: 9, name: 'Mantequilla', price: 3.4 },
{ id: 10, name: 'Huevos (10 uds.)', price: 2.8 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
Las tres partes van juntas:
- En
ProductList.jsx:export default memo(ProductList);. Ahora React compara las props antes de renderizar. - En
App.jsx:const visible = useMemo(() => PRODUCTS.filter(…), [query]);. El array es el mismo mientrasquerysea la misma. const add = useCallback(() => setCount((c) => c + 1), []);. La función es la misma en cada render. ConsetCount((c) => c + 1)no leecount, por eso la lista vacía[]es correcta.useCallback(() => setCount(count + 1), [])recuerda el primercounty el carrito se queda en 1: la comprobación lo detecta.
memo solo no basta: el filtro y la función son nuevos en cada render de App, y memo ve props nuevas.
Pruébalo
Encuentra el error
ProductList está en memo, y la lista se sigue renderizando con cada letra. ¿Qué línea tiene la culpa?Elige la línea con el error.
add es una flecha nueva en cada render de App. memo ve un onAdd nuevo y renderiza ProductList. Debe ser const add = useCallback(() => setCount((c) => c + 1), []);.Pruébalo
Completa
¿Cómo se mantiene igual la lista cara entre renders?
const visible = (
() => PRODUCTS.filter((p) => p.name.includes(query)),
[query],
);useMemo: ejecuta la función en el primer render y de nuevo solo cuando cambia una dependencia de [query]. Si no, devuelve el mismo array.La idea
Una lista larga: renderizar menos
El elemento más rápido es el que no se dibuja. Una persona ve 10–20 productos en la pantalla, no 2000. Por eso una lista larga dibuja solo una parte:
- «Mostrar más»: los primeros 20, y con un clic 20 más. Solo se recuerda cuántos se muestran.
- Páginas: «1, 2, 3 …», a menudo con el número en la dirección (
/?page=2), para poder compartirla. - Una lista virtual (windowing): dibuja solo las filas visibles y las cambia al desplazarse. Es difícil de escribir, así que usas una biblioteca: TanStack Virtual o react-window.
Las claves de la lección sobre listas ayudan también aquí: con key={p.id} los 20 productos añadidos son elementos nuevos, y los antiguos se quedan sin rehacerse.
La idea
Carga diferida
No todas las partes de la página hacen falta de golpe. Las reseñas del producto, el mapa de las tiendas, el editor del perfil: pueden cargarse solo cuando la persona los abre. Entonces la primera carga descarga menos código.
import { lazy, Suspense } from 'react';
const Reviews = lazy(() => import('./Reviews.jsx'));
<Suspense fallback={<p>Cargando las reseñas…</p>}> <Reviews /></Suspense>import('./Reviews.jsx') es una llamada a una función que carga el archivo y devuelve una promesa. lazy la convierte en un componente. Mientras el archivo viene en camino, Suspense muestra el fallback.
Consola
Abre la consola: la línea de reviews.js aparece solo después del clic. Para ser sinceros, en el sandbox todos los archivos ya están en la página, así que aquí import() solo ejecuta el archivo más tarde. En un proyecto real Vite pone cada uno de esos archivos aparte y el navegador lo descarga del servidor en el primer clic. Lo mismo vale para las páginas: con React Router cualquier página puede ser lazy.
Más precisamente, para curiosos
React Compiler. Si el proyecto usa React Compiler, él añade por su cuenta este tipo de memorización al construir el código, y rara vez hacen falta memo, useMemo y useCallback a mano. La documentación de React lo recomienda para código nuevo y mantiene useMemo y useCallback como medio de control preciso.
El estado hacia abajo. Otra solución sin memo: el campo con su draft pasa a ser un componente aparte SearchBox que avisa hacia arriba solo con «Buscar» (onSearch(draft)). Entonces las letras cambian el estado en SearchBox, y App no se renderiza en absoluto. Es la regla «tan arriba como haga falta y no más» de la lección sobre elevar el estado.
StrictMode. En desarrollo <StrictMode> renderiza cada componente dos veces, para detectar componentes impuros. El contador entonces crece de dos en dos. La versión construida no lo hace.
useMemo es una pista. React puede descartar el resultado recordado y calcularlo de nuevo. El código debe ser correcto también sin él.
El modo de desarrollo es más lento. Las advertencias y las comprobaciones cuestan tiempo. Mide la velocidad de la versión construida (vite build y vite preview), no solo en desarrollo.
Ejercicio
Mostrar más
El catálogo tiene 10 productos y los dibuja todos. Muestra los primeros 4, y debajo «Mostrando 4 de 10» y un botón «Mostrar más» que añade 4 más. Cuando todos están mostrados, el botón desaparece. La comprobación cuenta los productos después de cada clic y mira el texto y el botón.
Consola
Solución
App.jsximport { useState } from 'react';
import { PRODUCTS, money } from './products.js';
const PAGE = 4;
export default function App() {
const [limit, setLimit] = useState(PAGE);
const shown = PRODUCTS.slice(0, limit);
return (
<main>
<ul>
{shown.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
</li>
))}
</ul>
<p>{`Mostrando ${shown.length} de ${PRODUCTS.length}`}</p>
{limit < PRODUCTS.length && (
<button type="button" onClick={() => setLimit(limit + PAGE)}>Mostrar más</button>
)}
</main>
);
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Pan integral', price: 1.9 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 6, name: 'Leche desnatada', price: 0.9 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
{ id: 8, name: 'Queso kashkaval', price: 9.5 },
{ id: 9, name: 'Mantequilla', price: 3.4 },
{ id: 10, name: 'Huevos (10 uds.)', price: 2.8 },
{ id: 11, name: 'Manzanas (1 kg)', price: 2.2 },
{ id: 12, name: 'Bananas (1 kg)', price: 2.6 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
Solo guardas cuántos se muestran: const [limit, setLimit] = useState(4);. Los mostrados se calculan: PRODUCTS.slice(0, limit). El botón es {limit < PRODUCTS.length && <button …>}, y el clic es setLimit(limit + 4). Una variable normal let limit = 4 cambia con el clic, pero React no vuelve a renderizar: la comprobación lo detecta.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado derivado · Estado compartido: hacia arriba · Efectos · Enrutamiento
- Ejercicios sobre el tema: Una aplicación completa