Módulo 6: Effects And Side Effects
Puede que no necesites un efecto
Descripción
Hasta aquí el módulo te enseñó a usar useEffect bien. Esta lección enseña lo contrario, y es igual de importante: cuándo no usarlo. La mayoría de los useEffect mal escritos comparten un mismo defecto —son efectos que no deberían existir, porque lo que hacen no es sincronizar con un sistema externo sino transformar datos (que se deriva en el render, módulo 5) o responder a una acción (que va en un event handler, módulo 4)—. El síntoma más común es el efecto que sincroniza estado con estado: guardas un dato calculado en su propio estado y pones un efecto para "mantenerlo al día" cuando cambian sus fuentes. Eso provoca renders de más, bugs de desincronización, y complejidad gratis. Esta lección mide el costo de ese antipatrón y muestra el arreglo: derivar en el render.
Conexión con el módulo. Es el reverso exacto de la lección 2. Allá definiste que un efecto sincroniza con lo externo, no transforma; aquí cobras esa definición: si un useEffect no toca ningún sistema externo, es candidato a no existir. Cierra el círculo con el módulo 5 (deriva, no guardes): la lista filtrada, el total, el nombre formateado no son efectos —son derivados—. Y con el módulo 4: lo que pasa "cuando el usuario hace clic" va en el handler, no en un efecto. El mejor efecto es, muchas veces, el que borras.
Una analogía: el marcador que se recalcula solo
Imagina un pizarrón donde llevas la cuenta de un partido. Tienes anotados los puntos del equipo local y los del visitante. Y quieres mostrar la diferencia (local menos visitante). Hay dos formas de hacerlo:
- La mala: anotas la diferencia en un tercer casillero del pizarrón, con su propio número escrito. Cada vez que cambia un marcador, tienes que acordarte de ir a borrar y reescribir la diferencia. Si un día se te olvida, el pizarrón queda mintiendo: dice diferencia
+3cuando en realidad es+5. Tres números guardados, uno de ellos siempre en riesgo de quedar desactualizado. - La buena: no anotas la diferencia en ningún lado. Cuando alguien pregunta, la calculas al momento: local menos visitante. Nunca se desactualiza, porque no está guardada en ninguna parte: se deriva de los dos números que sí llevas.
El efecto que sincroniza estado con estado es el tercer casillero: guardas un dato que es función de otros, y pones un efecto para "acordarte de reescribirlo" cuando cambian. Derivar en el render es calcular al momento: no guardas la diferencia, la computas cuando la necesitas. La segunda nunca se desincroniza, porque no hay nada que sincronizar. Guarda la imagen: si un dato se puede calcular a partir de otros que ya tienes, no lo guardes ni lo sincronices —derívalo—.
Ejemplo trabajado: el efecto innecesario, medido
Tomemos el caso más común en Mercado: mostrar la lista de productos filtrada por la búsqueda. El dato visibleProducts es una función de products y query: es un derivado. Veamos las dos formas —el antipatrón con efecto y el arreglo con derivación— y midamos cuántos renders cuesta cada una.
Primero, el antipatrón (lo que no debes hacer): guardar visibleProducts en su propio estado y un efecto que lo re-calcula:
// ANTIPATRON: estado redundante + efecto que lo sincroniza
function Storefront({ products }) {
const [query, setQuery] = useState('');
const [visibleProducts, setVisibleProducts] = useState(products); // estado de MAS
useEffect(() => {
setVisibleProducts(filterByQuery(products, query)); // sincroniza estado con estado
}, [products, query]);
return <ProductList products={visibleProducts} />;
}
Y el arreglo (lo correcto): derivar visibleProducts en el render, sin estado ni efecto:
// CORRECTO: derivar en el render (modulo 5)
function Storefront({ products }) {
const [query, setQuery] = useState('');
const visibleProducts = filterByQuery(products, query); // se computa aqui, cada render
return <ProductList products={visibleProducts} />;
}
Ejecutemos las dos versiones sobre la misma secuencia —montar, y luego buscar 'key'— y contemos los renders:
const PRODUCTS = [
{ id: 'p1', name: 'Wireless Mouse' },
{ id: 'p2', name: 'Mechanical Keyboard' },
{ id: 'p3', name: 'USB-C Hub' },
];
function filterByQuery(products, query) {
const q = query.toLowerCase();
return products.filter((p) => p.name.toLowerCase().includes(q));
}
// makeRuntime: un mini-React autocontenido. Provee useState + useEffect, corre
// render + commit + effect, y CUENTA renders. Expone .start() (montaje),
// .renders() (total de renders) y .cells() (las ranuras de estado).
function makeRuntime(Component) {
let stateCells = [];
let effectHooks = [];
let stateCursor = 0;
let hookCursor = 0;
let pending = [];
let renderCount = 0;
function useState(initial) {
const i = stateCursor++;
if (!(i in stateCells)) stateCells[i] = initial;
const setState = (next) => {
const value = typeof next === 'function' ? next(stateCells[i]) : next;
if (Object.is(value, stateCells[i])) return; // sin cambio real: no re-render
stateCells[i] = value;
renderAndCommit();
};
return [stateCells[i], setState];
}
function sameDeps(a, b) {
if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false;
for (let k = 0; k < a.length; k++) if (!Object.is(a[k], b[k])) return false;
return true;
}
function useEffect(setup, deps) {
const i = hookCursor++;
const prev = effectHooks[i];
let shouldRun;
if (prev === undefined) shouldRun = true; // primer montaje: siempre corre
else if (deps === undefined) shouldRun = true; // sin array: cada render
else shouldRun = !sameDeps(prev.deps, deps); // con array: solo si cambio una dep
effectHooks[i] = { setup, deps };
if (shouldRun) pending.push(i);
}
function commit() {
const toRun = pending; // snapshot: el setup puede re-renderizar
pending = [];
for (const i of toRun) effectHooks[i].setup();
}
function renderAndCommit() {
renderCount++;
stateCursor = 0;
hookCursor = 0;
Component({ useState, useEffect });
commit();
}
return {
start: renderAndCommit,
renders: () => renderCount,
cells: () => stateCells,
};
}
// ── (MAL) guardar la lista filtrada en estado + effect que la sincroniza ──
let out1;
const bad = makeRuntime(({ useState, useEffect }) => {
const [query, setQuery] = useState('');
const [products] = useState(PRODUCTS);
const [visibleProducts, setVisibleProducts] = useState(PRODUCTS); // estado REDUNDANTE
useEffect(() => {
setVisibleProducts(filterByQuery(products, query)); // sincroniza estado con estado
}, [products, query]);
out1 = { setQuery, visible: visibleProducts };
});
console.log('=== (MAL) effect que sincroniza `visibleProducts` con products+query ===');
bad.start(); // montaje
out1.setQuery('key'); // el usuario busca 'key'
console.log(` renders totales: ${bad.renders()}`);
console.log(` visibleProducts final: ${bad.cells()[2].map((p) => p.name).join(', ')}\n`);
// ── (BIEN) derivar la lista filtrada en el render ──
let out2;
const good = makeRuntime(({ useState }) => {
const [query, setQuery] = useState('');
const [products] = useState(PRODUCTS);
const visibleProducts = filterByQuery(products, query); // DERIVADO en el render
out2 = { setQuery, visible: visibleProducts };
});
console.log('=== (BIEN) `visibleProducts` derivado en el render (sin effect, sin estado extra) ===');
good.start(); // montaje
out2.setQuery('key'); // el usuario busca 'key'
console.log(` renders totales: ${good.renders()}`);
console.log(` visibleProducts final: ${out2.visible.map((p) => p.name).join(', ')}`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== (MAL) effect que sincroniza `visibleProducts` con products+query ===
renders totales: 4
visibleProducts final: Mechanical Keyboard
=== (BIEN) `visibleProducts` derivado en el render (sin effect, sin estado extra) ===
renders totales: 2
visibleProducts final: Mechanical Keyboard
Mismo resultado final (Mechanical Keyboard, el único producto que contiene "key"), pero el doble de renders en la versión con efecto: 4 contra 2. Veamos de dónde salen esos renders de más.
La versión con efecto (4 renders). Al montar: render #1 con visibleProducts en su valor inicial (los 3 productos, aún sin filtrar). Después del commit corre el efecto, que llama a setVisibleProducts(...) → render #2 (ahora sí, filtrado). Ya en el montaje hubo dos renders, y peor: en el #1 la UI mostró datos stale (sin filtrar) por un instante, hasta que el efecto la corrigió en el #2. Luego setQuery('key'): render #3 (query cambió, pero visibleProducts todavía tiene el valor viejo). El efecto corre de nuevo → setVisibleProducts → render #4 (filtrado por "key"). Cada cambio dispara dos renders —uno con el dato viejo, otro con el corregido— y hay un parpadeo de datos desactualizados en medio.
La versión derivada (2 renders). Al montar: render #1, y visibleProducts se computa ahí mismo —ya filtrado, correcto desde el primer instante, sin efecto—. setQuery('key'): render #2, y visibleProducts se recomputa con el query nuevo, correcto de inmediato. Un render por cambio, sin datos stale, sin efecto, sin estado redundante, sin el filterByQuery duplicado en dos lugares.
La diferencia no es solo de rendimiento (que también: la mitad de renders). Es de correctitud: la versión con efecto muestra, en cada cambio, un frame con el dato viejo antes de corregirse, y mantiene dos piezas de estado que pueden desincronizarse si algo sale mal. La versión derivada no puede desincronizarse, porque no guarda el dato: lo calcula. Es el marcador que se recalcula solo.
Profundización: las tres preguntas de "¿necesito un efecto?"
La documentación de React tiene una página entera —"You Might Not Need an Effect"— dedicada a esto, porque es el error más frecuente. Aquí está el criterio destilado en tres preguntas. Ante cualquier useEffect, pregúntate:
1. ¿Estoy transformando datos para el render? → No necesitas efecto. Deriva.
Si el efecto solo lee estado/props, los transforma y guarda el resultado en otro estado, eso es un derivado. Ejemplos: la lista filtrada, el total del carrito, el nombre completo (firstName + ' ' + lastName), si el carrito está vacío (items.length === 0). Todos se computan en el render, sin estado extra ni efecto. Guardarlos y sincronizarlos con un efecto es el antipatrón que mediste.
// MAL: efecto para el total
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce((s, it) => s + it.priceCents, 0)); }, [items]);
// BIEN: derivar
const total = items.reduce((s, it) => s + it.priceCents, 0);
2. ¿Esto debe pasar porque el usuario hizo algo? → No necesitas efecto. Va en el event handler. Si la lógica responde a una acción del usuario —un clic, un envío—, va en el handler de ese evento (módulo 4), no en un efecto. Enviar el pedido al hacer clic en "Checkout", mostrar una notificación al agregar al carrito, navegar al confirmar: todo eso es "porque el usuario hizo X", no "porque el componente está en pantalla". Un efecto es para lo segundo.
// MAL: efecto que reacciona a un cambio de estado que en realidad vino de un clic
useEffect(() => { if (submitted) showToast('Order placed!'); }, [submitted]);
// BIEN: hacerlo en el handler, donde ocurrio la accion
function handleCheckout() {
submitOrder();
showToast('Order placed!');
}
3. ¿Estoy sincronizando con un sistema externo (red, DOM, conexión, timer)? → Sí necesitas efecto.
Solo aquí useEffect es la herramienta correcta: conectar a un chat, poner document.title, escuchar el resize del window, arrancar un timer, o cargar datos al montar (lección 5). Es el único caso de los tres que no se puede resolver derivando ni en un handler, porque involucra algo que vive fuera de React.
El caso especial: no copies props en estado. Un antipatrón hermano es useState(props.x) + un efecto que hace setX(props.x) cuando la prop cambia, para "mantener el estado sincronizado con la prop". No lo hagas: si solo necesitas mostrar la prop, úsala directo (no la copies en estado); si necesitas un valor que arranca desde la prop pero luego diverge, hay patrones específicos (la doc los cubre), pero casi nunca es un efecto. Copiar props en estado y sincronizarlas con un efecto es otra forma del tercer casillero.
Cuándo la derivación es "cara" (adelanto, no de este módulo). A veces el cálculo derivado es costoso (filtrar y ordenar miles de elementos en cada render). La respuesta no es guardarlo en estado con un efecto —sigue siendo un derivado—; es memorizar el cálculo con useMemo, que lo recomputa solo cuando sus entradas cambian. Eso es optimización de performance y pertenece a la guía de performance; el punto de este módulo es que derivar (con o sin useMemo) siempre le gana a sincronizar estado con estado vía efecto.
Errores comunes
Sincronizar estado con estado vía efecto. Qué pasa: se guarda un dato calculado (filtered, total, fullName) en su propio estado y un useEffect lo re-calcula cuando cambian sus fuentes. Por qué pasa: se piensa "cuando A cambie, actualiza B" como una sincronización. Cómo detectarlo: el useEffect no toca nada externo —solo lee estado, lo transforma y llama a setState—; hay dos estados donde uno es siempre función del otro; y ves renders de más (lo mediste: 4 vs 2) o un parpadeo de datos viejos. Cómo corregirlo: borra el estado redundante y el efecto; deriva el dato en el render.
Poner en un efecto lo que dispara una acción del usuario. Qué pasa: se usa un efecto que "reacciona" a un cambio de estado que en realidad fue causado por un clic (mostrar un toast, enviar un formulario). Por qué pasa: se separa la causa (el clic) del efecto (lo que debe pasar). Cómo detectarlo: el efecto depende de un flag booleano (submitted, clicked) que solo cambia por una acción; la lógica corre "un render tarde". Cómo corregirlo: mueve la lógica al event handler donde ocurrió la acción (módulo 4). Es más directo, corre en el momento exacto, y no necesita el flag intermedio.
Copiar props en estado y sincronizarlas con un efecto. Qué pasa: const [x, setX] = useState(props.x) + useEffect(() => setX(props.x), [props.x]). Por qué pasa: se quiere que el estado "siga" a la prop. Cómo detectarlo: tienes estado que es un espejo de una prop, sincronizado por un efecto. Cómo corregirlo: si solo lo muestras, usa props.x directo (sin estado). Si de verdad necesitas estado que arranca de la prop y diverge, revisa el patrón específico en la doc —pero no es un efecto de sincronización—.
Ejercicios
Ejercicio 1 — ¿Sobra el efecto? Para cada uno, di si el useEffect sobra (y qué poner en su lugar) o si es correcto: (a) un efecto que calcula fullName de firstName y lastName y lo guarda en estado; (b) un efecto que conecta a un servidor de chat con [roomId]; (c) un efecto que muestra una notificación cuando orderPlaced pasa a true; (d) un efecto que pone document.title con [productName].
Ver solución
- (a) Sobra.
fullNamees un derivado defirstNameylastName:const fullName = firstName + ' ' + lastName;en el render. Fuera el estado y el efecto. - (b) Correcto. Conectar a un chat es sincronizar con un sistema externo;
useEffect(..., [roomId])con cleanup es la herramienta correcta. - (c) Sobra. Mostrar la notificación es consecuencia de una acción del usuario (colocar el pedido). Va en el event handler del "Place order", no en un efecto que reacciona al flag
orderPlaced. - (d) Correcto.
document.titlees un sistema externo (el navegador); sincronizarlo conuseEffect(..., [productName])es correcto.
La regla de tres preguntas: (a) transforma → deriva; (c) responde a una acción → handler; (b) y (d) sincronizan con algo externo → efecto.
Ejercicio 2 — Explica los renders de más. En el ejemplo trabajado, la versión con efecto hizo 4 renders y la derivada 2. Explica por qué el montaje solo ya cuesta 2 renders en la versión con efecto, y qué problema de correctitud (no solo de rendimiento) tiene ese primer render.
Ver solución
En la versión con efecto, el montaje cuesta 2 renders porque:
- Render #1: el componente se pinta con
visibleProductsen su valor inicial (los 3 productos sin filtrar), porque el efecto todavía no corrió (corre después del commit, lección 2). La UI muestra datos stale —sin filtrar— por un instante. - Después del commit, el efecto corre y llama a
setVisibleProducts(filtrado)→ Render #2: ahora sí, con la lista correcta.
El problema de correctitud —no solo de rendimiento— está en el render #1: la UI muestra, aunque sea por un frame, un dato desactualizado (la lista sin filtrar). En una app real eso es un parpadeo visible. La versión derivada no lo tiene: en su render #1, visibleProducts se computa en ese mismo render, así que la UI es correcta desde el primer frame. Derivar no solo ahorra renders; elimina el instante de datos viejos.
Ejercicio 3 — Refactoriza. Este componente usa un efecto para todo. Reescríbelo aplicando las tres preguntas —deriva lo que se transforma, mueve al handler lo que responde a una acción, y deja en efecto solo lo externo:
function Cart({ items }) {
const [total, setTotal] = useState(0);
const [isEmpty, setIsEmpty] = useState(true);
useEffect(() => { setTotal(items.reduce((s, it) => s + it.priceCents, 0)); }, [items]);
useEffect(() => { setIsEmpty(items.length === 0); }, [items]);
useEffect(() => { document.title = `Cart (${items.length})`; }, [items.length]);
function handleCheckout() { /* ... */ }
return (/* ... */);
}
Ver solución
total e isEmpty son derivados de items (transforman datos): fuera sus estados y sus efectos, se computan en el render. El document.title sí es externo: se queda en un efecto.
function Cart({ items }) {
const total = items.reduce((s, it) => s + it.priceCents, 0); // derivado
const isEmpty = items.length === 0; // derivado
useEffect(() => {
document.title = `Cart (${items.length})`; // sincroniza (externo): se queda
}, [items.length]);
function handleCheckout() { /* ... */ }
return (/* ... */);
}
Pasamos de tres efectos y dos estados redundantes a un efecto (el único que toca algo externo, el document) y cero estados de más. total e isEmpty no pueden desincronizarse, porque se calculan en cada render. Menos código, menos renders, y sin el instante de datos viejos.
Resumen y siguiente paso
En esta lección aprendiste la otra mitad del criterio: cuándo no usar un efecto. El antipatrón central —sincronizar estado con estado— lo mediste: un efecto que guarda la lista filtrada en su propio estado costó 4 renders (con un frame de datos viejos en cada cambio); derivarla en el render dio el mismo resultado en 2 renders, correcta desde el primer frame. Y destilaste el criterio en tres preguntas: si transformas datos → deriva en el render (módulo 5); si algo pasa porque el usuario hizo algo → va en el event handler (módulo 4); solo si sincronizas con un sistema externo (red, DOM, conexión, timer) → es un efecto. Más el caso hermano: no copies props en estado para sincronizarlas con un efecto. La imagen que resume todo: no anotes la diferencia en un tercer casillero que te toca reescribir a mano —calcúlala al momento, y nunca se desincroniza—.
Antes de avanzar deberías poder: reconocer un efecto que sincroniza estado con estado y refactorizarlo a un derivado; aplicar las tres preguntas a cualquier useEffect; explicar por qué la versión derivada evita renders de más y datos stale; y decidir qué va en efecto, qué en handler y qué en el render.
La lección 8 cierra el módulo con el mini-proyecto: le pondrás al storefront de Mercado la carga de productos con un efecto (lección 5) al montar App, y una búsqueda en el servidor race-safe con el cleanup de la lección 6 —aplicando también el criterio de esta lección para no meter efectos donde va una derivación—. Verás la sesión completa ejecutada: montaje con carga, dos búsquedas rápidas que llegan fuera de orden, y el cleanup descartando la obsoleta, con un estado final coherente.
Recursos
- React, "You Might Not Need an Effect" — react.dev/learn/you-might-not-need-an-effect. La página oficial dedicada a esto: cómo reconocer efectos innecesarios (datos derivados, respuestas a eventos, props en estado) y qué poner en su lugar. La referencia central de esta lección. En inglés.
- React, "Keeping Components Pure" — react.dev/learn/keeping-components-pure. Por qué derivar en el render es seguro y correcto; el fundamento de "calcula, no guardes". En inglés.
- React, "Reacting to Input with State" — react.dev/learn/reacting-to-input-with-state. Cómo pensar la UI como una función del estado, que hace innecesarios muchos efectos de "sincronización". En inglés.
- React, "useMemo" (referencia) — react.dev/reference/react/useMemo. Para cuando un cálculo derivado es caro: memorizarlo en vez de guardarlo en estado con un efecto. La optimización correcta (guía de performance). En inglés.