tutoriales.com

Optimización del Renderizado en React con `useMemo`, `useCallback` y `React.memo` 🚀

Este tutorial profundiza en las técnicas clave de optimización del renderizado en React utilizando los Hooks `useMemo` y `useCallback`, junto con el componente de orden superior `React.memo`. Aprenderás a identificar y prevenir renders innecesarios, mejorando significativamente el rendimiento de tus aplicaciones y la experiencia del usuario.

Intermedio15 min de lectura16 views
Reportar error

Introducción a la Optimización del Renderizado en React ✨

React es una librería increíblemente eficiente para construir interfaces de usuario, pero como con cualquier herramienta, un uso subóptimo puede llevar a problemas de rendimiento. Uno de los desafíos más comunes en aplicaciones React grandes es el re-renderizado innecesario de componentes. Cuando un componente padre se re-renderiza, por defecto, todos sus hijos también lo hacen, incluso si sus props no han cambiado.

Este tutorial te guiará a través de tres herramientas fundamentales que React nos proporciona para combatir estos re-renders innecesarios: React.memo, useMemo y useCallback. Comprender y aplicar estas técnicas es crucial para construir aplicaciones React rápidas, fluidas y con una excelente experiencia de usuario.

🔥 Importante: La optimización prematura es la raíz de todo mal. Antes de aplicar estas técnicas, *siempre* perfila tu aplicación para identificar los cuellos de botella reales. Las herramientas de desarrollo de React (React DevTools) son tus mejores aliadas aquí.

¿Por qué importa la optimización del renderizado? 🎯

Cada vez que un componente se re-renderiza, React necesita ejecutar su función de renderizado y comparar el nuevo árbol de elementos virtuales con el anterior para determinar qué cambios aplicar al DOM real. Si esto ocurre con demasiada frecuencia, o si las funciones de renderizado son costosas computacionalmente, tu aplicación puede volverse lenta, parecer poco receptiva y consumir más recursos del navegador. Esto afecta directamente a la experiencia del usuario.

Causas comunes de re-renders innecesarios:

  • Cambios de estado en un componente padre: Cuando el estado de un componente padre cambia, todos sus hijos se re-renderizan por defecto.
  • Cambios de props: Si las props pasadas a un componente cambian, este se re-renderiza. El problema surge cuando las props parecen iguales pero son referencias a objetos o funciones nuevas en cada render.
  • Cambios de contexto: Si un Context cambia, todos los componentes que lo consumen se re-renderizan.

Nuestro objetivo es minimizar el número de re-renders y el coste de los re-renders inevitables.


React.memo: Optimizando componentes funcionales 🧩

React.memo es un Higher-Order Component (HOC) que nos permite memorizar componentes funcionales. Si las props de un componente memorizado no han cambiado entre renders, React reutilizará el último resultado renderizado en lugar de re-renderizar el componente. Es similar a shouldComponentUpdate en componentes de clase.

¿Cómo funciona React.memo? 🤔

React.memo realiza una comparación superficial (shallow comparison) de las props del componente. Esto significa que compara las propiedades de cada prop de objeto o array por referencia, y los valores primitivos por valor. Si todas las props son estrictamente iguales (===) al valor que tenían en el render anterior, el componente no se re-renderiza.

Implementación básica de React.memo

Imagina que tenemos un componente ListaTareas que recibe una lista de tareas y un componente Tarea individual. Si el padre de ListaTareas se actualiza por alguna razón (por ejemplo, un contador global), ListaTareas y todos sus Tarea se re-renderizarán, incluso si la lista de tareas no ha cambiado.

// Componente sin memorizar
const Tarea = ({ id, descripcion, completada, onToggle }) => {
  console.log(`Renderizando Tarea ${descripcion}`);
  return (
    <li>
      <input
        type="checkbox"
        checked={completada}
        onChange={() => onToggle(id)}
      />
      <span style={{ textDecoration: completada ? 'line-through' : 'none' }}>
        {descripcion}
      </span>
    </li>
  );
};

// Uso en un componente padre (ejemplo)
const ListaTareas = ({ tareas, handleToggle }) => {
  console.log('Renderizando ListaTareas');
  return (
    <ul>
      {tareas.map(tarea => (
        <Tarea
          key={tarea.id}
          id={tarea.id}
          descripcion={tarea.descripcion}
          completada={tarea.completada}
          onToggle={handleToggle} // Problema potencial aquí
        />
      ))}
    </ul>
  );
};

