useState的set方法不会立即反映出更改

819

我正在尝试学习Hooks,useState方法让我感到困惑。我正在以数组的形式为状态分配初始值。但是使用useState中的set方法对我来说不起作用,无论有无使用扩展语法。

我在另一台计算机上创建了一个API,我正在调用并获取我想要设置到状态中的数据。

这是我的代码:

<div id="root"></div>

<script type="text/babel" defer>
// import React, { useState, useEffect } from "react";
// import ReactDOM from "react-dom";
const { useState, useEffect } = React; // web-browser variant

const StateSelector = () => {
  const initialValue = [
    {
      category: "",
      photo: "",
      description: "",
      id: 0,
      name: "",
      rating: 0
    }
  ];

  const [movies, setMovies] = useState(initialValue);

  useEffect(() => {
    (async function() {
      try {
        // const response = await fetch("http://192.168.1.164:5000/movies/display");
        // const json = await response.json();
        // const result = json.data.result;
        const result = [
          {
            category: "cat1",
            description: "desc1",
            id: "1546514491119",
            name: "randomname2",
            photo: null,
            rating: "3"
          },
          {
            category: "cat2",
            description: "desc1",
            id: "1546837819818",
            name: "randomname1",
            rating: "5"
          }
        ];
        console.log("result =", result);
        setMovies(result);
        console.log("movies =", movies);
      } catch (e) {
        console.error(e);
      }
    })();
  }, []);

  return <p>hello</p>;
};

const rootElement = document.getElementById("root");
ReactDOM.render(<StateSelector />, rootElement);
</script>

<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>

setMovies(result)setMovies(...result)都无法正常工作。

我期望result变量被推入movies数组中。


你能看到将 console.log("movies =", movies); 移到 useEffect 钩子之外的更改吗? - Domenico Ruggiano
19个回答

908
和继承 React.ComponentReact.PureComponent 创建的类组件中使用的 .setState() 相似, 使用 useState 钩子提供的更新器进行状态更新也是异步的,并且不会立即反映出来。
此外,主要问题不仅在于异步性质,而在于基于当前闭包使用状态值的函数以及状态更新将在下一次重新渲染时反映出来,现有闭包不受影响,但会创建新的闭包。 现在,在当前状态下,钩子中的值是通过现有闭包获得的,当重新渲染时,闭包会根据函数是否再次创建而进行更新。
即使您向该函数添加一个 setTimeout, 它虽然会在一段时间后运行,但定时器仍将使用其先前闭包中的值而不是更新后的值。
setMovies(result);
console.log(movies) // movies here will not be updated

如果你想在状态更新时执行某个操作,你需要使用{{useEffect}}钩子,就像在类组件中使用{{componentDidUpdate}}一样,因为由{{useState}}返回的setter没有回调模式。
useEffect(() => {
    // action on update of movies
}, [movies]);

就状态更新的语法而言,setMovies(result)将用异步请求中可用的值替换状态中先前的movies值。
然而,如果您想将响应与先前存在的值合并,您必须使用状态更新的回调语法以及正确使用展开语法,例如{{spread syntax}}。
setMovies(prevMovies => ([...prevMovies, ...result]));

88
你好,关于在表单提交处理程序中调用useState怎么样?我正在验证一个复杂的表单,我在submitHandler内调用了useState hook,但遗憾的是更改并不会立即生效! - da45
5
然而,useEffect可能并不是最好的解决方案,因为它不支持异步调用。因此,如果我们想在movies状态更改时进行一些异步验证,我们就无法控制它。 - RA.
7
请注意,虽然建议非常好,但是原因的解释可以改进——这与“useState钩子提供的更新程序”是否异步无关,不像如果“this.setState”是同步的,可能会更改“this.state”的值;即使“useState”提供的是同步函数,围绕“const movies”的闭包仍将保持不变——请参见我的答案中的示例。 - Aprillion
5
setMovies(prevMovies => ([...prevMovies, ...result])); 对我起作用了。 - Mihir
21
记录错误结果是由于您记录了一个 陈旧的闭包,而不是因为setter是异步的。如果异步是问题所在,那么您可以在延迟后记录日志,但是即使您设置超时时间为一小时,仍然可能记录错误结果,因为异步不是导致问题的原因。 - HMR
显示剩余15条评论

527

previous answer的补充细节:

虽然React的setState是异步的(类和钩子都是),而且很容易利用这一事实来解释观察到的行为,但这不是它发生的原因

简而言之:原因是一个closure作用域包围了一个不可变的const值。


