Módulo 6: Effects And Side Effects

El array de dependencias

Descripción

Un efecto ya sabe qué hacer (sincronizar con un sistema externo, lección 2). Falta la otra mitad: saber cuándo volver a hacerlo. Un componente re-renderiza muchas veces —cada tecla, cada clic, cada cambio de estado—, y casi nunca quieres que el efecto vuelva a correr en cada uno de esos renders. A veces quieres que corra una sola vez (conectarte al montar y ya); a veces, cada vez que un dato específico cambia (re-buscar cuando cambia el query). Eso lo controlas con el array de dependencias: el segundo argumento de useEffect. Esta lección mide las tres formas —[], [dep] y sin array— en una secuencia de renders, para que veas exactamente cuándo corre cada una y cuándo se salta.

Conexión con el módulo. La lección 2 estableció que un efecto sincroniza con lo externo; esta decide su ritmo. El array de dependencias es la pieza que hace que un efecto sea eficiente y correcto: un efecto sin las dependencias bien puestas o corre de más (cada render, gastando red) o corre de menos (con datos viejos, un bug sutil). Y prepara la lección 4: cada vez que un efecto vuelve a correr por un cambio de dependencia, primero corre su cleanup. Deps y cleanup son las dos caras del "cuándo".

Una analogía: regar la planta cuando cambia la estación

Tienes una planta en el balcón, y la riegas según la estación: mucho en verano, poco en invierno. Hay tres maneras de pensar el "cuándo regar", y las tres corresponden a una forma del array:

  • Regar en cada instante, sin parar, mirando el reloj cada segundo: absurdo, agotador, y ahogas la planta. Eso es un efecto sin array: corre en cada render, siempre.
  • Regar una sola vez, el día que la plantaste, y nunca más: la planta se muere. Eso es []: corre una vez, al montar, y jamás vuelve.
  • Regar cuando cambia la estación: ni de más ni de menos, reaccionas al cambio que importa. Eso es [season]: el efecto corre al montar y cada vez que season cambia de valor.

El array de dependencias es tu forma de decirle a React de qué depende el efecto: "vuelve a correr este efecto solo cuando cambie esto". Poner [season] es declarar "este riego depende de la estación". Y aquí está la sutileza que la lección clava: el array no es una lista de deseos, es una declaración honesta de qué valores usa el efecto por dentro. Si tu efecto usa season pero pones [], estás mintiendo —le dices a React "no dependo de nada" mientras por dentro sí dependes de la estación—, y regarás con la estación vieja. Declara lo que usas, ni más ni menos.

Ejemplo trabajado: [] vs [query] vs sin array, medidos

Vamos a poner los tres tipos de efecto en un mismo componente y a correr una secuencia de renders para ver cuál se dispara en cada uno. El componente tal como lo escribirás en React:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [clicks, setClicks] = useState(0);

  useEffect(() => {
    console.log('effect []: corre solo al montar');
  }, []); // [] -> una vez, al montar

  useEffect(() => {
    console.log('effect [query]: corre cuando query cambia', query);
  }, [query]); // [query] -> al montar y cuando query cambia

  useEffect(() => {
    console.log('effect sin array: corre en cada render');
  }); // sin array -> cada render

  return (/* la UI */);
}

Para que la prueba sea honesta hay que recordar algo del módulo 3: React no re-renderiza si el estado no cambia de verdad. Si llamas a setQuery("mo") cuando query ya es "mo", no hay re-render. Por eso, para forzar re-renders sin tocar query, usamos dos piezas de estado: query y un contador clicks. Cambiar clicks re-renderiza el componente sin tocar query —y ahí se ve con claridad que el efecto [query] no corre (porque query no cambió), mientras el efecto sin array —. Modelamos el runtime con la comparación Object.is que React usa para decidir si una dependencia cambió:

let effectHooks = []; // una ranura por cada useEffect, en orden de llamada
let hookCursor = 0;
let pending = [];
let stateCells = [];
let stateCursor = 0;
let rerender = () => {};

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; // React NO re-renderiza si el valor es el mismo
    stateCells[i] = value;
    rerender();
  };
  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 alguna dep
  effectHooks[i] = { setup, deps };
  if (shouldRun) pending.push(i);
}

function commit() {
  for (const i of pending) effectHooks[i].setup();
  pending = [];
}