Para optimizar Tarea, lo envolvemos con React.memo:

// Componente Tarea memorizado
const Tarea = React.memo(({ id, descripcion, completada, onToggle }) => {
  console.log(`Renderizando Tarea ${descripcion}`);
  return (
    <li>
      <input
        type="checkbox"
        checked={completada}
        onChange={() => onToggle(id)}
      />
      <span style={{ textDecoration: completada ? 'line-through' : 'none' }}>
        {descripcion}
      </span>
    </li>
  );
});

export default Tarea;

Ahora, si las props de Tarea (id, descripcion, completada, onToggle) son las mismas que en el render anterior, Tarea no se re-renderizará. Esto es especialmente útil para componentes con un renderizado costoso o que son hijos de componentes que se re-renderizan con frecuencia.

Función de comparación personalizada

Por defecto, React.memo utiliza una comparación superficial. Sin embargo, puedes proporcionar tu propia función de comparación como segundo argumento:

const Tarea = React.memo(function Tarea({ id, descripcion, completada, onToggle }) {
  // ... contenido del componente
}, (prevProps, nextProps) => {
  // Retorna true si las props son iguales y el re-render no es necesario
  // Retorna false si las props son diferentes y el re-render es necesario
  return (
    prevProps.id === nextProps.id &&
    prevProps.descripcion === nextProps.descripcion &&
    prevProps.completada === nextProps.completada &&
    prevProps.onToggle === nextProps.onToggle
  );
});
📌 Nota: Usar una función de comparación personalizada es útil cuando la comparación superficial no es suficiente (ej., objetos anidados complejos) o cuando quieres una lógica de comparación específica. Sin embargo, asegúrate de que tu función de comparación sea eficiente, ya que se ejecutará en cada render del padre. En la mayoría de los casos, la comparación superficial predeterminada es suficiente.

Problemas con React.memo y funciones/objetos en las props ⚠️

Aunque React.memo es potente, tiene una limitación clave: la comparación superficial. Si pasas objetos, arrays o funciones inline (creados directamente en el render del padre) como props, sus referencias cambiarán en cada render, incluso si su contenido o lógica es idéntico. Esto hará que React.memo falle y el componente hijo se re-renderice de todas formas.

const Padre = () => {
  const [contador, setContador] = React.useState(0);

  // Esta función se crea de nuevo en cada render de Padre
  const handleClick = () => {
    console.log('Botón clickeado');
  };

  // Esta prop 'data' es un nuevo objeto en cada render de Padre
  const data = { valor: 1 };

  return (
    <div>
      <button onClick={() => setContador(c => c + 1)}>Incrementar Padre: {contador}</button>
      {/* Aunque Hijo está memorizado, se re-renderizará porque handleClick y data son nuevas referencias */}
      <HijoMemorizado onClick={handleClick} data={data} />
    </div>
  );
};

const HijoMemorizado = React.memo(({ onClick, data }) => {
  console.log('Renderizando HijoMemorizado');
  return (
    <div>
      <p>Hijo: {data.valor}</p>
      <button onClick={onClick}>Click en Hijo</button>
    </div>
  );
});

En este ejemplo, HijoMemorizado se re-renderizará cada vez que Padre se re-renderice (por ejemplo, al cambiar contador), porque las referencias de handleClick y data cambian en cada render de Padre. Aquí es donde entran useCallback y useMemo.


useCallback: Memorizando funciones 🤝

useCallback es un Hook de React que te permite memorizar una función. Devuelve una versión memorizada de la función de callback que solo cambia si una de las dependencias proporcionadas ha cambiado. Esto es crucial cuando pasas funciones como props a componentes hijos memorizados con React.memo.

Sintaxis y uso de useCallback

const memorizedCallback = useCallback(
  () => {
    // Tu lógica de función
  },
  [dependencias] // Array de dependencias
);

El Hook useCallback toma dos argumentos:

  1. La función que quieres memorizar.
  2. Un array de dependencias. La función memorizada solo se creará de nuevo si alguna de las dependencias en el array cambia.

