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/[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
źródło

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/[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
źródło
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.

Estus Flask
źródło
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/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/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 .

Danziger
źródło
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/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

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

Boso
źródło
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);
Vidya
źródło
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);
Jhonattan Oliveira
źródło
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/[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
źródło
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,