为什么需要带有功能更新表单的 React useState?

Why React useState with functional update form is needed?

提问人:likern 提问时间:9/7/2019 更新时间:7/26/2022 访问量:23550

问:

我正在阅读有关功能更新的 React Hook 文档,并看到这句话:

“+”和“-”按钮使用功能形式,因为更新了 值基于前一个值

但是我看不出需要功能更新的目的是什么,以及它们与直接使用旧状态来计算新状态有什么区别。

为什么 React useState Hook 的更新函数需要功能更新表单? 我们可以清楚地看到差异的示例(因此使用直接更新会导致错误)?

例如,如果我从文档中更改此示例

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
      <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
    </>
  );
}

直接更新:count

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </>
  );
}

我看不出行为有任何差异,也无法想象计数不会更新(或不是最新的)的情况。因为每当计数发生变化时,都会调用新的闭包,捕获最近的 .onClickcount

reactjs 反应钩子

评论


答:

41赞 Alex Gessen 9/7/2019 #1

状态更新在 React 中是异步的。因此,下次更新时可能会有旧值。例如,比较这两个代码示例的结果:count

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => {
        setCount(prevCount => prevCount + 1); 
        setCount(prevCount => prevCount + 1)}
      }>+</button>
    </>
  );
}

function Counter({initialCount}) {
  const [count, setCount] = useState(initialCount);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(initialCount)}>Reset</button>
      <button onClick={() => {
        setCount(count + 1); 
        setCount(count + 1)}
      }>+</button>
    </>
  );
}

评论

1赞 likern 9/7/2019
这是一个不同的情况。update 函数被视为异步函数,这意味着您不应期望在 setClount 调用后立即更改 count 的值,因此不能依赖该新值。我明白了,但这是一个与我的用例不同的用例。用基于旧的(就像大多数人一样)将状态替换为 new 有什么问题
8赞 Alex Gessen 9/7/2019
这正是原因 - 用户可以在重新渲染组件之前单击 + 和 - 或非常快速地单击 + 两次,在第二次调用中,值将是错误的(未更新)。count
1赞 likern 9/7/2019
是的,我想我必须同意这种可能的情况
1赞 bhagwans 10/10/2020
@AlexGessen,假设我快速点击 + 两次。初始值为 0。计数应增加到 2,即 0 -> 1,然后是 1 -> 2。如果没有功能更新程序表单,则有可能在执行第二个 setCount(count + 1) 时,计数尚未更新为 1,因此它使用 count - 0 的旧值将 count 设置为 0 + 1。在类似的情况下,但使用功能更新程序,这是否意味着当第二个 setCount 发生时 (setCount(prevCount => prevCount + 1)),即使 count 本身可能仍未更新,此处的 prevCount 也会更新?
3赞 Vanessa Phipps 7/26/2022
快速点击不会弄乱你的状态,Hooks hello 世界也没有错。看看我的答案:stackoverflow.com/a/73115899/388033
29赞 G Gallegos 1/13/2020 #2

