Back

Por Qué Tu App React Se Re-renderiza Demasiado: Guía Profunda de Optimización de Rendimiento

Por Qué Tu App React Se Re-renderiza Demasiado: Guía Profunda de Optimización de Rendimiento

Has construido una aplicación React hermosa. El código está limpio, los componentes bien estructurados, y todo funciona. Pero algo está mal. Escribir en un campo de formulario se siente lento. Hacer scroll en una lista tartamudea. Abrir un modal toma un momento notable. Tu app se siente... lenta.

Abres React DevTools Profiler, y se te cae el alma. Los componentes se re-renderizan 47 veces cuando escribes un solo carácter. Un simple clic en un botón desencadena más de 200 actualizaciones de componentes. Todo el árbol de la app se ilumina como un árbol de Navidad con cada cambio de estado.

Tienes un problema de re-renderizado. Y no estás solo.

Este es el problema de rendimiento más común en aplicaciones React, y también el más malentendido. Los desarrolladores recurren a React.memo, useMemo, y useCallback como encantamientos mágicos, esparciéndolos por todas partes esperando que algo funcione. Spoiler: ese enfoque usualmente empeora las cosas.

En esta guía profunda, diseccionaremos exactamente por qué los componentes React se re-renderizan, identificaremos los patrones que causan más daño, y recorreremos optimizaciones del mundo real que redujeron los conteos de renderizado en un 80% en aplicaciones de producción. Sin programación de cargo cult—solo entendiendo el sistema y aplicando correcciones dirigidas.

El Modelo Mental de Re-renderizado de React

Antes de optimizar, necesitas entender qué dispara un re-renderizado. El comportamiento de re-renderizado de React sigue reglas simples:

Regla 1: Los Cambios de Estado Disparan Re-renderizados

Cuando el estado de un componente cambia vía useState o useReducer, ese componente se re-renderiza:

