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.