let ui;
function SearchPage() {
  const [query, setQuery] = useState('');
  const [clicks, setClicks] = useState(0);
  useEffect(() => console.log('    effect []        -> corre  (solo al montar)'), []);
  useEffect(() => console.log(`    effect [query]    -> corre  (query = ${JSON.stringify(query)})`), [query]);
  useEffect(() => console.log(`    effect (sin array) -> corre  (cada render; clicks = ${clicks})`));
  return { setQuery, setClicks };
}

function renderAndCommit(label) {
  console.log(label);
  hookCursor = 0;
  stateCursor = 0;
  ui = SearchPage();
  commit();
}
rerender = () => renderAndCommit('  render (por cambio de estado):');

console.log('=== El array de dependencias: [] vs [query] vs sin array ===\n');
renderAndCommit('  render #1 (montaje):');
ui.setQuery('mo');   // query cambia '' -> 'mo'  => corre [query] (y sin-array)
ui.setClicks(1);     // clicks cambia, query NO  => NO corre [query]; SI corre sin-array
ui.setQuery('mouse'); // query cambia 'mo' -> 'mouse' => corre [query] (y sin-array)

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== El array de dependencias: [] vs [query] vs sin array ===

  render #1 (montaje):
    effect []        -> corre  (solo al montar)
    effect [query]    -> corre  (query = "")
    effect (sin array) -> corre  (cada render; clicks = 0)
  render (por cambio de estado):
    effect [query]    -> corre  (query = "mo")
    effect (sin array) -> corre  (cada render; clicks = 0)
  render (por cambio de estado):
    effect (sin array) -> corre  (cada render; clicks = 1)
  render (por cambio de estado):
    effect [query]    -> corre  (query = "mouse")
    effect (sin array) -> corre  (cada render; clicks = 1)

Lee la salida render por render, porque cada uno confirma una regla.

Render #1 (montaje). Corren los tres efectos. En el primer montaje todos corren, sin excepción —igual que riegas la planta el día que la plantas—. El [] dice "solo al montar", el [query] arranca con query = "", y el sin-array corre por ser el primer render.

setQuery('mo') (segundo render). query cambió de "" a "mo". Corren el [query] (su dependencia cambió → query = "mo") y el sin-array (siempre). El [] no corre: no vuelve nunca después del montaje. Regla de [] confirmada.

setClicks(1) (tercer render). Aquí está la línea reveladora. clicks cambió, así que el componente re-renderiza —por eso el sin-array corre (clicks = 1)—. Pero query no cambió (sigue "mo"), así que el [query] no corre. Esto es lo que hace útil al array de dependencias: aunque el componente se re-renderice por otra razón, el efecto de la búsqueda no se dispara si la búsqueda no cambió. No re-buscas en el servidor solo porque el usuario hizo clic en algo no relacionado. Regla de [dep] confirmada.

setQuery('mouse') (cuarto render). query cambió de "mo" a "mouse". El [query] vuelve a correr (query = "mouse"), y el sin-array también (clicks sigue en 1, pero él corre siempre).

La moraleja, en una frase: el array declara de qué depende el efecto, y React lo vuelve a correr solo cuando alguna de esas dependencias cambia (comparando con Object.is). [] no depende de nada → una vez. [query] depende de query → cuando query cambia. Sin array no declara nada → React no puede saltarse ninguno → cada render.

Profundización: cómo compara React, y el peligro de mentir

La comparación es por identidad (Object.is). En cada render, React toma el array de dependencias nuevo y lo compara, posición por posición, con el del render anterior, usando Object.is (esencialmente ===). Si todas son iguales, se salta el efecto. Si alguna difiere, lo vuelve a correr (después de su cleanup, lección 4). Para valores primitivos —strings, números, booleanos— esto es intuitivo: "mo" es igual a "mo", 1 es igual a 1. La sutileza aparece con objetos y arrays: dos objetos con el mismo contenido pero creados por separado no son Object.is-iguales (son referencias distintas). Por eso un [someObject] que se re-crea en cada render dispara el efecto en cada render aunque "se vea igual" —un tema que las guías de performance retoman; por ahora, prefiere depender de primitivos (product.id, query) en el array—.

