Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.
Un catálogo con 3.000 productos y un campo de búsqueda: cada tecla tarda en aparecer. No es la red, es React volviendo a renderizar 3.000 filas en cada pulsación. Es un problema de INP (lo que tarda la página en responder a una interacción), y tiene arreglos bastante mecánicos si vas en orden.
1. Confirmar dónde se va el tiempo
Con React DevTools, pestaña Profiler, graba mientras escribes. Si en cada commit aparecen miles de ProductRow renderizándose, ya sabes qué pasa. En Chrome DevTools, Performance, verás tareas largas (más de 50 ms) en cada tecla.
2. Que no se renderice lo que no ha cambiado
const ProductRow = memo(function ProductRow({ product, onSelect }) {
return <li onClick={() => onSelect(product.id)}>{product.name}</li>;
});
function Catalog({ products }) {
const [query, setQuery] = useState('');
const onSelect = useCallback((id) => {/* ... */}, []);
const visible = useMemo(
() => products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
// ...
}
memo solo sirve si las props son estables. Una función o un objeto creados en cada render (onSelect={() => ...} o style={{...}}) invalidan la memoización. Por eso useCallback aquí. Con React Compiler activado, buena parte de esto lo hace el compilador, pero conviene saber qué hace.
3. Que escribir no espere a la lista
useDeferredValue deja que el input se actualice al momento y renderiza la lista con un valor "atrasado", en segundo plano y con posibilidad de interrumpirse:
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const visible = useMemo(() => filterProducts(products, deferredQuery), [products, deferredQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul style={{ opacity: query !== deferredQuery ? 0.6 : 1 }}>
{visible.map((p) => <ProductRow key={p.id} product={p} onSelect={onSelect} />)}
</ul>
</>
);
Solo funciona si la lista está memoizada: si ProductRow se renderiza igual con el valor atrasado, no ganas nada.
4. No pintar lo que no se ve: virtualización
Si incluso un solo render de la lista completa es lento, o el DOM tiene decenas de miles de nodos, la solución es renderizar solo las filas visibles más un margen. Con TanStack Virtual:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{ position: 'absolute', top: 0, left: 0, width: '100%',
transform: `translateY(${row.start}px)` }}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);
}
Lo que pierdes al virtualizar: el Ctrl+F del navegador no encuentra las filas que no están en el DOM, y hay que cuidar la accesibilidad (que el lector de pantalla sepa cuántos elementos hay). Para listas de unos cientos de elementos, suele bastar con los pasos 2 y 3.
5. Una alternativa solo de CSS
content-visibility: auto en cada bloque deja que el navegador se salte el layout y el pintado de lo que está fuera de pantalla, sin JavaScript y sin perder la búsqueda del navegador. No reduce el trabajo de React (los componentes se siguen renderizando), pero ayuda cuando el coste está en el layout. Ponle contain-intrinsic-size para que la barra de scroll no salte.