解决方案:

  • read the value in render function (not inside nested functions):

      useEffect(() => { setMovies(result) }, [])
      console.log(movies)
    
  • add the variable into dependencies (and use the react-hooks/exhaustive-deps eslint rule):

      useEffect(() => { setMovies(result) }, [])
      useEffect(() => { console.log(movies) }, [movies])
    
  • use a temporary variable:

      useEffect(() => {
        const newMovies = result
        console.log(newMovies)
        setMovies(newMovies)
      }, [])
    
  • use a mutable reference (if we don't need a state and only want to remember the value - updating a ref doesn't trigger re-render):

      const moviesRef = useRef(initialValue)
      useEffect(() => {
        moviesRef.current = result
        console.log(moviesRef.current)
      }, [])
    

为什么会发生这种情况的解释:

如果只是异步操作的原因,那么可以使用await setState()

然而,propsstate都被假定在1次渲染期间不变

this.state看作是不可变的。

使用hooks时,通过使用const关键字和常量值加强了这种假设:

const [state, setState] = useState('initial')

这个值在两次渲染之间可能不同,但在渲染本身和任何闭包(即使渲染结束后仍然存在的函数,例如useEffect、事件处理程序,在任何Promise或setTimeout内部)中保持不变。

考虑以下虚假但同步的类似React的实现:

// sync implementation:

let internalState
let renderAgain

const setState = (updateFn) => {
  internalState = updateFn(internalState)
  renderAgain()
}

const useState = (defaultState) => {
  if (!internalState) {
    internalState = defaultState
  }
  return [internalState, setState]
}

const render = (component, node) => {
  const {html, handleClick} = component()
  node.innerHTML = html
  renderAgain = () => render(component, node)
  return handleClick
}

// test:

const MyComponent = () => {
  const [x, setX] = useState(1)
  console.log('in render:', x) // ✅
  
  const handleClick = () => {
    setX(current => current + 1)
    console.log('in handler/effect/Promise/setTimeout:', x) // ❌ NOT updated
  }
  
  return {
    html: `<button>${x}</button>`,
    handleClick
  }
}

const triggerClick = render(MyComponent, document.getElementById('root'))
triggerClick()
triggerClick()
triggerClick()
<div id="root"></div>