Las tres formas, y cuándo cada una.

  • [] (array vacío): "no dependo de nada que cambie; corre una vez al montar". Para conexiones y suscripciones que se abren al aparecer el componente y se cierran al desaparecer, o una carga inicial de datos. La mayoría de los efectos "al montar" son esto.
  • [a, b] (con dependencias): "corre al montar y cada vez que a o b cambien". Para efectos que deben re-sincronizar cuando un dato cambia: re-buscar cuando cambia query, re-suscribirse cuando cambia roomId.
  • Sin array (omitido): "corre en cada render". Rara vez lo quieres a propósito; casi siempre es un olvido. Si de verdad necesitas correr en cada render, probablemente el trabajo no debería estar en un efecto.

El peligro de mentir en el array. El array debe listar todos los valores reactivos (props, estado, y cosas derivadas de ellos) que el efecto usa por dentro. Si tu efecto lee query pero pones [], le estás mintiendo a React: "no dependo de query". React te cree, no vuelve a correr el efecto cuando query cambia, y tu efecto se queda usando el valor viejo de query para siempre —un bug silencioso y difícil de rastrear, porque "a veces funciona" (la primera vez sí usa el valor bueno)—. La regla no es "pon pocas dependencias para que corra menos"; es "declara exactamente lo que usas". Si el efecto corre demasiado por culpa de una dependencia, la solución es rediseñar el efecto (o sacar el trabajo de ahí), no borrar la dependencia y mentir. Hay una herramienta —la regla de ESLint react-hooks/exhaustive-deps— que revisa esto por ti y te avisa cuando el array no coincide con lo que el efecto usa; actívala.

Por qué no [] "por si acaso". Es tentador poner [] en todo para que el efecto corra "una sola vez y ya". Funciona hasta que el efecto usa una prop o un estado que cambia: entonces se queda pegado al primer valor. [] significa literalmente "este efecto no depende de ningún valor reactivo". Úsalo solo cuando sea verdad.

Errores comunes

Mentir en el array (dependencia faltante). Qué pasa: el efecto usa query (o product.id, o un handler) pero el array no lo incluye; el efecto se queda con el valor del primer render. Por qué pasa: se copia un [] "para que corra una vez" sin notar que el efecto sí depende de algo. Cómo detectarlo: el efecto "no reacciona" a un cambio que debería seguir (buscas "mouse" pero sigue mostrando resultados de ""); la regla exhaustive-deps marca la línea. Cómo corregirlo: agrega al array todo lo reactivo que el efecto lee. Declara lo que usas.

Omitir el array sin querer (efecto en cada render). Qué pasa: se olvida el segundo argumento de useEffect, y el efecto corre en cada render —si hace fetch, bombardea el servidor; si arranca un timer, arranca uno nuevo cada vez—. Por qué pasa: un descuido; useEffect(fn) es sintácticamente válido. Cómo detectarlo: red saturada, timers duplicados, o un console.log del efecto que se imprime sin parar. Cómo corregirlo: agrega el array con las dependencias correctas ([] si de verdad no depende de nada, [dep] si depende).

Poner un objeto o array recién creado como dependencia. Qué pasa: useEffect(..., [{ id: 1 }]) o [props.config] donde config se re-crea cada render; el efecto corre en cada render aunque "se vea igual", porque la referencia es nueva cada vez. Por qué pasa: se piensa la comparación como "por contenido", pero es por identidad (Object.is). Cómo detectarlo: el efecto se dispara más de lo esperado, sin que cambie nada visible. Cómo corregirlo: depende de primitivos estables (props.config.id en vez de props.config), o memoriza el objeto (herramienta de guías posteriores). Por ahora: en el array, prefiere strings y números.

Ejercicios

Ejercicio 1 — Elige el array. Para cada efecto, di qué array de dependencias corresponde ([], [algo], o sin array) y por qué: (a) conectarse a un servidor de chat de la sala roomId mientras el componente vive; (b) poner document.title con el product.name; (c) mandar un ping al servidor en cada render (caso hipotético, para ilustrar); (d) cargar la lista de productos una vez, al abrir la app.

Ver solución
  • (a) [roomId]. El efecto usa roomId (se conecta a esa sala) y debe re-conectarse cuando el usuario cambia de sala. Declara lo que usa: roomId.
  • (b) [product.name]. El efecto usa product.name y debe re-sincronizar el título cuando el nombre cambia. (Si dependiera de todo el objeto product, mejor [product.name] que [product], para depender del primitivo.)
  • (c) sin array. "En cada render" es literalmente omitir el array. (En la práctica casi nunca lo quieres; aquí es solo el ejemplo de la tercera forma.)
  • (d) []. La carga inicial no depende de ningún valor reactivo: corre una vez, al montar. Array vacío.

