使用React状态钩子在setInterval中时,状态没有更新

259

我正在尝试使用新的React Hooks,并且有一个时钟组件,其中time值应该每秒增加一次。然而,该值不会增加超过1。

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>


1
这种情况发生的原因有很好的解释。如果有人想要获取该值,https://dev59.com/SVQJ5IYBdhLWcg3w05ZM#57679222 是一个高度被低估的解决方法。 - Rishav
15个回答

309

之所以如此,是因为传入setInterval闭包中的回调函数只能访问第一次渲染时的time变量,它无法访问后续渲染中新的time值,因为第二次并未触发useEffect()

setInterval回调函数中time始终具有0值。

和你熟悉的setState一样,状态钩子有两种形式:一种接受更新后的状态,另一种是回调形式,其中传递了当前状态。应该使用第二种形式,在setState回调函数中读取最新的状态值,以确保在增加状态值之前具有最新的状态值。

额外内容:替代方法

Dan Abramov在他的博客文章中深入探讨了使用hooks进行setInterval的主题,并提供了解决此问题的替代方法。强烈推荐阅读!

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>


8
如果我只想在 setInterval 中读取状态值,应该怎么做? - neosarchizo
5
你读过 Dan 的文章吗?https://overreacted.io/making-setinterval-declarative-with-react-hooks/。如果你只想阅读它,你可以在底部作为呈现的一部分读取更新后的值。如果你想触发副作用,你可以添加一个 useEffect() 钩子并将该状态添加到依赖数组中。 - Yangshun Tay
如果您想在setInterval函数中使用console.log定期输出当前状态,该怎么做呢? - user3579222
1
@neosarchizo:“如果你只想阅读它,你可以在底部的渲染中读取更新后的值。” 您没明白吗?您能详细解释一下吗? - artsnr
那篇博客文章很棒。不过我希望能看到一个更通用的例子进行翻译。解释如何对任何回调函数进行操作将有助于检查我的工作是否正确。 - Daniel
显示剩余3条评论

59

正如其他人指出的那样,问题在于useState只被调用一次(作为deps = [])来设置间隔:

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

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

然后,每次setInterval滴答,它实际上会调用setTime(time + 1),但是time将始终保持在setInterval回调(闭包)定义时的初始值。
您可以使用useState的setter的另一种形式,并提供回调而不是您要设置的实际值(就像setState一样):
setTime(prevTime => prevTime + 1);

但我建议您创建自己的useInterval钩子,这样您就可以使用setInterval 声明式地DRY和简化代码,正如Dan Abramov在使用React Hooks使setInterval声明式中所建议的那样:

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>

除了可以生成更简单和更干净的代码外,这还允许您通过简单地传递delay = null来自动暂停(和清除)间隔,并返回间隔ID,以防您想要手动取消它(这在Dan的帖子中没有涵盖)。
实际上,这也可以改进,以便在取消暂停时不重新启动delay,但我想对于大多数用例而言,这已经足够好了。
如果您正在寻找与setInterval而不是setTimeout类似的答案,请查看此内容:https://dev59.com/_lQJ5IYBdhLWcg3wzJAP#59274757
您还可以在https://www.npmjs.com/package/@swyg/corre中找到setTimeoutsetInterval的声明版本,useTimeoutuseInterval,以及一些用TypeScript编写的其他钩子。

43

useEffect函数在提供空输入列表时仅在组件挂载时被评估一次。

setInterval的替代方法是在每次更新状态时使用setTimeout设置新的间隔:

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

setTimeout 的性能影响微不足道,可以忽略不计。除非组件对时间敏感,新设置的超时会导致不良影响,否则 setIntervalsetTimeout 方法都可接受。


23

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>
  );
}

我认为这篇文章会帮助你了解如何在React Hooks中使用定时器。


10

另一个解决方案是使用 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/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>


为什么在这里使用useEffect来多次更新时间,而依赖项数组为空,这意味着useEffect应该只在组件/应用程序首次渲染时调用一次? - BlackMath
1
@BlackMath 在 useEffect 中的函数确实只会在组件首次渲染时被调用一次。但是,在该函数内部,有一个 setInterval 负责定期更改时间。我建议您阅读一些关于 setInterval 的内容,之后应该会更清楚!https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setInterval - Bear-Foot

5
const [seconds, setSeconds] = useState(0);
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds((seconds) => {
        if (seconds === 5) {
          setSeconds(0);
          return clearInterval(interval);
        }
        return (seconds += 1);
      });
    }, 1000);
  }, []);

注意: 这将使用useState钩子来更新和重置计数器。5秒后,秒表会停止。因为首先改变setSecond的值,然后在setInterval中使用更新后的秒钟停止计时器,而useEffect仅运行一次。


这对我帮助很大。在所有面试中都会问到这个问题。 - Biplov Kumar

1

有一个类似的问题,涉及到一个状态值是对象没有更新

我之前也遇到过这个问题,希望这篇文章能帮到别人。 我们需要传递旧对象与新对象合并后的结果。

const [data, setData] = useState({key1: "val", key2: "val"});
useEffect(() => {
  setData(...data, {key2: "new val", newKey: "another new"}); // --> Pass old object
}, []);

1
function Clock() {
  const [time, setTime] = React.useState(0);
  React.useEffect(() => {
    const timer = window.setInterval(() => {
      setTime(time => time + 1);// **set callback function here** 
    }, 1000);
    return () => {
      window.clearInterval(timer);
    };
  }, []);

  return (
    <div>Seconds: {time}</div>
  );
}

ReactDOM.render(<Clock />, document.querySelector('#app'));

您的答案可以通过提供更多支持信息来改进。请[编辑]以添加更多细节,如引用或文档,以便其他人可以确认您的答案是否正确。您可以在帮助中心找到有关编写良好答案的更多信息。 - Community

1
这些解决方案对我无效,因为我需要获取变量并进行一些操作,而不仅仅是更新它。
我找到了一个解决方法,可以使用 Promise 获取钩子的更新值。
例如:
async function getCurrentHookValue(setHookFunction) {
  return new Promise((resolve) => {
    setHookFunction(prev => {
      resolve(prev)
      return prev;
    })
  })
}

使用这种方法,我可以像这样获得 setInterval 函数内的值

let dateFrom = await getCurrentHackValue(setSelectedDateFrom);

这是一种不好的做法,React状态设置器应该是纯粹的,没有副作用。此外,仅为了获取当前值而调用某些设置器仍然会触发当前组件的重新渲染。 - Emile Bergeron

0

针对那些寻找极简主义解决方案的人:

  1. 在N秒后停止间隔,以及
  2. 能够在按钮点击时多次重置

我并不是 React 专家,只是被同事请来帮忙,写了这篇文章,希望其他人也会找到它有用。


  const [disabled, setDisabled] = useState(true)
  const [inter, setInter] = useState(null)
  const [seconds, setSeconds] = useState(0)

  const startCounting = () => {
    setSeconds(0)
    setDisabled(true)
    setInter(window.setInterval(() => {
        setSeconds(seconds => seconds + 1)
    }, 1000))
  }

  useEffect(() => {
      startCounting()
  }, [])

  useEffect(() => {
    if (seconds >= 3) {
        setDisabled(false)
        clearInterval(inter)
    }
  }, [seconds])

  return (<button style = {{fontSize:'64px'}}
      onClick={startCounting}
      disabled = {disabled}>{seconds}</button>)
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接