32blogby StudioMitsu

Optimización de Re-renders en React: Guía Práctica

Detén los re-renders innecesarios con memo, useMemo y useCallback. Aprende cuándo usar cada uno, cómo medir y errores comunes.

9 min read
ReactperformancememouseMemouseCallbackre-render
Contenido

Cuando React se siente lento, los re-renders innecesarios suelen ser los culpables. Los componentes se re-ejecutan más de lo necesario, y el coste acumulado se manifiesta como jank.

Una vez tuve una página con una lista de varios cientos de elementos. El scroll se sentía pesado. Abrí React DevTools y descubrí que cada componente se re-renderizaba en cada interacción — incluyendo los que no tenían nada que ver con el cambio.

Este artículo explica cómo funcionan los re-renders, y luego te muestra exactamente cómo usar memo, useMemo y useCallback para detener los innecesarios. No se trata de "esparcir memo por todos lados" — sino de entender por qué cada herramienta es necesaria en cada escenario.

¿Por Qué Ocurren los Re-renders?

React re-renderiza un componente en tres situaciones:

  1. Su propio estado cambió (se llamó a setState)
  2. Su padre se re-renderizó
  3. Un contexto que consume cambió

El segundo punto es el más sorprendente: cuando un padre se re-renderiza, todos sus hijos se re-renderizan por defecto — sin importar si sus props cambiaron.

tsx
function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* ChildA and ChildB re-render every time count changes */}
      <ChildA />
      <ChildB />
    </div>
  );
}

ChildA y ChildB no reciben count como prop, sin embargo se re-renderizan cada vez que el estado de Parent cambia. A escala, esto se acumula en problemas reales de rendimiento.

Los Re-renders No Son Inherentemente Malos

Aclaración importante: los re-renders en sí no son malignos. React está diseñado para hacerlos rápidos, y las actualizaciones reales del DOM se minimizan al diff. Decenas de componentes simples re-renderizándose es imperceptible para los usuarios.

La optimización se vuelve necesaria cuando:

  • Los componentes contienen cálculos costosos
  • Las listas tienen cientos de elementos
  • Necesitas animaciones fluidas a 60fps

Mide primero, optimiza después. Esto no es negociable.

Visualiza los Re-renders con React DevTools

Antes de optimizar, identifica qué se está re-renderizando innecesariamente.

Usa la pestaña Profiler en React DevTools (extensión del navegador):

  1. Instala React DevTools
  2. Abre la pestaña Profiler
  3. Haz clic en el ícono del engranaje → activa "Highlight updates when components render"
  4. Interactúa con tu app — los componentes re-renderizados parpadearán

Guía de colores:

  • Azul — renders poco frecuentes (bien)
  • Verde — moderado
  • Amarillo — frecuente
  • Rojo — muy frecuente (investiga)

React.memo: Omitir Re-renders Cuando las Props No Cambiaron

React.memo envuelve un componente y omite el re-renderizado cuando las props no han cambiado (comparadas con Object.is).

Sin Optimización

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
      {/* NameDisplay re-renders every time count changes */}
      <NameDisplay name={name} />
    </div>
  );
}

function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // Called every time
  return <p>Hello, {name}</p>;
}

Con React.memo

tsx
// Only re-renders when name actually changes
const NameDisplay = memo(function NameDisplay({ name }: { name: string }) {
  console.log("NameDisplay rendered"); // Only called when name changes
  return <p>Hello, {name}</p>;
});

Cuando memo No Funciona

tsx
function Parent() {
  const [count, setCount] = useState(0);

  // ❌ New object reference on every render
  const user = { name: "Alice", age: 20 };
  // ❌ New function reference on every render
  const handleClick = () => console.log("clicked");

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      {/* user and handleClick have new references every render — memo can't help */}
      <UserCard user={user} onClick={handleClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
  onClick,
}: {
  user: { name: string; age: number };
  onClick: () => void;
}) {
  console.log("UserCard rendered"); // Still called every time
  return (
    <div onClick={onClick}>
      {user.name} ({user.age})
    </div>
  );
});

Los objetos y funciones crean nuevas referencias en cada render. memo ve "las props cambiaron" y re-renderiza de todas formas. Aquí es donde entran useMemo y useCallback.

useMemo: Estabilizar Referencias de Objetos y Cachear Cálculos Costosos

useMemo memoriza un valor calculado, devolviendo la misma referencia a menos que las dependencias cambien.

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");
  const [age, setAge] = useState(20);

  // ✅ New object only when name or age changes
  const user = useMemo(() => ({ name, age }), [name, age]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* When only count changes, user reference stays stable — UserCard skips re-render */}
      <UserCard user={user} />
    </div>
  );
}

const UserCard = memo(function UserCard({
  user,
}: {
  user: { name: string; age: number };
}) {
  console.log("UserCard rendered");
  return <div>{user.name} ({user.age})</div>;
});

Cachear Cálculos Costosos

