Búsqueda de sitios web

javascript - ¿Por qué useEffect se ejecuta dos veces y cómo manejarlo bien en React?


Tengo un contador y un console.log() en un useEffect para registrar cada cambio en mi estado, pero el useEffect se está llamando dos veces al montar. Estoy usando React 18. Aquí hay un CodeSandbox de mi proyecto y el código a continuación:

import  { useState, useEffect } from "react";

const Counter = () => {
  const [count, setCount] = useState(5);

  useEffect(() => {
    console.log("rendered", count);
  }, [count]);

  return (
    <div>
      <h1> Counter </h1>
      <div> {count} </div>
      <button onClick={() => setCount(count + 1)}> click to increase </button>
    </div>
  );
};

export default Counter;

Respuesta #1

Que useEffect se llame dos veces al montarse es normal desde la versión 18 de React cuando estás en desarrollo con StrictMode. Aquí hay un resumen de la razón según la documentación:

En el futuro, nos gustaría agregar una función que permita a React agregar y eliminar secciones de la interfaz de usuario mientras se preserva el estado. Por ejemplo, cuando un usuario cambia de pestaña desde una pantalla y vuelve, React debería poder mostrar inmediatamente la pantalla anterior. Para hacer esto, React soportará volver a montar árboles utilizando el mismo estado del componente que se usó antes de desmontar.

Esta función le dará a React un mejor rendimiento desde el primer momento, pero requiere que los componentes sean resistentes a que los efectos se monten y destruyan varias veces. La mayoría de los efectos funcionarán sin ningún cambio, pero algunos efectos no limpian adecuadamente sus suscripciones en el callback de destrucción, o asumen implícitamente que solo se montan o destruyen una vez.

Para ayudar a sacar a la luz estos problemas, React 18 introduce una nueva verificación solo para desarrollo en el Modo Estricto. Esta nueva verificación desmontará y volverá a montar automáticamente cada componente, cada vez que un componente se monte por primera vez, restaurando el estado anterior en el segundo montaje.

Esto solo se aplica al modo de desarrollo, el comportamiento de producción no cambia.

Parece extraño, pero al final, es para que escribamos mejor código de React, sin errores, alineado con las guías actuales y compatible con versiones futuras, almacenando en caché las solicitudes HTTP y usando la función de limpieza siempre que tener dos llamadas sea un problema. Aquí hay un ejemplo:

/* Having a setInterval inside an useEffect: */

import { useEffect, useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setCount((count) => count + 1), 1000);

    /* 
       Make sure I clear the interval when the component is unmounted,
       otherwise, I get weird behavior with StrictMode, 
       helps prevent memory leak issues.
    */
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
};

export default Counter;

En este artículo muy detallado, el equipo de React explica useEffect como nunca antes y dice sobre un ejemplo:

Esto ilustra que si volver a montar rompe la lógica de tu aplicación, normalmente esto revela errores existentes. Desde la perspectiva del usuario, visitar una página no debería ser diferente a visitarla, hacer clic en un enlace y luego presionar Atrás. React verifica que tus componentes no rompan este principio volviéndolos a montar una vez en desarrollo.

Para tu caso de uso específico, puedes dejarlo tal como está sin ninguna preocupación. Y no deberías intentar usar esas técnicas con useRef y declaraciones if en useEffect para hacer que se ejecute una vez, o eliminar StrictMode, porque como puedes leer en la documentación:

React vuelve a montar intencionalmente tus componentes en desarrollo para ayudarte a encontrar errores. La pregunta correcta no es "cómo ejecutar un Effect una vez", sino "cómo arreglar mi Effect para que funcione después de volver a montar".

Usualmente, la respuesta es implementar la función de limpieza. La función de limpieza debería detener o deshacer lo que el Efecto estaba haciendo. La regla general es que el usuario no debería poder distinguir entre el Efecto ejecutándose una vez (como en producción) y una secuencia de configuración → limpieza → configuración (como se vería en desarrollo).

/* As a second example, an API call inside an useEffect with fetch: */

useEffect(() => {
  const abortController = new AbortController();

  const fetchUser = async () => {
    try {
      const res = await fetch("/api/user/", {
        signal: abortController.signal,
      });
      const data = await res.json();
    } catch (error) {
      // ℹ️: The error name is "CanceledError" for Axios.
      if (error.name !== "AbortError") {
        /* Logic for non-aborted error handling goes here. */
      }
    }
  };

  fetchUser();

  /* 
    Abort the request as it isn't needed anymore, the component being 
    unmounted. It helps avoid, among other things, the well-known "can't
    perform a React state update on an unmounted component" warning.
  */
  return () => abortController.abort();
}, []);

No puedes "deshacer" una solicitud de red que ya ocurrió, pero tu función de limpieza debería asegurarse de que la búsqueda que ya no es relevante no siga afectando tu aplicación.

En desarrollo, verás dos solicitudes en la pestaña de Red. No hay nada de malo en eso. Con el enfoque anterior, el primer Effect se limpiará de inmediato... Así que, aunque haya una solicitud extra, no afectará el estado gracias a la cancelación.

En producción, solo habrá una solicitud. Si la segunda solicitud en desarrollo te está molestando, la mejor manera de abordarlo es usar una solución que deduplique las solicitudes y almacene en caché sus respuestas entre componentes:

function TodoList() {
  const todos = useSomeDataFetchingLibraryWithCache(`/api/user/${userId}/todos`);
  // ...

Y si todavía tienes problemas, tal vez estés usando useEffect donde no deberías en primer lugar, como dicen en Not an Effect: Inicializando la aplicación, y Not an Effect: Comprando un producto. Te sugeriría que leas el artículo completo.

Respuesta #2

Aunque estoy de acuerdo con los puntos planteados en la respuesta aceptada,

Si uno aún necesita usar useEffect

Entonces, usar useRef() para controlar el flujo es una opción.

Para aplicar el efecto SOLO en la PRIMERA montura:

const effectRan = useRef(false);

useEffect(() => {
  if (!effectRan.current) {
    console.log("effect applied - only on the FIRST mount");
  }

  return () => effectRan.current = true;
}, []);

Para aplicar el efecto en el REmount:

const effectRan = useRef(false);

useEffect(() => {
  if (effectRan.current || process.env.NODE_ENV !== "development") {
    console.log("effect applied - on the REmount");
  }

  return () => effectRan.current = true;
}, []);

¿Cuándo es esto útil?

Una aplicación es cuando el useEffect contiene una solicitud del servidor que puede cambiar potencialmente el estado del backend (e.g. DB cambio). En ese caso, las solicitudes de servidor no intencionadas (duplicadas) debido a StrictMode pueden dar lugar a resultados imprevidos.

¿No podemos cancelar la solicitud a través de AbortController?

Sí, podemos cancelar una solicitud. Sin embargo, para cuando se invoque la cancelación, la solicitud podría haber ya terminado, si no ha alterado el número de estados del backend. Abort no garantiza un descanso seguro, al menos no de manera predeterminada. Por lo tanto, (creo) que una solicitud que NO está prevista; no debería intentarse en primer lugar.

Artículos relacionados