2
实际上,我刚刚完成了一次使用useReducer的重写,遵循@kentcdobs的文章(参考下面链接),这真的给了我一个非常稳定的结果,不会受到这些闭包问题的影响。(参考链接:https://kentcdodds.com/blog/how-to-use-react-context-effectively) - Al Joslin
1
@ACV解决方案2对于原始问题运行良好。如果您需要解决不同的问题,则可能会有所不同,但我仍然100%确定引用的代码按照文档编写,并且问题出在其他地方。 - Aprillion
所有这些解决方案都需要使用useEffect。我的问题是,我的“电影”等价物是我从上下文提供程序获取的对象,并且可以被许多其他组件更改。我不想在每次更改时运行effect,因为我的effect不是setMovies - 它是一个不同的函数,我只需要在对电影进行特定更改时调用它 - 由于过期的上下文而在需要时看不到我需要的更改。 - Matt Frei
@MattFrei useEffect仅用于说明在渲染后设置状态并且状态值从上一次渲染中记住,它并不意味着建议实际从useEffect调用setState...其余的解释应该适用于Context提供程序中的useState,但如果需要额外深入,请随时指向新的SO问题。 - Aprillion
是的!这就是我从源代码中看到的,seState 的行为类似于异步,但确切地说是同步的。 - fawinell
显示剩余6条评论

29
我知道已经有很好的答案了。但是我想提供另一个解决相同问题的方法,并且使用我的模块react-useStateRef来访问最新的“电影”状态,它每周下载量超过11,000次。
通过使用React状态,您可以在状态改变时重新渲染页面。但是通过使用React引用,您始终可以获取最新的值。
所以模块react-useStateRef允许您同时使用状态和引用。它与React.useState向后兼容,因此您只需替换import语句即可。
const { useEffect } = React
import { useState } from 'react-usestateref'

  const [movies, setMovies] = useState(initialValue);

  useEffect(() => {
    (async function() {
      try {

        const result = [
          {
            id: "1546514491119",
          },
        ];
        console.log("result =", result);
        setMovies(result);
        console.log("movies =", movies.current); // will give you the latest results
      } catch (e) {
        console.error(e);
      }
    })();
  }, []);

更多信息:


13

React的useEffect具有自己的状态/生命周期。它与状态的变化相关,并且在销毁effect之前不会更新状态。

只需在参数中传递单个参数state或将其留空数组,它就可以完美工作。

React.useEffect(() => {
    console.log("effect");
    (async () => {
        try {
            let result = await fetch("/query/countries");
            const res = await result.json();
            let result1 = await fetch("/query/projects");
            const res1 = await result1.json();
            let result11 = await fetch("/query/regions");
            const res11 = await result11.json();
            setData({
                countries: res,
                projects: res1,
                regions: res11
            });
        } catch {}
    })(data)
}, [setData])
# or use this
useEffect(() => {
    (async () => {
        try {
            await Promise.all([
                fetch("/query/countries").then((response) => response.json()),
                fetch("/query/projects").then((response) => response.json()),
                fetch("/query/regions").then((response) => response.json())
            ]).then(([country, project, region]) => {
                // console.log(country, project, region);
                setData({
                    countries: country,
                    projects: project,
                    regions: region
                });
            })
        } catch {
            console.log("data fetch error")
        }
    })()
}, [setData]);

另外,您可以尝试使用React.useRef()来实现React hook的即时更改。

const movies = React.useRef(null);
useEffect(() => {
movies.current='values';
console.log(movies.current)
}, [])

最后一个代码示例既不需要 async 也不需要 await,因为您使用了 Promise API。这只在第一个示例中需要。 - oligofren

13

我刚用 useReducer 重新编写了代码,按照 Kent C. Dodds 的文章(下面有参考链接)操作,这让我得到了一个稳固的结果,完全不受闭包问题的影响。

请见:https://kentcdodds.com/blog/how-to-use-react-context-effectively

我将他易读的样板代码压缩到了我喜欢的 DRY 程度——读一下他在沙箱实现中的代码将让你明白它是如何工作的。

import React from 'react'

// ref: https://kentcdodds.com/blog/how-to-use-react-context-effectively

const ApplicationDispatch = React.createContext()
const ApplicationContext = React.createContext()

function stateReducer(state, action) {
  if (state.hasOwnProperty(action.type)) {
    return { ...state, [action.type]: state[action.type] = action.newValue };
  }
  throw new Error(`Unhandled action type: ${action.type}`);
}

const initialState = {
  keyCode: '',
  testCode: '',
  testMode: false,
  phoneNumber: '',
  resultCode: null,
  mobileInfo: '',
  configName: '',
  appConfig: {},
};

function DispatchProvider({ children }) {
  const [state, dispatch] = React.useReducer(stateReducer, initialState);
  return (
    <ApplicationDispatch.Provider value={dispatch}>
      <ApplicationContext.Provider value={state}>
        {children}
      </ApplicationContext.Provider>
    </ApplicationDispatch.Provider>
  )
}

function useDispatchable(stateName) {
  const context = React.useContext(ApplicationContext);
  const dispatch = React.useContext(ApplicationDispatch);
  return [context[stateName], newValue => dispatch({ type: stateName, newValue })];
}

function useKeyCode() { return useDispatchable('keyCode'); }
function useTestCode() { return useDispatchable('testCode'); }
function useTestMode() { return useDispatchable('testMode'); }
function usePhoneNumber() { return useDispatchable('phoneNumber'); }
function useResultCode() { return useDispatchable('resultCode'); }
function useMobileInfo() { return useDispatchable('mobileInfo'); }
function useConfigName() { return useDispatchable('configName'); }
function useAppConfig() { return useDispatchable('appConfig'); }

export {
  DispatchProvider,
  useKeyCode,
  useTestCode,
  useTestMode,
  usePhoneNumber,
  useResultCode,
  useMobileInfo,
  useConfigName,
  useAppConfig,
}

使用方式类似于这样:

import { useHistory } from "react-router-dom";

// https://react-bootstrap.github.io/components/alerts
import { Container, Row } from 'react-bootstrap';

import { useAppConfig, useKeyCode, usePhoneNumber } from '../../ApplicationDispatchProvider';

import { ControlSet } from '../../components/control-set';
import { keypadClass } from '../../utils/style-utils';
import { MaskedEntry } from '../../components/masked-entry';
import { Messaging } from '../../components/messaging';
import { SimpleKeypad, HandleKeyPress, ALT_ID } from '../../components/simple-keypad';

export const AltIdPage = () => {
  const history = useHistory();
  const [keyCode, setKeyCode] = useKeyCode();
  const [phoneNumber, setPhoneNumber] = usePhoneNumber();
  const [appConfig, setAppConfig] = useAppConfig();

  const keyPressed = btn => {
    const maxLen = appConfig.phoneNumberEntry.entryLen;
    const newValue = HandleKeyPress(btn, phoneNumber).slice(0, maxLen);
    setPhoneNumber(newValue);
  }

  const doSubmit = () => {
    history.push('s');
  }

  const disableBtns = phoneNumber.length < appConfig.phoneNumberEntry.entryLen;

  return (
    <Container fluid className="text-center">
      <Row>
        <Messaging {...{ msgColors: appConfig.pageColors, msgLines: appConfig.entryMsgs.altIdMsgs }} />
      </Row>
      <Row>
        <MaskedEntry {...{ ...appConfig.phoneNumberEntry, entryColors: appConfig.pageColors, entryLine: phoneNumber }} />
      </Row>
      <Row>
        <SimpleKeypad {...{ keyboardName: ALT_ID, themeName: appConfig.keyTheme, keyPressed, styleClass: keypadClass }} />
      </Row>
      <Row>
        <ControlSet {...{ btnColors: appConfig.buttonColors, disabled: disableBtns, btns: [{ text: 'Submit', click: doSubmit }] }} />
      </Row>
    </Container>
  );
};

AltIdPage.propTypes = {};

现在我的所有页面上,一切都顺利持久化了。


13
在原文环境中,我认为这个答案并没有特别有用。这个答案甚至没有使用 useState(),而这是 OP 所询问的核心问题。 - Martin Carel
2
平滑的解决方案,但并不是发生了什么的答案。 - oligofren

11

闭包并不是唯一的原因。

根据useState的源代码(以下简化),我觉得值从未立即被分配。

发生的情况是,在调用setValue时,更新操作会被排队。只有在下一次渲染时,调度程序启动并且这些更新操作被应用于该状态时,才会发生这些更新操作。

这意味着,即使我们没有闭包问题,React版本的useState也不会立即给您提供新值。新值甚至直到下一次渲染之前都不存在。

  function useState(initialState) {
    let hook;
    ...

    let baseState = hook.memoizedState;
    if (hook.queue.pending) {
      let firstUpdate = hook.queue.pending.next;

      do {
        const action = firstUpdate.action;
        baseState = action(baseState);            // setValue HERE
        firstUpdate = firstUpdate.next;
      } while (firstUpdate !== hook.queue.pending);

      hook.queue.pending = null;
    }
    hook.memoizedState = baseState;

    return [baseState, dispatchAction.bind(null, hook.queue)];
  }

function dispatchAction(queue, action) {
  const update = {
    action,
    next: null
  };
  if (queue.pending === null) {
    update.next = update;
  } else {
    update.next = queue.pending.next;
    queue.pending.next = update;
  }
  queue.pending = update;

  isMount = false;
  workInProgressHook = fiber.memoizedState;
  schedule();
}

还有一篇文章以类似的方式解释了上面的内容,https://dev.to/adamklein/we-don-t-know-how-react-state-hook-works-1lp8


11
我也曾被同样的问题困扰。正如上面其他答案所澄清的错误是,useState 是异步的,并且你试图在 setState 后立即使用值。由于 setState 异步的本质,它允许进一步执行代码,而值更新发生在后台。因此,你得到的是以前的值。当后台完成 setState 后,它将更新该值,然后你可以在下一次渲染时访问该值。
如果有人想深入了解这个话题,以下是一个非常好的会议演讲。 https://www.youtube.com/watch?v=8aGhZQkoFbQ

4
clear & simple thank you! - hakima maarouf

8
大部分的回答都是关于如何根据先前的值来更新状态,但我不明白这与问题有什么关系。

useState 的 set 方法并不立即反映出变化。


React 18

useState是异步的:

当触发某个代码的事件发生时,该代码开始运行,当它完成后,React会检查是否有状态更新,如果是这种情况,只有在这种情况下useState钩子的值才会被更新,从而导致新的渲染,其中新的值可用。

const [example,setExemple] = useState("")
//...
<button
  onClick={() => {
    const newValue = "new";
    setExample(newValue);
    console.log(example); // output "" and this is normal, because the component didn't rerenderd yet so the new value is not availabe yet
  }}
>
  Update state
</button>

假设我们有这样一种情况,其中一个状态依赖于另一个状态,例如,每当更新example的新值时,我们希望基于此进行API调用,并将响应数据存储在另一个状态anotherExample中。
为了实现这个目标,我们有两种方法:

1. 使用newValue的值:

<button
  onClick={async () => {
    const newValue = "new";
    const response = await axios.get(`http://127.0.0.1:5000/${newValue}`);
    setExample(newValue);
    setAnotherExample(response.data);
  }}
>
  test
</button>

既然你知道example将接收到这个值,你可以直接基于它创建你的逻辑。

2. 每当example被更新时,通过在依赖数组中包含example来触发一个useEffect运行:

<button
  onClick={() => {
    const newValue = "new";
    setExample(newValue);
  }}
>
  test
</button>

useEffect(() => {
 async function test(){
  const response = await axios.get(`http://127.0.0.1:5000/${example}`);
  setAnotherExample(response.data);
 } 
 test();
}, [example])

当使用事件函数更新`example`时,组件会重新渲染,我们现在处于一个新的不同的渲染状态。一旦完成,`useEffect`将运行,因为`example`的值与上次渲染时的值不同。由于是一个新的不同的渲染,`example`的新值在这里是可用的。
注意:`useEffect`钩子在首次挂载时无论如何都会运行。
哪种方法更好?
第一种方法会在一个渲染中完成所有工作(更好的方法)。"React将多个状态更新合并为单个重新渲染,以提高性能"。而第二种方法则需要两次渲染,第一次是在更新`example`时,第二次是在`useEffect`内部更新`anotherExample`时。
由于组件只有在`useState`钩子的新值与旧值不同时才重新渲染,所以当`newValue`等于`example`时,组件不会重新渲染,因此`useEffect`不会运行,`anotherExample`也不会被更新(更好的方法)。然而,在第一种方法中,API仍然会被调用,如果没有必要,我们不希望这样做。此外,如果发生这种情况,`anotherExample`将被更新(`anotherExample`将接收与其已包含的相同数据,因为它是相同的请求,`newValue`等于`example`),但如果响应是对象或数组,则`Object.is`方法(`useState`钩子使用的方法)无法检测新值是否等于先前的值,因此组件将重新渲染。

结论:

正如上面所提到的,每种方法都有其优势,因此取决于使用情况。

第二种方法更推荐,但在某些情况下,第一种方法可能更高效,例如当您确定代码仅在newValue通过onChange获得新值时运行,或者当您想要使用一些其他本地变量,而这些变量将无法从useEffect内部访问。


1
useState 从一开始就是异步的,它与 ReactJs 18 版本无关。 - AmerllicA
我没有说React 18中有一个新功能。 - Ahmed Sbai
那你为什么提到Reactjs的版本呢?这让我感到困惑。 - AmerllicA
2
因为这个答案是在React 18发布的最新版本时发布的,也许以后就不再有效了,谁知道呢。 - Ahmed Sbai

6
在React中,useState钩子返回的setState函数不会立即更新状态。相反,它会安排状态更新在下一个渲染周期中进行处理。这是因为React为了提高性能而批处理状态更新。
如果您尝试在调用setState后立即访问更新后的状态,则可能无法立即看到更新后的值。相反,您可以使用useEffect钩子在状态更新后执行操作。
以下是一个示例,演示如何使用useEffect在状态更新后执行操作。
 import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    // This effect will run after each state update
    console.log('Count has been updated:', count);
  }, [count]);

  const incrementCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementCount}>Increment</button>
    </div>
  );
};

在上面的例子中,useEffect hook 用于在每次状态更新后记录更新后的计数值。通过将[count]作为依赖数组传递给useEffect,当count状态发生变化时,该效果仅会运行一次。

4
我可以翻译为中文。以下是您需要翻译的内容:

我认为这很不错。与其像第一种方法一样定义状态(例如),

const initialValue = 1;

const [state,setState] = useState(initialValue)

尝试这种方法(方法2),

const [state = initialValue,setState] = useState()

在这种情况下,由于我们不关心useEffect的内部闭包方法,因此可以通过这种方式解决重新渲染问题。

附注:如果您担心在任何用例中使用旧状态,则需要使用useState和useEffect,因为它需要该状态,因此在这种情况下应使用方法1。


2
这个答案没有用。关于重新渲染和捕获闭包值,这种方法并没有丝毫的区别。它唯一会产生区别的情况是当状态值被故意设置为 undefined 时,在这种情况下,你将再次获得 initialValue。这只是一个令人困惑的做法,因为你可以直接将其设置为初始值而不需要额外的步骤。 - Martin
方法2会污染全局空间。正如所提到的,方法1在最好的情况下是反模式。 - ggorlen

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