Ejemplo práctico con useCallback y React.memo

Retomando el ejemplo anterior, podemos arreglar el problema de los re-renders de HijoMemorizado usando useCallback:

const PadreOpt = () => {
  const [contador, setContador] = React.useState(0);
  const [mensaje, setMensaje] = React.useState('Hola');

  // Memorizamos la función handleClick. Solo se recreará si 'mensaje' cambia.
  const handleClick = React.useCallback(() => {
    console.log(`Botón clickeado. Mensaje actual: ${mensaje}`);
  }, [mensaje]); // Dependencia: mensaje

  // Esta 'data' seguirá siendo nueva en cada render, si la usáramos en el hijo, causaría re-render.
  // Para objetos, usaremos useMemo.
  // const data = { valor: 1 }; 

  return (
    <div>
      <button onClick={() => setContador(c => c + 1)}>Incrementar Padre: {contador}</button>
      <button onClick={() => setMensaje(m => m + '!')}>Actualizar Mensaje</button>
      <HijoMemorizado onClick={handleClick} /> {/* Ahora handleClick es estable */}
    </div>
  );
};

const HijoMemorizado = React.memo(({ onClick }) => {
  console.log('Renderizando HijoMemorizado');
  return (
    <div>
      <p>Soy el hijo memorizado.</p>
      <button onClick={onClick}>Click en Hijo</button>
    </div>
  );
});

export default PadreOpt;

Ahora, cuando PadreOpt se re-renderice debido a un cambio en contador, la función handleClick seguirá siendo la misma referencia (porque mensaje no ha cambiado), permitiendo que HijoMemorizado evite un re-render innecesario.

Concepto clave

Cuándo usar useCallback

  • Cuando pasas funciones como props a componentes hijos memorizados (React.memo).
  • Cuando una función es una dependencia de otro Hook (useEffect, useMemo, useCallback anidados).
  • Cuando la función realiza una operación costosa y quieres evitar recrearla en cada render (aunque useMemo es más adecuado para valores).
⚠️ Advertencia: Un uso excesivo de `useCallback` puede generar más sobrecarga que beneficios, ya que el Hook en sí tiene un coste. Úsalo cuando sea evidente que está previniendo un re-render costoso en un componente hijo memorizado.

useMemo: Memorizando valores 🧠

useMemo es un Hook que te permite memorizar el resultado de una función computacionalmente costosa. Solo recalcula el valor si una de sus dependencias ha cambiado. Es ideal para evitar que cálculos caros se ejecuten en cada render cuando no es necesario.

Sintaxis y uso de useMemo

const memorizedValue = useMemo(
  () => computeExpensiveValue(a, b),
  [a, b] // Array de dependencias
);

El Hook useMemo toma dos argumentos:

  1. Una función que devuelve el valor que quieres memorizar. Esta función se ejecuta solo si las dependencias cambian.
  2. Un array de dependencias. El valor memorizado solo se recalculará si alguna de las dependencias en el array cambia.

Ejemplo práctico con useMemo

Considera un componente que calcula un valor complejo (como filtrar una lista grande o realizar una agregación) y lo pasa a un hijo. Sin useMemo, este cálculo se realizaría en cada render del padre.

const DatosCalculados = ({ items }) => {
  // Simula un cálculo costoso
  const calcularSumaCostosa = (arr) => {
    console.log('Calculando suma costosa...');
    // Supongamos que esto es un bucle largo o una operación compleja
    return arr.reduce((acc, item) => acc + item.valor, 0);
  };

  // SIN useMemo, calcularSumaCostosa se ejecuta en cada render
  const sumaTotalSinMemo = calcularSumaCostosa(items);

  // CON useMemo, calcularSumaCostosa solo se ejecuta si 'items' cambia
  const sumaTotalConMemo = React.useMemo(() => calcularSumaCostosa(items), [items]);

  return (
    <div>
      <p>Suma sin memo: {sumaTotalSinMemo}</p>
      <p>Suma con memo: {sumaTotalConMemo}</p>
    </div>
  );
};

