El estado no se actualiza cuando se usa React state hook dentro de setInterval

109

Estoy probando los nuevos React Hooks y tengo un componente de reloj con un contador que se supone que aumenta cada segundo. Sin embargo, el valor no aumenta más allá de uno.

function Clock() {
  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    const timer = window.setInterval(() => {
      setTime(time + 1);
    }, 1000);
    return () => {
      window.clearInterval(timer);
    };
  }, []);

  return (
    <div>Seconds: {time}</div>
  );
}

ReactDOM.render(<Clock />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Yangshun Tay
fuente

Respuestas:

159

La razón es porque la devolución de llamada pasada al setIntervalcierre de 'solo accede a la timevariable en el primer renderizado, no tiene acceso al nuevo timevalor en el renderizado posterior porque useEffect()no se invoca la segunda vez.

timesiempre tiene el valor de 0 dentro de la setIntervaldevolución de llamada.

Al igual que con el setStateque está familiarizado, los ganchos de estado tienen dos formas: una en la que toma el estado actualizado y la forma de devolución de llamada en la que se pasa el estado actual. Debe usar la segunda forma y leer el último valor de estado dentro de la setStatedevolución de llamada para asegúrese de tener el último valor de estado antes de incrementarlo.

Bono: enfoques alternativos

Dan Abramov, profundiza en el tema sobre el uso setIntervalcon ganchos en su publicación de blog y proporciona formas alternativas de solucionar este problema. ¡Recomiendo encarecidamente leerlo!

function Clock() {
  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    const timer = window.setInterval(() => {
      setTime(prevTime => prevTime + 1); // <-- Change this line!
    }, 1000);
    return () => {
      window.clearInterval(timer);
    };
  }, []);

  return (
    <div>Seconds: {time}</div>
  );
}

ReactDOM.render(<Clock />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Yangshun Tay
fuente
3
Esta es una información muy útil. ¡Gracias Yangshun!
Tholle
8
De nada, pasé un rato mirando el código solo para descubrir que
cometí un
3
SO en su mejor momento 👍
Patrick Hund
42
¡Jaja, vine aquí para conectar mi publicación y ya está en la respuesta!
Dan Abramov
2
Gran publicación de blog. Apertura de ojos.
benjaminadk
19

useEffect La función se evalúa solo una vez en el montaje del componente cuando se proporciona una lista de entrada vacía.

Una alternativa setIntervales establecer un nuevo intervalo setTimeoutcada vez que se actualiza el estado:

  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    const timer = setTimeout(() => {
      setTime(time + 1);
    }, 1000);
    return () => {
      clearTimeout(timer);
    };
  }, [time]);

El impacto en el rendimiento de setTimeoutes insignificante y generalmente se puede ignorar. A menos que el componente es sensible al tiempo hasta el punto en recién configurado tiempos de espera causan efectos indeseables, tanto setIntervaly setTimeoutenfoques son aceptables.

Matraz Estus
fuente
12

Como han señalado otros, el problema es que useStatesolo se llama una vez (ya que es deps = []para configurar el intervalo:

React.useEffect(() => {
    const timer = window.setInterval(() => {
        setTime(time + 1);
    }, 1000);

    return () => window.clearInterval(timer);
}, []);

Luego, cada vez setIntervalque haga tictac, realmente llamará setTime(time + 1), pero timesiempre mantendrá el valor que tenía inicialmente cuando setIntervalse definió la devolución de llamada (cierre).

Puede usar la forma alternativa de useState's setter y proporcionar una devolución de llamada en lugar del valor real que desea establecer (como con setState):

setTime(prevTime => prevTime + 1);

Pero lo animo a crear su propio useIntervalgancho para que pueda SECAR y simplificar su código mediante el uso setInterval declarativo , como Dan Abramov sugiere aquí en Cómo hacer un setInterval declarativo con React Hooks :

function useInterval(callback, delay) {
  const intervalRef = React.useRef();
  const callbackRef = React.useRef(callback);

  // Remember the latest callback:
  //
  // Without this, if you change the callback, when setInterval ticks again, it
  // will still call your old callback.
  //
  // If you add `callback` to useEffect's deps, it will work fine but the
  // interval will be reset.

  React.useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Set up the interval:

  React.useEffect(() => {
    if (typeof delay === 'number') {
      intervalRef.current = window.setInterval(() => callbackRef.current(), delay);

      // Clear interval if the components is unmounted or the delay changes:
      return () => window.clearInterval(intervalRef.current);
    }
  }, [delay]);
  
  // Returns a ref to the interval ID in case you want to clear it manually:
  return intervalRef;
}


const Clock = () => {
  const [time, setTime] = React.useState(0);
  const [isPaused, setPaused] = React.useState(false);
        
  const intervalRef = useInterval(() => {
    if (time < 10) {
      setTime(time + 1);
    } else {
      window.clearInterval(intervalRef.current);
    }
  }, isPaused ? null : 1000);

  return (<React.Fragment>
    <button onClick={ () => setPaused(prevIsPaused => !prevIsPaused) } disabled={ time === 10 }>
        { isPaused ? 'RESUME ⏳' : 'PAUSE 🚧' }
    </button>

    <p>{ time.toString().padStart(2, '0') }/10 sec.</p>
    <p>setInterval { time === 10 ? 'stopped.' : 'running...' }</p>
  </React.Fragment>);
}

ReactDOM.render(<Clock />, document.querySelector('#app'));
body,
button {
  font-family: monospace;
}

body, p {
  margin: 0;
}

p + p {
  margin-top: 8px;
}

#app {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100vh;
}

