L'état ne se met pas à jour lors de l'utilisation du hook d'état React dans setInterval

109

J'essaie les nouveaux Hooks React et j'ai un composant Clock avec un compteur qui est censé augmenter chaque seconde. Cependant, la valeur n'augmente pas au-delà de un.

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
la source

Réponses:

158

La raison est que le rappel passé dans setIntervalla fermeture de s n'accède qu'à la timevariable dans le premier rendu, il n'a pas accès à la nouvelle timevaleur dans le rendu suivant car le useEffect()n'est pas appelé la deuxième fois.

timea toujours la valeur 0 dans le setIntervalrappel.

Comme setStatevous le connaissez, les hooks d'état ont deux formes: l'un où il prend l'état mis à jour et le formulaire de rappel dans lequel l'état actuel est passé. Vous devez utiliser le second formulaire et lire la dernière valeur d'état dans le setStaterappel à assurez-vous que vous disposez de la dernière valeur d'état avant de l'incrémenter.

Bonus: Approches alternatives

Dan Abramov, approfondit le sujet de l'utilisation setIntervaldes crochets dans son article de blog et propose des solutions alternatives pour contourner ce problème. Je recommande vivement de le lire!

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
la source
3
Cette information est très utile. Merci Yangshun!
Tholle
8
Vous êtes le bienvenu, j'ai passé un moment à regarder le code pour découvrir que j'avais fait une erreur très basique (mais probablement non évidente?) Et que je voulais partager cela avec la communauté
Yangshun Tay
3
SO à son meilleur 👍
Patrick Hund
42
Haha je suis venu ici pour brancher mon post et c'est déjà dans la réponse!
Dan Abramov
2
Excellent article de blog. Ouverture des yeux.
benjaminadk
19

useEffect La fonction n'est évaluée qu'une seule fois lors du montage du composant lorsqu'une liste d'entrée vide est fournie.

Une alternative à setIntervalest de définir un nouvel intervalle à setTimeoutchaque fois que l'état est mis à jour:

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

L'impact sur les performances de setTimeoutest insignifiant et peut être généralement ignoré. À moins que le composant est sensible au temps au point où les délais d' attente nouvellement créées provoquent des effets indésirables, à la fois setIntervalet les setTimeoutapproches sont acceptables.

Fiole d'Estus
la source
12

Comme d'autres l'ont souligné, le problème est qu'il useStaten'est appelé qu'une seule fois (car il sert deps = []à configurer l'intervalle:

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

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

Ensuite, à chaque fois qu'il setIntervalcoche, il appellera setTime(time + 1), mais timeconservera toujours la valeur qu'il avait initialement lorsque le setIntervalrappel (fermeture) a été défini.

Vous pouvez utiliser la forme alternative de useState's setter et fournir un rappel plutôt que la valeur réelle que vous souhaitez définir (comme avec setState):

setTime(prevTime => prevTime + 1);

Mais je vous encourage à créer votre propre useIntervalhook afin que vous puissiez DRY et simplifier votre code en utilisant de manière setInterval déclarative , comme le suggère Dan Abramov ici dans Making setInterval Declarative with 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>

En plus de produire un code plus simple et plus propre, cela vous permet de suspendre (et d'effacer) automatiquement l'intervalle en passant delay = nullet renvoie également l'ID d'intervalle, au cas où vous voudriez l'annuler vous-même manuellement (ce n'est pas couvert dans les articles de Dan).

En fait, cela pourrait également être amélioré afin qu'il ne redémarre pas delaylorsqu'il est réactivé, mais je suppose que pour la plupart des cas d'utilisation, cela suffit.

Si vous recherchez une réponse similaire pour setTimeoutplutôt que setInterval, consultez ceci: https://stackoverflow.com/a/59274757/3723993 .

Vous pouvez également trouver la version déclarative de setTimeoutand setInterval, useTimeoutet useInterval, plus un useThrottledCallbackhook personnalisé écrit en TypeScript dans https://gist.github.com/Danziger/336e75b6675223ad805a88c2dfdcfd4a .

Danziger
la source
5

Une solution alternative serait d'utiliser useReducer, car il sera toujours passé l'état actuel.

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>

Pied d'ours
la source
Pourquoi useEffectici est-il appelé plusieurs fois pour mettre à jour l'heure, alors que le tableau des dépendances est vide, ce qui signifie que le useEffectne doit être appelé que la première fois que le composant / l'application est rendu?
BlackMath le
1
@BlackMath La fonction à l'intérieur useEffectn'est appelée qu'une seule fois, lorsque le composant est effectivement rendu pour la première fois. Mais à l'intérieur, il y a un setIntervalqui se charge de changer l'heure de façon régulière. Je vous suggère de lire un peu sur setInterval, les choses devraient être plus claires après ça! developer.mozilla.org/en-US/docs/Web/API/…
Bear-Foot
3

Faites comme ci-dessous, cela fonctionne bien.

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

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

//call increment function
increment(count);
Vidya
la source
Le count => count + 1rappel a fonctionné pour moi, merci!
Nickofthyme le
1

Ces solutions ne fonctionnent pas pour moi car j'ai besoin d'obtenir la variable et de faire certaines choses, pas seulement de la mettre à jour.

J'obtiens une solution de contournement pour obtenir la valeur mise à jour du crochet avec une promesse

Par exemple:

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

Avec cela, je peux obtenir la valeur à l'intérieur de la fonction setInterval comme ceci

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

Dites à React re-render lorsque le temps a changé. se désengager

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
la source
2
Le problème avec ceci est que la minuterie sera effacée et réinitialisée après chaque countchangement.
sumail
Et parce que setTimeout()c'est préféré comme le souligne Estus
Chayim Friedman le