const App = () => {
  const [contador, setContador] = React.useState(0);
  const [dataItems, setDataItems] = React.useState([
    { id: 1, valor: 10 },
    { id: 2, valor: 20 },
    { id: 3, valor: 30 },
  ]);

  const handleAddItem = () => {
    setDataItems(prev => [...prev, { id: prev.length + 1, valor: Math.floor(Math.random() * 50) }]);
  };

  return (
    <div>
      <button onClick={() => setContador(c => c + 1)}>Incrementar Contador: {contador}</button>
      <button onClick={handleAddItem}>Añadir Item</button>
      {/* Pasar dataItems directamente a DatosCalculados */}
      <DatosCalculados items={dataItems} />
    </div>
  );
};

Al ejecutar este ejemplo, verás que "Calculando suma costosa..." se imprime cada vez que contador cambia si no usas useMemo. Con useMemo, solo se imprime cuando dataItems realmente cambia.

useMemo también es la solución para pasar objetos o arrays estables a componentes hijos memorizados. Si tu data del ejemplo de React.memo fuera un objeto complejo que no cambia estructuralmente a menudo pero se recrea en cada render del padre, useMemo lo haría estable:

const PadreConMemoParaObjeto = () => {
  const [contador, setContador] = React.useState(0);

  // El objeto 'data' ahora es memorizado y solo cambia si 'contador' cambia (que no es el caso aquí, es solo para el ejemplo)
  const memoizedData = React.useMemo(() => ({
    valor: 1,
    config: { theme: 'dark' }
  }), []); // Array de dependencias vacío, se crea una sola vez.

  // Si el objeto 'data' dependiera de 'contador', sería así:
  // const memoizedData = React.useMemo(() => ({ valor: contador * 2 }), [contador]);

  return (
    <div>
      <button onClick={() => setContador(c => c + 1)}>Incrementar Padre: {contador}</button>
      <HijoMemorizadoConObjeto data={memoizedData} />
    </div>
  );
};

const HijoMemorizadoConObjeto = React.memo(({ data }) => {
  console.log('Renderizando HijoMemorizadoConObjeto');
  return (
    <div>
      <p>Hijo: {data.valor}, Theme: {data.config.theme}</p>
    </div>
  );
});

Ahora, HijoMemorizadoConObjeto solo se re-renderizará si memoizedData cambia su referencia, lo cual solo sucede si las dependencias de useMemo cambian.

Cuándo usar useMemo

  • Para cálculos computacionalmente costosos que se repiten en cada render.
  • Para memorizar objetos o arrays que se pasan como props a componentes hijos memorizados (React.memo) y cuya recreación en cada render del padre rompería la memorización.
  • Cuando un valor es una dependencia de otro Hook (useEffect, useCallback).

useMemo vs. useCallback:

CaracterísticauseMemouseCallback
---------
Qué memorizaEl resultado de una función (un valor)La instancia de una función (la propia función)
Cuándo usarPara cálculos costosos, objetos/arrays establesPara funciones que se pasan como props o dependencias
---------
Tipo de retornoEl valor calculadoLa función memorizada
Re-ejecuciónCuando las dependencias cambian, ejecuta la funciónCuando las dependencias cambian, devuelve una nueva función
💡 Consejo: Piensa en `useCallback` cuando estás pasando funciones. Piensa en `useMemo` cuando estás pasando valores (objetos, arrays, resultados de cálculos).

Patrones avanzados y consideraciones 🧐

Dependencias correctas ✅

El aspecto más crítico de useMemo y useCallback es el array de dependencias. Un array de dependencias incorrecto puede llevar a bugs difíciles de depurar (valores stale closures) o a que la memorización no funcione correctamente. Siempre incluye todas las variables y funciones que se usan dentro de tu función memorizada y que podrían cambiar con el tiempo.

const MiComponente = () => {
  const [count, setCount] = React.useState(0);
  const [items, setItems] = React.useState([1, 2, 3]);

  // Incorrecto: La función no capturaría el 'count' actualizado si este cambia
  // const handleClick = React.useCallback(() => console.log('Count:', count), []);

  // Correcto: La función se recreará si 'count' cambia, capturando el valor más reciente
  const handleClick = React.useCallback(() => console.log('Count:', count), [count]);

  // Correcto: El valor se recalculará solo si 'items' cambia
  const sum = React.useMemo(() => items.reduce((a, b) => a + b, 0), [items]);

  // ...
};
⚠️ Advertencia: El linter de ESLint (`eslint-plugin-react-hooks`) es tu mejor amigo para gestionar las dependencias. Asegúrate de tenerlo configurado en tu proyecto. Te avisará sobre dependencias faltantes.