button {
  margin: 32px 0;
  padding: 8px;
  border: 2px solid black;
  background: transparent;
  cursor: pointer;
  border-radius: 2px;
}
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Además de producir un código más simple y limpio, esto le permite pausar (y borrar) el intervalo automáticamente al pasar delay = nully también devuelve la ID del intervalo, en caso de que desee cancelarlo usted mismo manualmente (eso no está cubierto en las publicaciones de Dan).

En realidad, esto también podría mejorarse para que no se reinicie delaycuando no se pausa, pero supongo que para la mayoría de los casos de uso esto es lo suficientemente bueno.

Si está buscando una respuesta similar para en setTimeoutlugar de setInterval, consulte esto: https://stackoverflow.com/a/59274757/3723993 .

También puede encontrar una versión declarativa de setTimeouty setInterval, useTimeouty useInterval, además de un useThrottledCallbackgancho personalizado escrito en TypeScript en https://gist.github.com/Danziger/336e75b6675223ad805a88c2dfdcfd4a .

Danziger
fuente
5

Una solución alternativa sería usar useReducer, ya que siempre se pasará al estado actual.

function Clock() {
  const [time, dispatch] = React.useReducer((state = 0, action) => {
    if (action.type === 'add') return state + 1
    return state
  });
  React.useEffect(() => {
    const timer = window.setInterval(() => {
      dispatch({ type: 'add' });
    }, 1000);
    return () => {
      window.clearInterval(timer);
    };
  }, []);

  return (
    <div>Seconds: {time}</div>
  );
}

ReactDOM.render(<Clock />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

Pie de oso
fuente
¿Por qué useEffectaquí se llama varias veces para actualizar la hora, mientras que la matriz de dependencias está vacía, lo que significa que solo se useEffectdebe llamar la primera vez que se procesa el componente / aplicación?
BlackMath
1
@BlackMath La función interna useEffectse llama solo una vez, cuando el componente se procesa por primera vez. Pero dentro de ella, hay una setIntervalque se encarga de cambiar la hora de forma regular. Te sugiero que leas un poco setInterval, ¡las cosas deberían estar más claras después de eso! developer.mozilla.org/en-US/docs/Web/API/…
Bear-Foot
3

Haz lo siguiente, funciona bien.

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

async function increment(count,value) {
    await setCount(count => count + 1);
  }

//call increment function
increment(count);
Vidya
fuente
La count => count + 1devolución de llamada fue lo que funcionó para mí, ¡gracias!
Nickofthyme
1

Estas soluciones no funcionan para mí porque necesito obtener la variable y hacer algunas cosas, no solo actualizarla.

Obtengo una solución alternativa para obtener el valor actualizado del gancho con una promesa

P.ej:

async function getCurrentHookValue(setHookFunction) {
  return new Promise((resolve) => {
    setHookFunction(prev => {
      resolve(prev)
      return prev;
    })
  })
}

Con esto puedo obtener el valor dentro de la función setInterval como esta

let dateFrom = await getCurrentHackValue(setSelectedDateFrom);
Jhonattan Oliveira
fuente
0

Dile a React que vuelva a renderizar cuando cambie el tiempo. optar por no

function Clock() {
  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    const timer = window.setInterval(() => {
      setTime(time + 1);
    }, 1000);
    return () => {
      window.clearInterval(timer);
    };
  }, [time]);

  return (
    <div>Seconds: {time}</div>
  );
}

ReactDOM.render(<Clock />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>

sumail
fuente
2
El problema con esto es que el temporizador se borrará y reiniciará después de cada countcambio.
sumail
Y porque así setTimeout()se prefiere como lo señaló Estus
Chayim Friedman