En una entrevista técnica me preguntaron cómo filtraría una lista de ejercicios para mostrarla como resultados de un buscador. Parece una pregunta sencilla, y en cierto modo lo es, porque un filter sobre un array lo sabe hacer cualquiera, pero en cuanto lo metes en un componente de React empiezan a aparecer las preguntas de verdad: ¿dónde guardo el texto que escribe el usuario?, ¿guardo también la lista filtrada en un estado?, ¿la calculo en un useEffect?, ¿qué pasa con el resto de la pantalla cada vez que alguien pulsa una tecla? Muchos problemas de rendimiento en React tienen la misma raíz, que es no tener claro qué provoca un render, así que en este artículo voy a usar ese buscador de ejercicios como ejemplo para entenderlo desde el principio.
Qué es un render (y por qué no es malo)
Cuando React «renderiza» un componente, lo único que hace es llamar a tu función. Tu componente devuelve JSX, React lo compara con lo que devolvió la vez anterior y, solo si hay diferencias, toca el DOM. Renderizar no significa repintar la pantalla, y la mayoría de renders son tan baratos que nunca vas a notarlos.
Es fácil obsesionarse con que un componente se renderice dos veces en vez de una, pero un render barato no te cuesta nada. Los renders que duelen son los caros que no hacían falta, y los que disparan otros renders en cadena hasta que la app va a tirones mientras escribes.
Las tres razones por las que un componente se renderiza
Un componente se vuelve a renderizar por una de estas tres cosas: cambia su propio estado, se renderiza su componente padre o cambia el valor de un contexto que usa.
Lo que suele sorprender a la gente que empieza es que las props no aparecen en la lista. Mucha gente piensa que un hijo se renderiza «porque le han cambiado las props», pero se renderiza porque su padre se ha renderizado, y React, por defecto, vuelve a renderizar a todos los hijos de un componente cuando este se renderiza, les hayan cambiado las props o no. Si el padre de toda tu página guarda el texto del buscador, cada tecla vuelve a ejecutar la página entera.
Con esto en la cabeza, vamos al buscador. Esta es la versión más directa:
function ExerciseSearch({ exercises }) {
const [query, setQuery] = useState('');
const results = exercises.filter((exercise) =>
exercise.name.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((exercise) => (
<li key={exercise.id}>{exercise.name}</li>
))}
</ul>
</>
);
}
Cada tecla cambia query, el componente se renderiza, se calcula results de nuevo y React actualiza la lista. Es un render por tecla, que es lo que queremos, y con unos cientos de ejercicios no vas a notar nada.
Guardar en el estado lo que se puede calcular
El error más habitual es pensar que la lista filtrada también tiene que ser un estado y sincronizarla con un useEffect:
const [query, setQuery] = useState('');
const [results, setResults] = useState(exercises);
useEffect(() => {
setResults(
exercises.filter((exercise) =>
exercise.name.toLowerCase().includes(query.toLowerCase())
)
);
}, [exercises, query]);
Parece más ordenado, pero fíjate en lo que pasa cuando escribes una letra. React renderiza con el query nuevo y los results viejos, pinta la pantalla, ejecuta el efecto, el efecto cambia results y React vuelve a renderizar. Son dos renders por tecla en vez de uno, y durante un instante la lista no corresponde a lo que hay escrito en el buscador.
La solución es borrar el efecto y el segundo estado. Si un valor se puede calcular a partir de las props o del estado que ya tienes, calcúlalo durante el render, como en la primera versión. Un useEffect sirve para sincronizar tu componente con algo que está fuera de React, como una petición a una API o un widget del DOM, no para mantener un estado al día con otro estado. Y si lo que quieres es leer un valor que vive fuera de React y cambia solo, como navigator.onLine o un store externo, React tiene un hook pensado para eso, useSyncExternalStore, que se suscribe a esa fuente y renderiza cuando cambia sin que tengas que copiar el valor a un estado.
¿Y si el filtro es caro de verdad, porque tienes miles de ejercicios y buscas también por músculo, material y categoría? Entonces envuelves el cálculo en useMemo, que solo lo repite cuando cambian sus dependencias:
const results = useMemo(
() => filterExercises(exercises, query),
[exercises, query]
);
Pero ojo con poner useMemo en todas partes por si acaso, porque también tiene su coste y hace el código más difícil de leer. Primero mide, y luego memoiza lo que salga caro.
Efectos que se disparan en bucle
Supongamos ahora que el buscador tiene filtros por músculo y por material, y que los resultados vienen de una API. Una primera versión podría ser esta:
const [muscle, setMuscle] = useState('all');
const [equipment, setEquipment] = useState('all');
const [results, setResults] = useState([]);
const filters = { query, muscle, equipment };
useEffect(() => {
searchExercises(filters).then(setResults);
}, [filters]);
¿Ves el problema? filters es un objeto nuevo en cada render, y React compara las dependencias con Object.is, así que para él { query: 'press' } y otro { query: 'press' } creado en el render siguiente son cosas distintas. El efecto se ejecuta después de cada render, cambia results, eso provoca otro render, se crea otro filters, el efecto se vuelve a ejecutar… y ya tienes un bucle infinito de peticiones a tu API.
Lo mismo pasa con las funciones que defines dentro del componente y luego pones como dependencia de un efecto. La solución más limpia es depender de valores primitivos, que sí se comparan por valor, o crear el objeto dentro del propio efecto:
useEffect(() => {
let ignore = false;
searchExercises({ query, muscle, equipment }).then((data) => {
if (!ignore) setResults(data);
});
return () => {
ignore = true;
};
}, [query, muscle, equipment]);
La variable ignore no tiene que ver con los renders, pero ya que estamos, evita otro problema típico de los buscadores: si la respuesta de «pre» llega después que la de «press», sin ella acabarías mostrando los resultados de la búsqueda antigua.
Los otros efectos
useEffect no es el único hook de efectos, y los otros cambian cuándo se ejecuta tu código respecto al render y a cuando el navegador pinta la pantalla.
useLayoutEffect funciona igual que useEffect, pero se ejecuta después de que React actualice el DOM y antes de que el navegador pinte. ¿Para qué quieres eso? Imagina que la lista de resultados es un desplegable debajo del buscador y, si no cabe en la pantalla, quieres abrirlo hacia arriba. Para saberlo tienes que medir su altura, y eso solo puedes hacerlo cuando ya está en el DOM:
const listRef = useRef(null);
const [openUp, setOpenUp] = useState(false);
useLayoutEffect(() => {
const { bottom } = listRef.current.getBoundingClientRect();
setOpenUp(bottom > window.innerHeight);
}, [results]);
Con useEffect, el navegador pintaría primero la lista hacia abajo, cortada, y un instante después la verías saltar hacia arriba. Con useLayoutEffect, el setOpenUp provoca otro render, pero React lo hace antes de pintar, así que el usuario solo ve el resultado final. El precio es que todo ese trabajo bloquea el pintado, así que resérvalo para medir o colocar cosas en pantalla y usa useEffect para todo lo demás.
Y hay otro más reciente que encaja con la sección anterior: useEffectEvent, estable desde React 19.2. Supón que el buscador recibe una prop onResults para avisar al padre cada vez que llegan resultados. Si la llamas dentro del efecto, tienes que ponerla como dependencia, y como el padre seguramente crea esa función en cada render, el efecto vuelve a buscar cada vez que el padre se renderiza. useEffectEvent te deja leer el valor más reciente de onResults sin que forme parte de las dependencias:
const notifyResults = useEffectEvent((data) => onResults(data));
useEffect(() => {
searchExercises({ query, muscle, equipment }).then((data) => notifyResults(data));
}, [query, muscle, equipment]);
El efecto ahora solo se repite cuando cambia lo que de verdad define la búsqueda. Eso sí, no lo uses para esconder dependencias que molestan: solo tiene sentido para lógica que reacciona a algo que ha pasado en el efecto, como aquí avisar de que han llegado resultados.
Estado demasiado arriba
Volvamos a las tres razones: cuando un componente se renderiza, se renderizan también todos los componentes que crea en su JSX. Imagina que el query vive en la página que también pinta la cabecera, un panel lateral con los filtros y un gráfico con tu historial de entrenamientos. Cada tecla vuelve a ejecutar todo eso, aunque nada de ello dependa del texto que estás escribiendo.
Antes de pensar en memoizar nada, lo primero que hay que preguntarse es si ese estado puede bajar. Si solo el buscador y su lista necesitan query, el estado debería vivir en un componente que contenga solo el buscador y su lista, y así cada tecla renderiza ese trozo y deja el resto de la página en paz.
Si el buscador tiene que envolver otras partes de la página, puedes dejarlas fuera pasándolas como children:
function SearchPanel({ children }) {
const [query, setQuery] = useState('');
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{children}
</>
);
}
<SearchPanel>
<WorkoutHistoryChart />
</SearchPanel>
SearchPanel se renderiza con cada tecla, pero <WorkoutHistoryChart /> lo ha creado la página, y la página no se ha renderizado, así que React reutiliza ese elemento y se salta el gráfico. Así que cuando las tres razones dicen «se renderiza su padre», el padre es el componente que ha escrito ese JSX, acabe donde acabe en el árbol.
Cuando no puedes bajar el estado, tienes memo. Un componente envuelto en memo se salta el render si sus props son iguales a las del render anterior:
const ExerciseRow = memo(function ExerciseRow({ exercise, onSelect }) {
return <li onClick={() => onSelect(exercise.id)}>{exercise.name}</li>;
});
Si el padre le pasa onSelect={(id) => setSelected(id)}, esa función es nueva en cada render, las props nunca son iguales y el memo no sirve para nada. Para que funcione tienes que mantener la función estable con useCallback:
const handleSelect = useCallback((id) => setSelected(id), []);
En este caso te puedes ahorrar el useCallback: React mantiene la misma función set de useState en todos los renders, así que onSelect={setSelected} le pasa una función estable.
Este es el motivo por el que memo, useMemo y useCallback suelen ir juntos, y también el motivo por el que tanta gente acaba llenando su código de ellos sin saber muy bien cuáles están sirviendo de algo.
El contexto que lo renderiza todo
El contexto es la tercera razón de la lista, y es la que más cuesta ver. Todos los componentes que leen un contexto se renderizan cuando cambia su valor, y React vuelve a comparar ese valor con Object.is:
function SearchProvider({ children }) {
const [query, setQuery] = useState('');
const [favorites, setFavorites] = useState([]);
return (
<SearchContext.Provider value={{ query, setQuery, favorites, setFavorites }}>
{children}
</SearchContext.Provider>
);
}
Cada vez que alguien escribe en el buscador, el objeto de value es nuevo, y todos los componentes que usan SearchContext se renderizan, incluido el botón de favoritos que no tiene nada que ver con el texto buscado. Si metes en un solo contexto cosas que cambian a menudo con cosas que casi nunca cambian, estás atando el ritmo de toda la app al de lo que cambia más rápido. Separa el contexto en varios más pequeños, uno para la búsqueda y otro para los favoritos, y envuelve el value en useMemo cuando sea un objeto.
Cuando escribir va a tirones
Hay un caso en el que, aunque lo hayas hecho todo bien, el render sigue siendo caro: una lista muy larga que tiene que repintarse con cada tecla. Ya no te sobra ningún render que quitar, pero el input espera a que termine la lista y escribir se nota pesado.
Para eso existe useDeferredValue. Le dices a React que la lista puede ir un poco por detrás del input:
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const results = useMemo(
() => filterExercises(exercises, deferredQuery),
[exercises, deferredQuery]
);
El input se actualiza enseguida con query, y React calcula la lista con deferredQuery cuando tiene tiempo, abandonando ese trabajo si llega otra tecla antes de que termine. No te ahorras ningún render, porque React hace uno más en segundo plano, pero React le da prioridad al input sobre la lista, y en un buscador el usuario está mirando el input.
Y una cosa más sobre listas filtradas: usa el id del ejercicio como key, nunca el índice. Al filtrar, el ejercicio que estaba en la posición 3 pasa a estar en la 0, y si la key es el índice React cree que es el mismo elemento, reutiliza el componente y mezcla el estado de uno con el de otro.
React Compiler
Todo lo que he contado sobre memo, useMemo y useCallback es trabajo manual, y fácil de hacer mal. React Compiler es una herramienta que se ejecuta al compilar tu app y añade esa memoización por ti. Ya es estable, funciona con React 17, 18 y 19, y se integra con Babel, Vite, Metro, Rsbuild y Next.js.
En la práctica, con el compilador activado, el objeto filters del ejemplo del bucle solo se crea de nuevo cuando cambian query, muscle o equipment, la función onSelect ya es estable sin useCallback y los hijos que no dependen del texto buscado se saltan el render sin que envuelvas nada en memo, así que puedes borrar casi todos los memo, useMemo y useCallback de las secciones anteriores.
Pero no lo arregla todo. El compilador memoiza componentes y hooks, no cualquier función, y no comparte esa memoria entre componentes distintos. Tampoco cambia el diseño de tu estado: si sincronizas un estado con otro en un useEffect vas a seguir teniendo dos renders por tecla, y si guardas el texto del buscador en un contexto enorme, todos los componentes que lo lean se van a seguir renderizando cuando cambie.
Cómo encontrar los renders que sobran
Nada de esto sirve si no sabes dónde está el problema, y adivinar en rendimiento casi nunca sale bien, así que mídelo con estas herramientas.
Las React Developer Tools tienen una opción, «Highlight updates when components render», que dibuja un borde alrededor de cada componente que se renderiza. Activarla y escribir en el buscador es la forma más rápida de ver si cada tecla está encendiendo media página.
La pestaña Profiler de esas mismas herramientas graba lo que pasa mientras usas la app y te dice cuánto ha tardado cada componente en cada render. Si activas «Record why each component rendered while profiling» en sus ajustes, además te dice el motivo: qué estado, qué prop o qué hook ha cambiado. Es la forma de responder con datos a la pregunta que da título a este artículo.
Si quieres algo que te avise sin tener que grabar, React Scan resalta los renders directamente en la página y marca los que parecen innecesarios, y why-did-you-render escribe en la consola cuando un componente se renderiza con props que son iguales en valor pero distintas en referencia, que es el caso de los objetos y funciones nuevos en cada render.
Y un aviso para no volverte loco: en desarrollo, con StrictMode activado, React ejecuta tus componentes dos veces y monta, desmonta y vuelve a montar los efectos a propósito para ayudarte a encontrar errores. Si ves todo doble en la consola, no es un bug tuyo, y en producción no pasa.
Volviendo a la pregunta de la entrevista, filtrar una lista de ejercicios es una línea de código, pero decidir dónde vive el texto que escribe el usuario, qué se calcula y qué se guarda, y qué partes de la pantalla tienen que enterarse de cada tecla, es lo que hace que ese buscador vaya fluido con diez ejercicios o con diez mil. La próxima vez que un componente se renderice más de lo que esperabas, ¿cuál de las tres razones crees que será?