¿Cuándo no usar memorización? ⛔

La memorización no es una bala de plata y puede incluso perjudicar el rendimiento si se usa en exceso o incorrectamente. Cada React.memo, useMemo o useCallback tiene un coste de memoria y de CPU (para la comparación de dependencias o props).

No uses memorización si:

  • El componente o el cálculo no es costoso. Pequeños componentes que re-renderizan rápidamente no necesitan optimización.
  • La sobrecarga de la memorización es mayor que el beneficio. Esto ocurre con frecuencia en componentes muy simples.
  • Las props o dependencias cambian casi siempre de todas formas. En este caso, la memorización no tendrá efecto y solo añade coste.
Inicio ¿Problema de rendimiento? No Fin ¿Renderizado costoso o frecuente de hijos? No Otras optimizaciones ¿Son las props del hijo estables? No useCallback / useMemo React.memo(Hijo) Fin
¿Qué es un 'Higher-Order Component' (HOC)? Un HOC es una función que toma un componente como argumento y devuelve un nuevo componente. En el caso de `React.memo`, toma tu componente funcional y devuelve una versión memorizada de él, que implementa la lógica de comparación de props internamente. Son una forma avanzada de reutilizar lógica de componentes en React.

Contexto y memorización 💡

El Context API puede ser una fuente de re-renders inesperados. Si un Provider de contexto cambia su value (por ejemplo, porque el objeto value se recrea en cada render del padre del Provider), todos los consumidores de ese contexto se re-renderizarán, incluso si las partes que usan no han cambiado.

Para evitar esto, asegúrate de que el value de tu Context.Provider sea estable, usando useMemo si es un objeto o array complejo, o useCallback si es una función:

// Contexto para un tema global
const ThemeContext = React.createContext(null);

const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = React.useState('light');

  // El objeto de contexto debe ser memorizado para evitar re-renders innecesarios de los consumidores
  const contextValue = React.useMemo(() => ({
    theme,
    toggleTheme: () => setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
  }), [theme]); // Depende solo de 'theme'

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
};

Comparación superficial vs. profunda

React.memo y los Hooks de memorización realizan una comparación superficial. Esto significa que si tienes objetos anidados o arrays complejos como props o dependencias, solo se compara la referencia de nivel superior. Si el contenido anidado cambia pero la referencia externa no, la memorización fallará.

Para estos casos, a menudo es mejor estructurar tus props de forma que los datos complejos se pasen solo cuando realmente sea necesario o usar una función de comparación personalizada en React.memo si sabes exactamente cómo comparar esos objetos anidados de forma eficiente. Evita comparar objetos profundos a menos que sea estrictamente necesario y estés seguro de que el rendimiento es un problema, ya que una comparación profunda puede ser más costosa que el re-renderizado.


Herramientas para detectar re-renders 🛠️

  • React Developer Tools: La extensión de navegador de React es indispensable. En la pestaña 'Profiler', puedes grabar renders y ver qué componentes se re-renderizan, por qué y cuánto tiempo tardan.
  • Why Did You Render: Una librería excelente para desarrollo que te avisa en consola cuando un componente memorizado se re-renderiza innecesariamente y te explica la razón. Es muy útil para identificar dónde aplicar useMemo o useCallback.
💡 Consejo: `Why Did You Render` debe usarse *solo en desarrollo*. No lo incluyas en tus builds de producción.

Conclusión ✅

Dominar React.memo, useMemo y useCallback es un paso crucial para escribir aplicaciones React de alto rendimiento. Recuerda siempre medir antes de optimizar y aplicar estas herramientas estratégicamente donde realmente aporten valor. Un uso indiscriminado puede generar código más complejo y, paradójicamente, una ligera disminución del rendimiento debido a la sobrecarga de la memorización.

Al aplicar estas técnicas con sensatez, tus aplicaciones serán más rápidas, más eficientes y ofrecerán una mejor experiencia a tus usuarios. ¡Feliz codificación optimizada!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!