Состояние не обновляется при использовании обработчика состояния React в setInterval

Я пробую новый React Hooks и имею компонент Clock со счетчиком, который должно увеличиваться каждую секунду. Однако значение не превышает единицы.

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>


person Yangshun Tay    schedule 27.10.2018    source источник
comment
Есть прекрасные объяснения, почему это происходит. Если кто-то хочет получить значение, stackoverflow.com/a/57679222/4427870 - это очень недооцененный прием.   -  person Rishav    schedule 28.03.2021


Ответы (8)


Причина в том, что обратный вызов, переданный в закрытие setInterval, обращается только к переменной time в первом рендере, у него нет доступа к новому значению time в последующем рендеринге, потому что useEffect() не вызывается во второй раз.

time всегда имеет значение 0 в обратном вызове setInterval.

Как и setState, с которым вы знакомы, перехватчики состояния имеют две формы: одну, в которой он принимает обновленное состояние, и форму обратного вызова, в которую передается текущее состояние. Вы должны использовать вторую форму и читать последнее значение состояния в setState обратный вызов, чтобы убедиться, что у вас есть последнее значение состояния, прежде чем увеличивать его.

Бонус: альтернативные подходы

Дэн Абрамов подробно рассказывает об использовании setInterval с крючками в своем блоге. post и предлагает альтернативные способы решения этой проблемы. Очень рекомендую прочитать это!

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>

person Yangshun Tay    schedule 27.10.2018
comment
@YangshunTay Если я просто хочу прочитать значение состояния в setInterval, как мне это сделать? - person neosarchizo; 17.02.2020
comment
Как бы это выглядело, если бы вы хотели периодически выводить текущее состояние с помощью console.log в функции setInterval? - person user3579222; 22.03.2020
comment
Я хочу прочитать время (в setInterval) и обновить, если оно больше, чем какое-то время. Как этого добиться? - person artsnr; 22.01.2021
comment
@neosarchizo Если вы просто хотите прочитать это, вы можете прочитать обновленное значение как часть рендеринга внизу. Не понял, не могли бы вы немного уточнить - person artsnr; 22.01.2021
comment
Это сообщение в блоге отличное. Однако мне бы хотелось, чтобы он был переведен на более общий пример. Объясняя, как это сделать для любого обратного вызова, было бы неплохо перепроверить мою работу. - person Daniel; 03.04.2021

useEffect функция оценивается только один раз при монтировании компонента, если предоставлен пустой список ввода.

Альтернативой setInterval является установка нового интервала с setTimeout каждый раз при обновлении состояния:

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

Влияние setTimeout на производительность незначительно, и его можно игнорировать. Если компонент не чувствителен ко времени до точки, где вновь установленные тайм-ауты вызывают нежелательные эффекты, приемлемы оба подхода: setInterval и setTimeout.

person Estus Flask    schedule 28.01.2019

Как указывали другие, проблема в том, что useState вызывается только один раз (как deps = []) для установки интервала:

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

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

Затем каждый раз, когда setInterval тикает, он фактически вызывает setTime(time + 1), но time всегда будет хранить значение, которое оно имело изначально, когда был определен обратный вызов setInterval (закрытие).

Вы можете использовать альтернативную форму установщика useState и предоставить обратный вызов, а не фактическое значение, которое вы хотите установить (как и в случае с setState):

setTime(prevTime => prevTime + 1);

Но я бы посоветовал вам создать свой собственный useInterval хук, чтобы вы могли DRY и упростить свой код, используя setInterval декларативно, как предлагает Дэн Абрамов здесь, в Создание декларативного setInterval с помощью 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>

Помимо создания более простого и понятного кода, это позволяет вам автоматически приостанавливать (и очищать) интервал, просто передавая delay = null, а также возвращает идентификатор интервала, если вы хотите отменить его вручную (это не рассматривается в сообщениях Дэна).

На самом деле, это также можно улучшить, чтобы он не перезапускал delay при снятии паузы, но я думаю, для большинства случаев использования этого достаточно.

Если вы ищете аналогичный ответ для setTimeout, а не для setInterval, проверьте это: https://stackoverflow.com/a/59274757/3723993.

Вы также можете найти декларативную версию setTimeout и setInterval, useTimeout и useInterval, а также пользовательский хук useThrottledCallback, написанный на TypeScript, в https://gist.github.com/Danziger/336e75b6675223ad805a88c2dfdcfd4a.

person Danziger    schedule 10.12.2019

Альтернативным решением было бы использовать useReducer, так как ему всегда будет передаваться текущее состояние.

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>

person Bear-Foot    schedule 28.01.2019
comment
Почему useEffect здесь вызывается несколько раз для обновления времени, в то время как массив зависимостей пуст, а это означает, что useEffect следует вызывать только при первом рендеринге компонента / приложения? - person BlackMath; 26.09.2020
comment
@BlackMath Функция внутри useEffect вызывается только один раз, когда компонент действительно сначала отрисовывается. Но внутри него есть setInterval, который отвечает за изменение времени на регулярной основе. Предлагаю вам прочитать немного о setInterval, после этого все станет яснее! developer.mozilla.org/en-US/docs/Web/ API / - person Bear-Foot; 27.09.2020

Сделайте, как показано ниже, он отлично работает.

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

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

//call increment function
increment(count);
person Vidya    schedule 04.02.2020
comment
Где в вашем ответе используется setInterval? - person Vasanth; 10.05.2021

useRef может решить эту проблему, вот аналогичный компонент, который увеличивает счетчик каждые 1000 мс.

import { useState, useEffect, useRef } from "react";

export default function App() {
  const initalState = 0;
  const [count, setCount] = useState(initalState);
  const counterRef = useRef(initalState);

  useEffect(() => {
    counterRef.current = count;
  })

  useEffect(() => {
    setInterval(() => {
      setCount(counterRef.current + 1);
    }, 1000);
  }, []);

  return (
    <div className="App">
      <h1>The current count is:</h1>
      <h2>{count}</h2>
    </div>
  );
}

и я думаю, что эта статья поможет вам об использовании интервала для перехвата реакции

person Esin ÖNER    schedule 16.04.2021

Эти решения не работают для меня, потому что мне нужно получить переменную и сделать что-то, а не просто обновить ее.

У меня есть обходной путь, чтобы получить обновленное значение хука с обещанием

Eg:

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

С этим я могу получить значение внутри функции setInterval следующим образом

let dateFrom = await getCurrentHackValue(setSelectedDateFrom);
person Jhonattan Oliveira    schedule 27.08.2019

Сообщите React о повторной визуализации при изменении времени. opt из

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>

person sumail    schedule 20.03.2019
comment
Проблема в том, что таймер очищается и сбрасывается после каждого count изменения. - person sumail; 21.03.2019
comment
И поскольку так setTimeout() предпочтительнее, как указал Эстус - person Chayim Friedman; 16.07.2020