Stan nie aktualizuje się, gdy używa się podpięcia stanu React w ramach setInterval


120

Wypróbowuję nowe haki do reagowania i mam komponent zegara z licznikiem, który ma zwiększać się co sekundę. Jednak wartość nie przekracza jednego.

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/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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

Odpowiedzi:


170

Powodem jest to, że wywołanie zwrotne przekazane do setIntervalzamknięcia uzyskuje dostęp tylko do timezmiennej w pierwszym renderowaniu, nie ma dostępu do nowej timewartości w kolejnym renderowaniu, ponieważ funkcja useEffect()nie jest wywoływana po raz drugi.

timezawsze ma wartość 0 w setIntervalwywołaniu zwrotnym.

Podobnie jak ten, setStatektóry znasz, haczyki stanu mają dwie formy: jedną, w której przyjmuje stan zaktualizowany, i formę wywołania zwrotnego, w której przekazywany jest bieżący stan. Należy użyć drugiego formularza i odczytać najnowszą wartość stanu w setStatewywołaniu zwrotnym upewnij się, że masz najnowszą wartość stanu przed jej zwiększeniem.

Bonus: podejście alternatywne

Dan Abramov, szczegółowo omawia temat używania setIntervalhaczyków w swoim poście na blogu i przedstawia alternatywne sposoby rozwiązania tego problemu. Gorąco polecam przeczytanie tego!

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/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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


3
To jest bardzo przydatna informacja. Dziękuję Yangshun!
Tholle

8
Nie ma za co, spędziłem chwilę wpatrując się w kod, aby dowiedzieć się, że popełniłem bardzo podstawowy (ale prawdopodobnie nieoczywisty?) Błąd i chciałem podzielić się nim ze społecznością
Yangshun Tay

3
SO w najlepszym wydaniu 👍
Patrick Hund

46
Haha, przyszedłem tutaj, aby podłączyć swój post i to już jest w odpowiedzi!
Dan Abramov

2
Świetny post na blogu. Otwarcie oczu.
benjaminadk

19

useEffect funkcja jest oceniana tylko raz podczas montowania komponentu, gdy podana jest pusta lista wejść.

Alternatywą setIntervaljest ustawienie nowego interwału przy setTimeoutkażdej aktualizacji stanu:

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

Wpływ na wydajność setTimeoutjest nieznaczny i ogólnie można go zignorować. O ile komponent nie jest wrażliwy na czas do momentu, w którym nowo ustawione limity czasu powodują niepożądane skutki, oba podejścia setIntervali setTimeoutpodejścia są dopuszczalne.


16

Jak zauważyli inni, problem polega na tym, że useStatejest wywoływana tylko raz (as deps = []), aby ustawić interwał:

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

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

Następnie, za każdym razem setInterval, gdy tyknie, faktycznie zadzwoni setTime(time + 1), ale timezawsze zachowa wartość, którą miał początkowo, gdy setIntervalzostało zdefiniowane wywołanie zwrotne (zamknięcie).

Możesz użyć alternatywnej formy useStatesetera i podać wywołanie zwrotne zamiast rzeczywistej wartości, którą chcesz ustawić (tak jak w przypadku setState):

setTime(prevTime => prevTime + 1);

Ale zachęcałbym cię do stworzenia własnego useIntervalhooka, abyś mógł wysuszyć i uprościć swój kod używając setInterval deklaratywnie , jak sugeruje Dan Abramov tutaj w 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/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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

Oprócz tworzenia prostszego i czystszego kodu, pozwala to na automatyczne wstrzymanie (i wyczyszczenie) interwału, po prostu przekazując go, delay = nulla także zwraca identyfikator interwału, na wypadek, gdybyś chciał anulować go ręcznie (nie jest to uwzględnione w postach Dana).

Właściwie można to również poprawić, aby nie uruchamiało się ponownie delaypo wznowieniu, ale myślę, że w większości przypadków jest to wystarczająco dobre.

Jeśli szukasz podobnej odpowiedzi setTimeoutzamiast setInterval, sprawdź to: https://stackoverflow.com/a/59274757/3723993 .

Można również znaleźć deklaratywny wersję setTimeouta setInterval, useTimeouta useInterval, a także zwyczaj useThrottledCallbackhak napisany na maszynie w https://gist.github.com/Danziger/336e75b6675223ad805a88c2dfdcfd4a .


5

Alternatywnym rozwiązaniem byłoby użycie useReducer, gdyż zawsze zostanie przekazany stan obecny.

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/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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


Dlaczego useEffecttutaj jest wywoływana wiele razy, aby zaktualizować czas, podczas gdy tablica zależności jest pusta, co oznacza, że useEffectpowinna być wywoływana tylko przy pierwszym renderowaniu komponentu / aplikacji?
BlackMath

1
@BlackMath Funkcja wewnątrz useEffectjest wywoływana tylko raz, kiedy komponent rzeczywiście się wyrenderuje . Ale w środku setIntervaljest odpowiedzialny za regularną zmianę czasu. Proponuję trochę poczytać setInterval, po tym wszystko powinno być jaśniejsze! developer.mozilla.org/en-US/docs/Web/API/ ...
Bear-Foot

3

Zrób tak, jak poniżej, działa dobrze.

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

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

//call increment function
increment(count);

To count => count + 1oddzwonienie zadziałało dla mnie, dzięki!
Nickofthyme

1

To rozwiązanie nie działa dla mnie, ponieważ muszę pobrać zmienną i zrobić kilka rzeczy, a nie tylko ją zaktualizować.

Otrzymuję obejście, aby uzyskać zaktualizowaną wartość zaczepu z obietnicą

Na przykład:

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

Dzięki temu mogę uzyskać wartość wewnątrz funkcji setInterval w ten sposób

let dateFrom = await getCurrentHackValue(setSelectedDateFrom);

0

Poinformuj Reacta o ponownym renderowaniu, gdy czas się zmieni zrezygnować

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/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

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


2
Problem polega na tym, że licznik czasu zostanie wyczyszczony i zresetowany po każdej countzmianie.
sumail

A ponieważ tak setTimeout()jest preferowane, jak wskazał Estus
Chayim Friedman,
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.