Evitando Re-renderizados Innecesarios — Identifica y elimina renderizados desperdiciados para una interfaz más rápida
Receta
// BEFORE: Every keystroke re-renders the entire product list (47 renders)
function ProductPage() {
const [search, setSearch] = useState("");
const [products] = useState<Product[]>(initialProducts);
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<ProductList products={products} /> {/* Re-renders on every keystroke */}
<Footer /> {/* Also re-renders unnecessarily */}
</div>
);
}
// AFTER: Only SearchBar re-renders on keystroke (3 renders)
function ProductPage() {
const [products] = useState<Product[]>(initialProducts);
return (
<div>
<SearchBar /> {/* State colocated here */}
<ProductList products={products} /> {/* No longer re-renders */}
<Footer /> {/* No longer re-renders */}
</div>
);
}
function SearchBar() {
const [search, setSearch] = useState("");
return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}Cuándo usarlo: Cuando el Profiler de React DevTools muestra componentes parpadeando en cada interacción, o cuando la interfaz se siente lenta durante escritura, desplazamiento o cambio de pestaña. Siempre perfila primero para confirmar el problema antes de aplicar soluciones.
Ejemplo Funcional
// ---- BEFORE: Slow list with unnecessary re-renders ----
interface Task {
id: string;
title: string;
completed: boolean;
}
// Problem: TaskItem re-renders for ALL items when any state changes
function TaskApp() {
const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
const [filter, setFilter] = useState("all");
const [newTitle, setNewTitle] = useState("");
const toggleTask = (id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const filtered = tasks.filter((t) => {
if (filter === "done") return t.completed;
if (filter === "todo") return !t.completed;
return true;
});
return (
<div>
{/* Typing here re-renders all 500 TaskItems */}
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="New task..."
/>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="done">Done</option>
<option value="todo">Todo</option>
</select>
<ul>
{filtered.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={() => toggleTask(task.id)} // New function every render!
/>
))}
</ul>
</div>
);
}
function TaskItem({ task, onToggle }: { task: Task; onToggle: () => void }) {
// Simulated expensive render
const start = performance.now();
while (performance.now() - start < 1) {} // 1ms per item = 500ms total
return (
<li onClick={onToggle} style={{ textDecoration: task.completed ? "line-through" : "none" }}>
{task.title}
</li>
);
}
// ---- AFTER: Optimized — reduces re-renders from 500 to ~1-3 per interaction ----
function TaskAppOptimized() {
const [tasks, setTasks] = useState<Task[]>(generateTasks(500));
const [filter, setFilter] = useState("all");
const toggleTask = useCallback((id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
}, []);
const filtered = useMemo(
() =>
tasks.filter((t) => {
if (filter === "done") return t.completed;
if (filter === "todo") return !t.completed;
return true;
}),
[tasks, filter]
);
return (
<div>
{/* State colocated — typing no longer re-renders the list */}
<NewTaskInput
onAdd={(title) =>
setTasks((prev) => [...prev, { id: crypto.randomUUID(), title, completed: false }])
}
/>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="done">Done</option>
<option value="todo">Todo</option>
</select>
<ul>
{filtered.map((task) => (
<MemoizedTaskItem key={task.id} task={task} onToggle={toggleTask} />
))}
</ul>
</div>
);
}
// Colocated input state — isolated from the list
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
const [title, setTitle] = useState("");
return (
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim()) {
onAdd(title.trim());
setTitle("");
}
}}
placeholder="New task..."
/>
);
}
// Memoized item — only re-renders when its own task changes
const MemoizedTaskItem = memo(function TaskItem({
task,
onToggle,
}: {
task: Task;
onToggle: (id: string) => void;
}) {
const start = performance.now();
while (performance.now() - start < 1) {}
return (
<li
onClick={() => onToggle(task.id)}
style={{ textDecoration: task.completed ? "line-through" : "none" }}
>
{task.title}
</li>
);
});Lo que esto demuestra:
- Colocation de estado: mover el estado de entrada a
NewTaskInputevita que 500 elementos se re-renderizen en cada pulsación de tecla React.memoenMemoizedTaskItemomite re-renderizados cuando los propstaskyonToggleno cambianuseCallbackentoggleTaskestabiliza la referencia de función para que memo funcioneuseMemoenfilteredevita recomputar el filtro en cambios de estado no relacionados- El tiempo total de renderizado cae de ~500ms a menos de 5ms en cada pulsación de tecla
Inmersión Profunda
Cómo Funciona
- El cambio de estado provoca un re-renderizado — Cuando se llama a
setState, React re-renderiza ese componente y todos sus descendientes. Este es el disparador de re-renderizado más común. - El re-renderizado padre se propaga — Un componente padre que se re-renderiza hace que todos los hijos se re-renderizen, incluso si sus props no han cambiado. Este es el comportamiento predeterminado de React y la fuente principal de renderizados desperdiciados.
- El cambio de contexto provoca un re-renderizado — Cualquier componente que llame a
useContext(SomeContext)se re-renderiza siempre que cambie el valor del contexto, independientemente de si cambió el campo específico que utiliza. - Las funciones en línea crean nuevas referencias —
onClick={() => handleClick(id)}crea una nueva función en cada renderizado. Si el hijo está envuelto enmemo, esta nueva referencia anula la optimización. - Los objetos en línea crean nuevas referencias —
style={{ color: "red" }}odata={{ items }}crea una nueva referencia de objeto en cada renderizado, rompiendo la comparación superficial enmemo. - React DevTools Profiler resalta — Habilita "Resaltar actualizaciones cuando los componentes se renderizen" en la configuración de React DevTools. Los componentes parpadean con un borde de color en cada renderizado. El parpadeo frecuente durante inactividad o escritura indica renderizados desperdiciados.
Variaciones
Patrón de hijos para prevenir re-renderizados:
// BEFORE: SlowChild re-renders when count changes
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
<SlowChild />
</div>
);
}
// AFTER: SlowChild passed as children, won't re-render
function CounterWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}
function Parent() {
return (
<CounterWrapper>
<SlowChild /> {/* Created in Parent scope, not CounterWrapper */}
</CounterWrapper>
);
}Dividiendo componentes para aislar estado:
// BEFORE: Hover state re-renders the entire card
function ProductCard({ product }: { product: Product }) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ExpensiveChart data={product.data} />
<HoverOverlay visible={isHovered} />
</div>
);
}
// AFTER: Hover state isolated in its own component
function ProductCard({ product }: { product: Product }) {
return (
<div>
<ExpensiveChart data={product.data} />
<HoverArea />
</div>
);
}
function HoverArea() {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<HoverOverlay visible={isHovered} />
</div>
);
}Notas de TypeScript
React.memopreserva tipos de props. Para componentes genéricos, lanza después de envolver:memo(MyComponent) as typeof MyComponent.- El array de dependencias de
useCallbackes verificado por TypeScript y la regla ESLintreact-hooks/exhaustive-deps. - Cuando uses el patrón de hijos, escribe los hijos como
React.ReactNodepara máxima flexibilidad.
Trampas
-
Agregar memo en todas partes sin perfilar —
memotiene sobrecarga (comparación superficial en cada renderizado). Para componentes simples que se renderizen rápidamente, el costo de comparación excede el costo de renderizado. Solución: Perfila primero. Solo envuelve componentes que el Profiler muestre que son costosos y se re-renderizen innecesariamente. -
Claves inestables causando remontajes — Usar el índice del array como
keyo generar claves conMath.random()fuerza a React a desmontar y remontar componentes, lo cual es más costoso que un re-renderizado. Solución: Usa IDs únicos y estables de tus datos. -
Olvidar useCallback al pasar manejadores a hijos memoizados —
memoen el hijo es inútil si el padre pasa una nueva referencia de función en cada renderizado. Solución: Envuelve funciones de manejador enuseCallbacko reestructura para que el hijo posea el manejador. -
El contexto causando re-renderizados globales — Un único contexto con estado y dispatch causa que todos los consumidores se re-renderizen en cualquier cambio de estado. Solución: Divide en
StateContextyDispatchContextseparados, o usa Zustand con selectores. -
Expandir props anulando memo —
<Child {...props} />dondepropsse reconstruye en cada renderizado crea nuevas referencias de objeto. Solución: Pasa props individuales o memoiza el objeto props.
Alternativas
| Enfoque | Compensación |
|---|---|
| Colocation de estado | Solución más simple; puede requerir reestructurar el árbol de componentes |
React.memo + useCallback | Control preciso; agrega boilerplate |
| Patrón de hijos | Cero sobrecarga; solo funciona cuando el componente propietario de estado envuelve el contenido |
| React Compiler | Automático; experimental, no aún estable |
| Selectores de Zustand | Reemplaza contexto; añade dependencia |
| Virtualización | Solo renderiza elementos visibles; cambia comportamiento de desplazamiento |
useDeferredValue | Mantiene la entrada responsiva; muestra contenido obsoleto brevemente |
Preguntas Frecuentes
¿Cuál es la causa más común de re-renderizados innecesarios en React?
Un componente padre que se re-renderiza causa que todos sus hijos se re-renderizen, incluso si sus props no han cambiado. Este es el comportamiento predeterminado de React y la fuente principal de renderizados desperdiciados.
¿Cómo la colocation de estado previene re-renderizados?
Al mover el estado al componente que realmente lo usa, los componentes padre ya no se re-renderizen cuando ese estado cambia. Por ejemplo, mover el estado de entrada de búsqueda a un componente SearchBar evita que el hermano ProductList se re-renderice en cada pulsación de tecla.
¿Por qué envolver un hijo en React.memo no ayuda si el padre pasa una función en línea?
Las funciones en línea como onClick={() => handleClick(id)} crean una nueva referencia de función en cada renderizado. memo usa comparación superficial, así que ve un nuevo prop y re-renderiza el hijo de todas formas.
Solución: Envuelve el manejador en useCallback o reestructura para que el hijo posea el manejador.
¿Qué es el patrón de hijos y cuándo deberías usarlo?
function CounterWrapper({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
{children}
</div>
);
}Los hijos se crean en el ámbito padre, no dentro de CounterWrapper, por lo que no se re-renderizen cuando count cambia. Úsalo cuando un componente propietario de estado envuelve contenido que no necesita controlar.
¿Cómo identificas re-renderizados innecesarios usando React DevTools?
- Habilita "Resaltar actualizaciones cuando los componentes se renderizen" en la configuración de React DevTools
- Los componentes parpadean con un borde de color en cada renderizado
- El parpadeo frecuente durante inactividad o escritura indica renderizados desperdiciados
- Usa la pestaña Profiler para grabar interacciones e inspeccionar duraciones de renderizado
Trampa: ¿Puede usar el índice del array como una clave causar problemas de rendimiento?
Sí. Usar el índice del array como key o generar claves con Math.random() fuerza a React a desmontar y remontar componentes en lugar de actualizarlos. El remontaje es más costoso que un re-renderizado. Siempre usa IDs únicos y estables de tus datos.
¿Por qué un único contexto con estado y dispatch causa re-renderizados globales?
Cualquier componente que llame a useContext(SomeContext) se re-renderiza siempre que cambie el valor del contexto, independientemente de qué campo cambió. Si el estado y dispatch comparten un contexto, cada cambio de estado re-renderiza todos los consumidores, incluyendo aquellos que solo hacen dispatch.
Solución: Divide en StateContext y DispatchContext, o usa Zustand con selectores.
¿Expandir props anula React.memo?
Sí. <Child {...props} /> donde props se reconstruye en cada renderizado crea nuevas referencias de objeto cada vez, haciendo que memo siempre vea props "cambiados". Pasa props individuales o memoiza el objeto props en su lugar.
¿Cómo escribes el prop de hijos en el patrón de hijos en TypeScript?
function Wrapper({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}Usa React.ReactNode para máxima flexibilidad. Acepta elementos, strings, números, fragmentos, portales y null.
¿Cómo escribes un componente genérico envuelto en React.memo en TypeScript?
React.memo preserva tipos de props, pero para componentes genéricos, necesitas una aserción de tipo después de envolver:
const MemoizedList = memo(MyList) as typeof MyList;Sin la aserción, se pierde el parámetro de tipo genérico.
¿Cuándo NO deberías usar React.memo?
- Cuando el componente se renderiza rápidamente (menos de 1ms) y el costo de comparación superficial excede el costo de renderizado
- Cuando los props cambian en casi cada renderizado de todas formas
- Cuando uses React Compiler, que maneja la memoización automáticamente
Siempre perfila primero para confirmar que el componente es tanto costoso como se re-renderiza innecesariamente.
¿Cuál es la diferencia entre useDeferredValue y colocation de estado para manejar listas lentas?
- Colocation de estado previene que el componente lento se re-renderice en absoluto
useDeferredValueaún re-renderiza el componente lento pero con menor prioridad, manteniendo la entrada responsiva mientras muestra contenido obsoleto brevemente
La colocation de estado es más simple cuando es posible; useDeferredValue es útil cuando el componente lento genuinamente depende del estado cambiante.
Relacionado
- Memoization — Inmersión profunda en useMemo, useCallback, y React.memo
- React Compiler — Memoización automática sin hooks manuales
- Profiling — Cómo usar React DevTools Profiler para encontrar cuellos de botella de re-renderizado
- State Management Performance — Selectores de Zustand y división de contexto