La regla mecánica: mira qué valores reactivos usa el efecto por dentro y ponlos todos en el array. Ni más (dispararías de más) ni menos (usarías valores viejos).

Ejercicio 2 — Predice la salida. Sin correr nada, predice qué efectos corren en cada render si la secuencia del ejemplo trabajado fuera:

renderAndCommit('montaje');
ui.setClicks(1);
ui.setClicks(1);   // mismo valor
ui.setQuery('a');
Ver solución
montaje:
  effect []         -> corre
  effect [query]     -> corre (query = "")
  effect (sin array) -> corre (clicks = 0)
setClicks(1):
  effect (sin array) -> corre (clicks = 1)
setQuery('a'):
  effect [query]     -> corre (query = "a")
  effect (sin array) -> corre (clicks = 1)

Paso a paso:

  • Montaje: corren los tres (primer render).
  • setClicks(1): clicks cambia de 0 a 1 → re-render. query no cambió → el [query] no corre. El sin-array sí (clicks = 1). El [] nunca vuelve.
  • setClicks(1) otra vez: el valor es el mismo (1). React no re-renderiza (bail-out del módulo 3): no corre ningún efecto, no hay render. Por eso esta línea no produce salida.
  • setQuery('a'): query cambia de "" a "a" → corre el [query] (query = "a") y el sin-array. El [] no vuelve.

La trampa está en el segundo setClicks(1): sin cambio de valor, no hay render ni efectos.

Ejercicio 3 — Diagnostica el bug. Un compañero se queja: "mi búsqueda en el servidor no se actualiza; escribo cosas nuevas y siempre muestra los resultados de la primera búsqueda". Su código:

useEffect(() => {
  fetchResults(query).then(setResults);
}, []); // corre una vez

Explica el bug y arréglalo.

Ver solución

El bug es una dependencia faltante: el efecto usa query (llama a fetchResults(query)), pero el array es [], que declara "no dependo de nada". React le cree: corre el efecto una sola vez, al montar, cuando query todavía es su valor inicial (por ejemplo ""). Cuando el usuario escribe y query cambia, React no vuelve a correr el efecto —porque [] dijo que no dependía de query—, así que la búsqueda se queda clavada en el primer valor. Es exactamente "mentir en el array".

El arreglo es declarar la dependencia real:

useEffect(() => {
  fetchResults(query).then(setResults);
}, [query]); // depende de query -> re-busca cuando query cambia

Ahora, cada vez que query cambia, el efecto vuelve a correr y re-busca con el valor nuevo. (Y este efecto, al hacer fetch con query cambiante, necesita el cleanup de la lección 6 para no sufrir la race condition; pero el primer arreglo es el array honesto.)

Resumen y siguiente paso

En esta lección aprendiste cuándo vuelve a correr un efecto: lo decide el array de dependencias, el segundo argumento de useEffect. Mediste las tres formas en una secuencia de renders: [] corre una vez al montar (y nunca más), [query] corre al montar y cada vez que query cambia (comparado con Object.is), y sin array corre en cada render. Viste la línea clave —un re-render por clicks que no dispara el efecto [query] porque query no cambió—, que es justo lo que hace útil al array. Y clavaste la regla que evita el bug más común: el array es una declaración honesta de lo que el efecto usa por dentro; mentir (dejar fuera una dependencia) hace que el efecto trabaje con valores viejos, y [] "por si acaso" es la forma más común de mentir. Guarda la planta: no riegas en cada instante ni una sola vez; riegas cuando cambia la estación.

Antes de avanzar deberías poder: explicar qué hace [], [dep] y omitir el array; predecir qué efectos corren en una secuencia de renders; explicar por qué un re-render por otra causa no dispara un efecto cuya dependencia no cambió; y diagnosticar un efecto con dependencia faltante.

La lección 4 toma la otra cara del "cuándo". Cada vez que un efecto vuelve a correr por un cambio de dependencia —y también cuando el componente se desmonta—, React corre primero una función especial que el efecto puede devolver: el cleanup. Es cómo un efecto deshace lo que montó (cierra la conexión que abrió, cancela el timer que arrancó), y sin él vienen los leaks y las race conditions. Lo mediremos con una suscripción que, con cleanup, deja siempre exactamente una activa —y sin cleanup, se acumulan—.

Recursos