function Counter() { const [count, setCount] = useState(0); // Cada clic dispara un re-renderizado de Counter return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }

Esto es esperado y necesario. No hay optimización necesaria aquí.

Regla 2: Los Re-renderizados del Padre Se Propagan a los Hijos

Cuando un componente se re-renderiza, todos sus hijos también se re-renderizan, independientemente de si sus props cambiaron:

function Parent() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> {/* ExpensiveChild se re-renderiza en CADA cambio de count */} {/* aunque no recibe props relacionados con count */} <ExpensiveChild /> </div> ); } function ExpensiveChild() { // Esto se ejecuta en cada re-renderizado del padre console.log('ExpensiveChild rendered'); return <div>Soy costoso de renderizar</div>; }

Esta es la fuente del 90% de los problemas de rendimiento. El padre tiene estado, entonces se re-renderiza. El hijo no le importa ese estado, pero se re-renderiza de todos modos porque React no lo sabe.

Regla 3: Los Cambios de Context Re-renderizan Todos los Consumers

Cada componente que llama useContext(SomeContext) se re-renderizará cuando el valor de ese context cambie:

const ThemeContext = createContext({ theme: 'light' }); function App() { const [theme, setTheme] = useState('light'); const [user, setUser] = useState(null); // Problema: cambiar user causa que los consumers de theme se re-rendericen // porque todo el objeto value se recrea return ( <ThemeContext.Provider value={{ theme, user, setUser }}> <ThemedButton /> {/* ¡Se re-renderiza cuando user cambia! */} </ThemeContext.Provider> ); }

Esta es la segunda fuente más grande de problemas de rendimiento—valores de context que cambian muy frecuentemente o contienen demasiado.

Identificando el Problema: React DevTools Profiler

Antes de optimizar cualquier cosa, necesitas datos. Abre React DevTools y navega a la pestaña Profiler.

Paso 1: Graba una Interacción Problemática

Haz clic en "Start profiling" y realiza la acción que se siente lenta. Escribe en un input, haz scroll en una lista, o alterna un modal. Luego detén el profiling.

Paso 2: Analiza el Flamegraph

El flamegraph te muestra:

  • Qué componentes se renderizaron (barras coloreadas)
  • Cuánto tiempo tomó cada renderizado (ancho de barra)
  • Por qué se renderizaron (hover para detalles)

Busca:

  1. Componentes renderizándose cuando no deberían (barras grises que deberían ser amarillas)
  2. El mismo componente renderizándose múltiples veces (barras repetidas en la línea de tiempo)
  3. Componentes costosos renderizándose frecuentemente (barras anchas apareciendo seguido)

Paso 3: Habilita "Highlight updates when components render"

En la configuración de React DevTools, habilita esta opción. Ahora interactúa con tu app. Los componentes que se re-rendericen parpadearán. Si toda tu app parpadea cuando escribes un carácter, encontraste tu problema.

Los Mayores Errores de Re-renderizado (Y Cómo Corregirlos)

Error 1: Crear Objetos/Arrays en el Render

Este es el error más común. Crear nuevos objetos o arrays durante el render causa que los componentes hijos reciban props "nuevos" cada vez:

// ❌ MAL: Crea nuevo array en cada render function TodoList({ todos }) { return ( <List items={todos.filter(t => !t.completed)} // Nuevo array cada vez config={{ showDates: true }} // Nuevo objeto cada vez /> ); } // ✅ BIEN: Referencias estables function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); const config = useMemo( () => ({ showDates: true }), [] // Deps vacíos = nunca cambia ); return <List items={activeTodos} config={config} />; }

Aún mejor—si el config nunca cambia, muévelo fuera del componente:

// Mejor: Completamente fuera del ciclo de render const LIST_CONFIG = { showDates: true }; function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); return <List items={activeTodos} config={LIST_CONFIG} />; }

Error 2: Props de Funciones Inline

Pasar funciones inline como props crea nuevas referencias de función en cada render:

// ❌ MAL: Nueva referencia de función cada render function TodoItem({ todo, onToggle }) { return ( <Checkbox checked={todo.completed} onChange={() => onToggle(todo.id)} // Nueva función cada vez /> ); } // ✅ BIEN: Callback estable function TodoItem({ todo, onToggle }) { const handleToggle = useCallback( () => onToggle(todo.id), [todo.id, onToggle] ); return ( <Checkbox checked={todo.completed} onChange={handleToggle} /> ); }

Importante: useCallback solo ayuda si el componente hijo está memoizado (React.memo) o usa el callback en sus propios arrays de dependencias. De lo contrario, estás añadiendo overhead sin beneficio.

Error 3: Elevar el Estado Demasiado Alto

El estado debe vivir tan cerca de donde se usa como sea posible:

// ❌ MAL: Estado del formulario en App causa que todo el árbol se re-renderice function App() { const [formData, setFormData] = useState({ name: '', email: '' }); return ( <div> <Header /> {/* Se re-renderiza en cada tecla */} <Sidebar /> {/* Se re-renderiza en cada tecla */} <Form formData={formData} setFormData={setFormData} /> <Footer /> {/* Se re-renderiza en cada tecla */} </div> ); } // ✅ BIEN: Estado colocado con su uso function App() { return ( <div> <Header /> <Sidebar /> <Form /> {/* El estado vive aquí */} <Footer /> </div> ); } function Form() { const [formData, setFormData] = useState({ name: '', email: '' }); // Solo Form y sus hijos se re-renderizan al teclear return (/* ... */); }

Error 4: Recreación del Objeto Value del Context

Los valores de context se comparan por referencia. Si creas un nuevo objeto en cada render, cada consumer se re-renderiza:

// ❌ MAL: Nuevo objeto cada render = todos los consumers se re-renderizan function AuthProvider({ children }) { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser, isLoggedIn: !!user }}> {children} </AuthContext.Provider> ); } // ✅ BIEN: Valor memoizado function AuthProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo( () => ({ user, setUser, isLoggedIn: !!user }), [user] // Solo recrear cuando user cambia ); return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> ); }

Error 5: Un Solo Mega-Context

Poner todo en un context significa que cada cambio re-renderiza cada consumer:

// ❌ MAL: Un context para todo const AppContext = createContext({ user: null, theme: 'light', notifications: [], sidebarOpen: false, // ... 20 propiedades más }); // Cada componente usando useContext(AppContext) se re-renderiza // cuando CUALQUIERA de estos valores cambia // ✅ BIEN: Dividir contexts por frecuencia de actualización const UserContext = createContext(null); // Raramente cambia const ThemeContext = createContext('light'); // Casi nunca cambia const NotificationContext = createContext([]); // Cambia frecuentemente const UIContext = createContext({}); // Cambia en interacción

Patrones de Optimización Avanzados

Patrón 1: Composición de Componentes (Children como Props)

En lugar de renderizar hijos dentro de un padre con estado, pásalos como props:

// ❌ MAL: Los hijos se re-renderizan cuando el estado del padre cambia function Modal({ isOpen }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> <ExpensiveContent /> {/* Se re-renderiza al arrastrar */} </div> ); } // ✅ BIEN: Children pasados como props no se re-renderizan function Modal({ isOpen, children }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> {children} {/* La referencia es estable, no hay re-render */} </div> ); } // Uso: <Modal isOpen={isOpen}> <ExpensiveContent /> </Modal>

Esto funciona porque children se crea en el padre de Modal, no dentro de Modal. Cuando el estado de posición de Modal cambia, la referencia del prop children permanece igual.

Patrón 2: Colocación de Estado Extrayendo Componentes

Cuando tienes un componente con preocupaciones mixtas—algo pesado en estado, algo pesado en props—extrae la parte con estado:

// ❌ MAL: Posición del mouse causa que toda la lista se re-renderice function ItemList({ items }) { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> {/* ¡Se re-renderiza al mover el mouse! */} ))} </div> ); } // ✅ BIEN: Extraer la parte con estado function ItemList({ items }) { return ( <div> <CursorTracker /> {/* Contiene su propio estado */} {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> ))} </div> ); } function CursorTracker() { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> </div> ); }

Patrón 3: Consumers de Context Selectivos

Cuando solo necesitas parte de un valor de context, crea un hook personalizado que se suscriba selectivamente:

// Problema: useContext se re-renderiza cuando CUALQUIER parte del context cambia function UserAvatar() { const { user } = useContext(AppContext); // Se re-renderiza cuando notifications cambia, theme cambia, etc. return <img src={user.avatar} />; } // Solución: Usar una librería de gestión de estado con selectores // (Zustand, Jotai, o Redux con selectores) import { create } from 'zustand'; const useStore = create((set) => ({ user: null, theme: 'light', notifications: [], setUser: (user) => set({ user }), })); function UserAvatar() { // Solo se re-renderiza cuando user cambia const user = useStore((state) => state.user); return <img src={user?.avatar} />; }

Patrón 4: Virtualización para Listas Largas

Si estás renderizando una lista con 100+ elementos, virtualízala:

// ❌ MAL: Renderiza los 10,000 elementos function MessageList({ messages }) { return ( <div className="messages"> {messages.map(msg => ( <Message key={msg.id} message={msg} /> ))} </div> ); } // ✅ BIEN: Solo renderiza elementos visibles import { Virtuoso } from 'react-virtuoso'; function MessageList({ messages }) { return ( <Virtuoso data={messages} itemContent={(index, msg) => <Message message={msg} />} /> ); }

Librerías de virtualización populares:

  • react-virtuoso: Excelente para interfaces tipo chat
  • @tanstack/react-virtual: Headless, flexible
  • react-window: Ligera, probada en batalla

Cuándo NO Optimizar

La optimización de rendimiento tiene costos:

  1. La complejidad del código aumenta
  2. El debugging se vuelve más difícil
  3. Se pueden introducir bugs
  4. La optimización prematura desperdicia tiempo

No optimices si:

  • El componente renderiza rápido (< 16ms)
  • El componente raramente se re-renderiza
  • Los usuarios no se han quejado del rendimiento
  • No tienes datos del profiler mostrando que es un problema

React es rápido por defecto. El algoritmo de diffing del Virtual DOM está altamente optimizado. La mayoría de los re-renderizados son baratos. Solo optimiza cuando tengas evidencia de un problema.

La Regla del 80%: Resultados del Mundo Real

En nuestra aplicación de producción, aplicamos estos principios sistemáticamente:

Antes:

  • Promedio de 847 renderizados de componentes por interacción de usuario
  • Latencia de input de 120ms
  • Caídas de frames durante el scroll

Cambios Realizados:

  1. Movimos estado de formulario a los formularios (-40% renderizados)
  2. Dividimos un mega-context en 5 contexts enfocados (-25% renderizados)
  3. Memoizamos cálculos costosos de items de lista (-10% renderizados)
  4. Virtualizamos la lista principal de mensajes (-15% renderizados, eliminamos el tartamudeo del scroll)

Después:

  • Promedio de 156 renderizados de componentes por interacción de usuario (reducción del 81%)
  • Latencia de input de 12ms
  • Scroll suave a 60fps

Los arreglos tomaron 2 días en implementar. El profiling tomó 1 día. Entender el problema fue la parte difícil.

Lista de Verificación de Debugging

Cuando encuentres un problema de rendimiento, sigue esta lista:

  1. Primero profilea - Usa React DevTools Profiler para identificar el problema real
  2. Verifica props de nuevos objetos/arrays - Estos son los culpables más comunes
  3. Mira el uso de context - ¿Está cambiando un valor de context muy frecuentemente?
  4. Verifica la ubicación del estado - ¿Está el estado elevado más de lo necesario?
  5. Revisa el renderizado de listas - ¿Estás renderizando cientos de elementos sin virtualización?
  6. Mide después de los cambios - ¿Tu optimización realmente ayudó?

Conclusión

Los re-renderizados de React no son el enemigo—los re-renderizados innecesarios lo son. El framework está diseñado para ser rápido por defecto, pero no puede leer tu mente sobre qué actualizaciones son significativas.

Las mejores optimizaciones vienen de entender tu árbol de componentes:

  • Coloca el estado con los componentes que lo usan
  • Divide contexts por frecuencia de actualización
  • Usa patrones de composición para aislar actualizaciones
  • Memoiza estratégicamente, no en todas partes
  • Virtualiza listas largas

Lo más importante: mide antes y después. No confíes en tus instintos—confía en el profiler.

Tus usuarios nunca verán qué tan elegante es tu código. Solo sentirán qué tan rápido responde tu app. Ahora tienes las herramientas para darles esa experiencia.

reactperformanceoptimizationjavascriptfrontendhooksmemousecallbackusememo