我最近偶然发现了这个需求。例如,假设您有一个组件,该组件用一定数量的元素填充数组,并能够根据某些用户操作附加到该数组(例如,在我的情况下,当用户不断向下滚动屏幕时,我一次加载 10 个项目。 代码看起来像这样:

function Stream() {
  const [feedItems, setFeedItems] = useState([]);
  const { fetching, error, data, run } = useQuery(SOME_QUERY, vars);

  useEffect(() => {
    if (data) {
      setFeedItems([...feedItems, ...data.items]);
    }
  }, [data]);     // <---- this breaks the rules of hooks, missing feedItems

...
<button onClick={()=>run()}>get more</button>
...

显然,你不能只将 feedItems 添加到 useEffect 钩子中的依赖项列表中,因为你在其中调用了 setFeedItems,所以你会陷入循环。

救援功能更新:

useEffect(() => {
    if (data) {
      setFeedItems(prevItems => [...prevItems, ...data.items]);
    }
  }, [data]);     //  <--- all good now

评论

3赞 ThisDude 11/16/2021
这是我需要的答案......正在处理合并数组
1赞 letvar 5/13/2020 #3

另一个使用功能更新的用例 - 和 react 钩子。有关详细信息,请点击此处 - https://css-tricks.com/using-requestanimationframe-with-react-hooks/setStaterequestAnimationFrame

总之,当您这样做时,会频繁调用 的处理程序,从而导致不正确的值。另一方面,使用会产生正确的值。requestAnimationFramecountsetCount(count+delta)setCount(prevCount => prevCount + delta)

10赞 ehab 12/15/2020 #4

我已经回答了类似的问题,它被关闭了,因为这是规范的问题 - 我不知道,在查看答案后,我决定在这里重新发布我的答案,因为我认为它增加了一些价值。

如果更新依赖于在状态中找到的先前值,则应使用函数形式。如果你在这种情况下不使用函数式形式,那么你的代码会在某个时候中断。

为什么会坏,什么时候坏

React 功能组件只是闭包,闭包中的状态值可能已经过时 - 这意味着闭包中的值与该组件的 React 状态值不匹配,这可能发生在以下情况下:

1- 异步操作(在此示例中,单击慢添加,然后在添加按钮上多次单击,稍后您会看到单击慢添加按钮时状态已重置为闭包内的状态)

const App = () => {
  const [counter, setCounter] = useState(0);

  return (
    <>
      <p>counter {counter} </p>
      <button
        onClick={() => {
          setCounter(counter + 1);
        }}
      >
        immediately add
      </button>
      <button
        onClick={() => {
          setTimeout(() => setCounter(counter + 1), 1000);
        }}
      >
        Add
      </button>
    </>
  );
};

2- 当您在同一闭包中多次调用更新函数时

const App = () => {
  const [counter, setCounter] = useState(0);

  return (
    <>
      <p>counter {counter} </p>
      <button
        onClick={() => {
          setCounter(counter + 1);
          setCounter(counter + 1);
        }}
      >
        Add twice
      </button>
   
    </>
  );
}
18赞 Vanessa Phipps 7/26/2022 #5

“状态更新在 React 中是异步的”答案具有误导性,下面的一些评论也是如此。在我进一步深入研究之前,我的想法也是错误的。你是对的,这很少需要。

功能状态更新背后的关键思想是,新状态所依赖的状态可能已过时。状态是如何过时的?让我们消除一些关于它的神话:

  • 神话:在事件处理期间,可以在您的领导下更改状态。
    • 事实:ECMAScript 事件循环一次只运行一件事。如果正在运行处理程序,则没有其他任何处理程序与它一起运行。
  • 神话:快速单击两次(或任何其他快速发生的用户操作)可能会导致第二个处理程序以过时状态运行。
    • 事实:React 保证不会跨多个用户发起的事件进行批量更新。即使在 React 18 中也是如此,它比以前的版本做了更多的批处理。您可以依赖于在事件处理程序之间进行渲染。

来自 React 工作组

注意:React 只在通常安全的情况下批量更新。例如,React 确保对于每个用户发起的事件(如点击或按键),DOM 在下一个事件之前完全更新。例如,这确保了在提交时禁用的表单不能提交两次。

这是如何工作的?2 次快速点击 = 2 次运行相同的处理程序,对吧?

@Ich评论中提出了一个很好的问题。即使您在处理一个事件和下一个事件之间重新渲染,它们不是都使用引用相同状态的相同处理程序吗,因为它们源自同一个渲染?

这涉及到他们不经常谈论的 React 事件处理的一个细节。它在 React 17 发行说明中有所提及。

在 React 17 中,事件处理程序不再在 上注册,而是在根节点上注册。我记得读到这篇文章时感到困惑。为什么事件处理程序首先出现在为什么当我们编写 时,处理程序不在 DOMElement 上?documentdocument<button onClick="...">button

答案似乎是(除其他外)React 无法立即安全地运行处理程序。事实上,它不一定知道要运行哪个处理程序(如果有的话)!romain-trotard 的这个要点进入了实现细节。

长话短说

无论你的树中有多少个 s,React 都只在树的顶部注册一个处理程序。而这个处理程序只是一个填充码,它将事件调度到 React 中的队列中。onClickclick

这样 React 就可以按照自己的节奏处理事件。每个渲染最多只能处理 1 个用户发起的事件。队列中的其他人必须等待下一次渲染,或者之后的渲染,等等。这样,他们每个人都会得到一个新的处理程序。

假设您有一个递增状态的按钮。你有一个涡轮鼠标,每 1 毫秒点击一次,在下一次渲染之前你敲击该按钮 10 次。

React 在其事件队列中放置了 10 个点击事件。它会取消其中一个排队并处理它,从而导致重新渲染。

重新渲染完成后,React 会注意到队列不是空的,并将下一个队列排出队列并使用使用新状态的新处理程序进行处理。它再次重新渲染。

重复此操作,直到队列为空。最后,有 10 个渲染和 10 个不同的处理程序,具有不同的状态闭包。

好的,React 非常努力地防止过时状态。那么,你什么时候会变得陈旧呢?

以下是我能想到的主要 3 种情况:

同一处理程序中的多个状态更新

这是已经提到的情况,即在同一处理程序中多次设置相同的状态,并依赖于以前的状态。正如你所指出的,这个案例是相当人为的,因为这显然是错误的:

  <button
    onClick={() => {
      setCount(count + 1);
      setCount(count + 1);
    }}
  >+</button>

一个更合理的情况是调用多个函数,每个函数都在同一状态上进行更新,并依赖于前一个状态。但这仍然很奇怪,进行所有计算然后设置一次状态会更有意义。

处理程序中的异步状态更新

例如:

  <button
    onClick={() => {
      doSomeApiCall().then(() => setCount(count + 1));
    }}
  >+</button>

这并不是那么明显的错误。在调用和解决之间可以更改状态。在这种情况下,状态更新确实是异步的,但你是这样做的,而不是 React!doSomeApiCall

函数形式解决了这个问题:

  <button
    onClick={() => {
      doSomeApiCall().then(() => setCount((currCount) => currCount + 1));
    }}
  >+</button>

在 useEffect 中更新状态

G Gallegos 的回答一般指出了这一点,而 letvar 的回答指出了这一点。如果您要根据 中的先前状态更新状态,则将该状态放在依赖项数组中(或不使用依赖项数组)是无限循环的秘诀。请改用函数式窗体。useEffectuseEffectrequestAnimationFrameuseEffect

总结

基于先前状态的状态更新不需要功能窗体,只要您这样做即可 1.在用户触发的事件处理程序 2 中。每个处理程序每个状态一次,3.同步。如果违反其中任何一个条件,则需要功能更新。

有些人可能更喜欢始终使用功能更新,因此您不必担心这些情况。在安全的情况下,其他人可能更喜欢较短的形式来清楚起见,这对许多处理程序来说都是如此。在这一点上,它是个人偏好/代码风格。

历史笔记

我在 Hooks 之前学习了 React,当时只有类组件有状态。在类组件中,“同一处理程序中的多个状态更新”看起来并不那么明显:

  <button
    onClick={() => {
      this.setState({ count: this.state.count + 1 });
      this.setState({ count: this.state.count + 1 });
    }}
  >+</button>

由于 state 是实例变量而不是函数参数,因此这看起来没问题,除非您知道在同一个处理程序中进行批处理调用。setState

事实上,在 React <= 17 中,这可以正常工作:

  setTimeout(() => {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
  }, 1000);

由于它不是事件处理程序,因此 React 会在每次调用后重新渲染。setState

React 18 针对这种情况和类似情况引入了批处理。这是一项有用的性能改进。缺点是它破坏了依赖于上述行为的类组件。

引用

评论

0赞 lch 8/2/2023
我仍然不太明白,因为,第一次点击进行状态更改,因为反应状态更新是异步的,假设它在“提交”之前保持了 100 毫秒,如果我的第二次点击足够快并进行第二次状态更改,并且此更改仍然是指过时的(因为它尚未更新)<button onClick={() => setCount(count + 1)}>+</button>counter
0赞 Vanessa Phipps 10/15/2023
@Ich React 保证这不会发生。根据工作组的说法,您的场景是这样的:在 0 毫秒时单击,处理开始,在 50 毫秒时单击,第一次单击的状态更改导致在 100 毫秒时重新渲染,现在处理第二次单击(100+ 毫秒)。也就是说,他们煞费苦心地确保在开始处理第二次点击之前,第一次点击更改的所有内容都会重新呈现。换句话说,它们保证在处理下一个用户启动的事件之前同步状态。这些时间也很长,大多数渲染都是 <20 毫秒。
0赞 Vanessa Phipps 11/28/2023
@Ich我用更全面的解释编辑了这篇文章。必须查找一些东西才能弄清楚完整的答案是什么。问得好!