El estado no se actualiza cuando se usa el enlace de estado de React dentro de setInterval

Resuelto Yangshun Tay asked hace 6 años • 15 respuestas

Estoy probando los nuevos React Hooks y tengo un componente de reloj con un timevalor 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>
Expandir fragmento

Yangshun Tay avatar Oct 28 '18 00:10 Yangshun Tay
Aceptado

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

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

Como setStateya conoce, los enlaces 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 devolución de setStatellamada para asegúrese de tener el último valor de estado antes de incrementarlo.

Bonificación: enfoques alternativos

Dan Abramov profundiza en el tema sobre el uso setIntervalde ganchos en su publicación de blog y ofrece formas alternativas de solucionar este problema. ¡Recomiendo mucho 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>
Expandir fragmento

Yangshun Tay avatar Oct 27 '2018 17:10 Yangshun Tay

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

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

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

Luego, cada vez setIntervalque marca, 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 del useStateconfigurador y proporcionar una devolución de llamada en lugar del valor real que desea establecer (al igual que con setState):

setTime(prevTime => prevTime + 1);

Pero te animo a que crees tu propio useIntervalgancho para que puedas SECAR y simplificar tu código usando setInterval de forma declarativa , como sugiere Dan Abramov aquí en Cómo hacer que setInterval sea declarativo con ganchos de React :

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>
Expandir fragmento

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

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

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

También puede encontrar una versión declarativa de setTimeouty setInterval, useTimeouty useInterval, algunos enlaces adicionales escritos en TypeScript en https://www.npmjs.com/package/@swyg/corre .

Danziger avatar Dec 10 '2019 19:12 Danziger

useEffectLa 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 setTimeoutes insignificante y, en general, puede ignorarse. A menos que el componente sea sensible al tiempo hasta el punto en que los tiempos de espera recién establecidos causen efectos indeseables, ambos setIntervalenfoques setTimeoutson aceptables.

Estus Flask avatar Jan 28 '2019 07:01 Estus Flask