tsx
function SearchResults({ items, query }: { items: Item[]; query: string }) {
  // ❌ Filter runs on every render
  // const filtered = items.filter(item => item.name.includes(query));

  // ✅ Filter only runs when items or query changes
  const filtered = useMemo(
    () => items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    ),
    [items, query]
  );

  return (
    <ul>
      {filtered.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Para más de 1.000 elementos con filtrado o clasificación compleja, useMemo vale la pena. Para 10 elementos simples, el overhead de useMemo probablemente excede el ahorro.

useCallback: Estabilizar Referencias de Funciones

useCallback memoriza una función, devolviendo la misma referencia a menos que las dependencias cambien.

tsx
function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Alice");

  // ✅ New function only when name changes
  const handleUserClick = useCallback(() => {
    console.log(`${name} was clicked`);
  }, [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>count: {count}</button>
      {/* When only count changes, handleUserClick reference stays stable */}
      <UserCard onClick={handleUserClick} />
    </div>
  );
}

const UserCard = memo(function UserCard({ onClick }: { onClick: () => void }) {
  console.log("UserCard rendered");
  return <button onClick={onClick}>Click me</button>;
});

useMemo vs useCallback

useCallback(fn, deps) es equivalente a useMemo(() => fn, deps). Piensa en useCallback como un atajo para memorizar funciones:

tsx
// These two are identical
const fn1 = useCallback(() => doSomething(a, b), [a, b]);
const fn2 = useMemo(() => () => doSomething(a, b), [a, b]);

Ejemplo Real: Optimizando una Lista Pesada

Veamos una optimización completa de una lista grande de tareas.

Antes

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500 items
  const [filter, setFilter] = useState("all");

  // Filtering runs on every render
  const filtered = todos.filter(todo => {
    if (filter === "done") return todo.done;
    if (filter === "pending") return !todo.done;
    return true;
  });

  // New function references on every render
  const handleToggle = (id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  };

  const handleDelete = (id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  };

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          // Every TodoItem re-renders when filter changes
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>
    </div>
  );
}

Después

tsx
function TodoList() {
  const [todos, setTodos] = useState(initialTodos); // 500 items
  const [filter, setFilter] = useState("all");

  // ✅ Filter only recalculates when todos or filter changes
  const filtered = useMemo(
    () => todos.filter(todo => {
      if (filter === "done") return todo.done;
      if (filter === "pending") return !todo.done;
      return true;
    }),
    [todos, filter]
  );

  // ✅ Stable references — functional updates mean no need for todos in deps
  const handleToggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, []);

  const handleDelete = useCallback((id: string) => {
    setTodos(prev => prev.filter(t => t.id !== id));
  }, []);

  return (
    <div>
      <FilterButtons filter={filter} onChange={setFilter} />
      <ul>
        {filtered.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle} // Stable reference
            onDelete={handleDelete} // Stable reference
          />
        ))}
      </ul>
    </div>
  );
}

// ✅ memo wrap — skips re-render when todo, onToggle, onDelete haven't changed
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
}) {
  return (
    <li>
      <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
        {todo.text}
      </span>
      <button onClick={() => onToggle(todo.id)}>Toggle</button>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
});

Qué cambió:

  1. useMemo para el filtrado — el filtro de 500 elementos ya no se ejecuta en renders no relacionados
  2. useCallback para los handlers — las actualizaciones funcionales (prev =>) permiten omitir todos de las deps
  3. memo en TodoItem — los elementos individuales omiten re-renders cuando nada cambió para ellos

Prevenir Re-renders Causados por Context

Cuando un valor de contexto cambia, cada componente que consume ese contexto se re-renderiza. Sobrecargar un solo contexto causa re-renders en cascada.

tsx
// ❌ Everything in one context
const AppContext = createContext({
  user: null,
  theme: "dark",
  notifications: [],
});

// A notification update causes components only using user to re-render
tsx
// ✅ Split by update frequency
const UserContext = createContext(null);
const ThemeContext = createContext("dark");
const NotificationContext = createContext([]);

// Components using UserContext only re-render when user changes

Agrupa los valores del contexto según la frecuencia con la que cambian juntos.

Conclusión

La optimización de re-renders sigue este orden: medir → identificar → corregir.

HerramientaPropósitoCuándo Usar
React.memoOmitir re-renders cuando las props no cambianComponentes pesados con re-renders innecesarios confirmados
useMemoCachear valores y estabilizar referencias de objetosCálculos costosos, objetos pasados a componentes memorizados
useCallbackEstabilizar referencias de funcionesFunciones pasadas a componentes memorizados, funciones en deps de useEffect

"Esparcir memo por todos lados" frecuentemente empeora las cosas. Abre el Profiler de React DevTools, confirma qué re-renders son innecesarios, rastrea la causa y aplica la corrección adecuada.

Prioridad de optimización:

  1. Mide y confirma que el problema existe
  2. Considera soluciones estructurales (colocación de componentes) primero
  3. Luego recurre a memouseCallbackuseMemo