从组件中的 useState 多次调用 state updater 会导致多次重新渲染

Posted

技术标签:

【中文标题】从组件中的 useState 多次调用 state updater 会导致多次重新渲染【英文标题】:Multiple calls to state updater from useState in component causes multiple re-renders 【发布时间】:2019-05-03 14:48:59 【问题描述】:

我第一次尝试 React 钩子,一切似乎都很好,直到我意识到当我获取数据并更新两个不同的状态变量(数据和加载标志)时,我的组件(数据表)被渲染了两次,甚至尽管对状态更新程序的两次调用都发生在同一个函数中。这是我的 api 函数,它将两个变量都返回给我的组件。

const getData = url => 

    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(async () => 

        const test = await api.get('/people')

        if(test.ok)
            setLoading(false);
            setData(test.data.results);
        

    , []);

    return  data, loading ;
;

在普通类组件中,您只需调用一次即可更新可能是复杂对象的状态,但“挂钩方式”似乎是将状态拆分为更小的单元,其副作用似乎是单独更新时多次重新渲染。有什么办法可以缓解这种情况吗?

【问题讨论】:

如果你有依赖状态,你应该使用useReducer 哇!我只是刚刚发现了这一点,它完全颠覆了我对反应渲染如何工作的理解。我无法理解以这种方式工作的任何优势 - 异步回调中的行为与普通事件处理程序中的行为不同似乎相当随意。顺便说一句,在我的测试中,似乎只有在处理完所有 setState 调用之后才会进行协调(即更新真实 DOM),因此无论如何都浪费了中间渲染调用。 “异步回调中的行为与普通事件处理程序中的行为不同,这似乎相当随意” - 这不是随意的,而是通过实现 [1]。 React 批处理在 React 事件处理程序期间完成的所有 setState 调用,并在退出其自己的浏览器事件处理程序之前应用它们。但是,事件处理程序之外的几个 setState(例如在网络响应中)将不会被批处理。所以在这种情况下你会得到两次重新渲染。 [1]github.com/facebook/react/issues/10231#issuecomment-316644950 '但“钩子方式”似乎是将状态拆分为更小的单元'——这有点误导,因为只有在调用 setX 函数时才会发生多次重新渲染在异步回调中。来源:github.com/facebook/react/issues/14259#issuecomment-439632622、blog.logrocket.com/… 【参考方案1】:

您可以将loading 状态和data 状态组合成一个状态对象,然后您可以进行一次setState 调用,并且只会进行一次渲染。

注意:与类组件中的setState 不同,从useState 返回的setState 不会将对象与现有状态合并,它会完全替换对象。如果要进行合并,则需要读取先前的状态并将其与新值合并。参考docs。

在您确定存在性能问题之前,我不会过多担心过度调用渲染。渲染(在 React 上下文中)和将虚拟 DOM 更新提交到真实 DOM 是不同的事情。这里的渲染是指生成虚拟 DOM,而不是更新浏览器 DOM。 React 可能会批处理 setState 调用并使用最终的新状态更新浏览器 DOM。

const useState, useEffect = React;

function App() 
  const [userRequest, setUserRequest] = useState(
    loading: false,
    user: null,
  );

  useEffect(() => 
    // Note that this replaces the entire object and deletes user key!
    setUserRequest( loading: true );
    fetch('https://randomuser.me/api/')
      .then(results => results.json())
      .then(data => 
        setUserRequest(
          loading: false,
          user: data.results[0],
        );
      );
  , []);

  const  loading, user  = userRequest;

  return (
    <div>
      loading && 'Loading...'
      user && user.name.first
    </div>
  );


ReactDOM.render(<App />, 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>

替代方案 - 编写你自己的状态合并钩子

const useState, useEffect = React;

function useMergeState(initialState) 
  const [state, setState] = useState(initialState);
  const setMergedState = newState => 
    setState(prevState => Object.assign(, prevState, newState)
  );
  return [state, setMergedState];


function App() 
  const [userRequest, setUserRequest] = useMergeState(
    loading: false,
    user: null,
  );

  useEffect(() => 
    setUserRequest( loading: true );
    fetch('https://randomuser.me/api/')
      .then(results => results.json())
      .then(data => 
        setUserRequest(
          loading: false,
          user: data.results[0],
        );
      );
  , []);

  const  loading, user  = userRequest;

  return (
    <div>
      loading && 'Loading...'
      user && user.name.first
    </div>
  );


ReactDOM.render(<App />, 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>

【讨论】:

我同意这并不理想,但它仍然是一种合理的做法。如果您不想自己进行合并,您可以编写自己的自定义钩子来进行合并。我更新了最后一句话,使其更清晰。你熟悉 React 的工作原理吗?如果没有,这里有一些链接可以帮助您更好地理解 - reactjs.org/docs/reconciliation.html、blog.vjeux.com/2013/javascript/react-performance.html。 @jonhobbs 我添加了一个替代答案,它创建了一个 useMergeState 钩子,以便您可以自动合并状态。 太棒了,再次感谢。我越来越熟悉使用 React,但可以学到很多关于它的内部结构。我会读一读。 好奇在什么情况下这可能是真的?:“React 可能会批处理 setState 调用并使用最终的新状态更新浏览器 DOM。 很好..我考虑过组合状态,或者使用单独的有效负载状态,并从有效负载对象中读取其他状态。不过很好的帖子。 10/10 将再次阅读【参考方案2】:

react-hooks 中的批量更新 https://github.com/facebook/react/issues/14259

如果从基于 React 的事件中触发状态更新,例如按钮单击或输入更改,React 当前将批量更新状态。如果它们是在 React 事件处理程序之外触发的,它不会批量更新,比如异步调用。

【讨论】:

这是一个很有帮助的答案,因为它可能在大多数情况下解决了问题,而无需执行任何操作。谢谢! 更新:从 React 18(选择加入功能)开始,所有更新都将自动批处理,无论它们来自何处。 github.com/reactwg/react-18/discussions/21【参考方案3】:

补充一点回答https://***.com/a/53575023/121143

酷!对于那些打算使用这个钩子的人来说,它可以用一种更健壮的方式来编写,以使用函数作为参数,例如:

const useMergedState = initial => 
  const [state, setState] = React.useState(initial);
  const setMergedState = newState =>
    typeof newState == "function"
      ? setState(prevState => ( ...prevState, ...newState(prevState) ))
      : setState(prevState => ( ...prevState, ...newState ));
  return [state, setMergedState];
;

更新:优化版本,当传入的部分状态没有改变时,状态不会被修改。

const shallowPartialCompare = (obj, partialObj) =>
  Object.keys(partialObj).every(
    key =>
      obj.hasOwnProperty(key) &&
      obj[key] === partialObj[key]
  );

const useMergedState = initial => 
  const [state, setState] = React.useState(initial);
  const setMergedState = newIncomingState =>
    setState(prevState => 
      const newState =
        typeof newIncomingState == "function"
          ? newIncomingState(prevState)
          : newIncomingState;
      return shallowPartialCompare(prevState, newState)
        ? prevState
        :  ...prevState, ...newState ;
    );
  return [state, setMergedState];
;

【讨论】:

酷!我只会用useMemo(() =&gt; setMergedState, []) 包装setMergedState,因为正如文档所说,setState 在重新渲染之间不会改变:React guarantees that setState function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.,这样 setState 函数就不会在重新渲染时重新创建。跨度> 【参考方案4】:

这也有另一个使用useReducer的解决方案!首先我们定义新的setState

const [state, setState] = useReducer(
  (state, newState) => (...state, ...newState),
  loading: true, data: null, something: ''
)

之后我们可以像以前的 this.setState 一样简单地使用它,只是没有 this

setState(loading: false, data: test.data.results)

正如您在我们的新setState 中可能注意到的那样(就像我们之前对this.setState 所做的一样),我们不需要一起更新所有状态!例如我可以像这样改变我们的一个状态(它不会改变其他状态!):

setState(loading: false)

太棒了,哈?!

所以让我们把所有的部分放在一起:

import useReducer from 'react'

const getData = url => 
  const [state, setState] = useReducer(
    (state, newState) => (...state, ...newState),
    loading: true, data: null
  )

  useEffect(async () => 
    const test = await api.get('/people')
    if(test.ok)
      setState(loading: false, data: test.data.results)
    
  , [])

  return state


Typescript 支持。 感谢P. Galbraith 回复了这个解决方案, 那些使用打字稿的人可以使用这个:

useReducer<Reducer<MyState, Partial<MyState>>>(...)

其中MyState 是您的状态对象的类型。

例如在我们的例子中,它会是这样的:

interface MyState 
   loading: boolean;
   data: any;
   something: string;


const [state, setState] = useReducer<Reducer<MyState, Partial<MyState>>>(
  (state, newState) => (...state, ...newState),
  loading: true, data: null, something: ''
)

以前的州支持。 在 cmets 中,user2420374 要求有一种方法可以访问我们的 setState 中的 prevState,所以这里有一个实现这个目标的方法:

const [state, setState] = useReducer(
    (state, newState) => 
        newWithPrevState = isFunction(newState) ? newState(state) : newState
        return (
            ...state, ...newWithPrevState
        )
     ,
     initialState
)

// And then use it like this...
setState(prevState => ...)

isFunction 检查传递的参数是函数(这意味着您正在尝试访问 prevState)还是普通对象。你可以找到this implementation of isFunction by Alex Grande here。


通知。对于那些想经常使用这个答案的人,我决定把它变成一个图书馆。你可以在这里找到它:

Github:https://github.com/thevahidal/react-use-setstate

NPM:https://www.npmjs.com/package/react-use-setstate

【讨论】:

Expected 0 arguments, but got 1.ts(2554) 是我尝试以这种方式使用 useReducer 时收到的错误。更准确地说是setState。你如何解决这个问题?文件是js,但我们还是用ts检查。 我认为结合下面发布的 React 挂钩 (github.com/facebook/react/issues/14259) 中的批处理更新,这是一个很棒的答案。幸运的是,我的用例很简单,我能够简单地从 useState 切换到 useReducer,但在其他情况下(例如 GitHub 问题中列出的一些情况)并不那么容易。 对于那些使用打字稿的人useReducer&lt;Reducer&lt;MyState, Partial&lt;MyState&gt;&gt;&gt;(...) 其中MyState 是您的状态对象的类型。 谢谢伙计,这真的帮助了我。还要感谢 @P.Galbraith 提供的 TypeScript 解决方案 :) 我一直在寻求实现这个解决方案,但是我们如何才能像基于类的组件的 this.setState 函数一样访问以前的状态呢?像这样:this.setState(prevState => )【参考方案5】:

这样就可以了:

const [state, setState] = useState( username: '', password: '');

// later
setState(
    ...state,
    username: 'John'
);

【讨论】:

【参考方案6】:

如果你使用第三方的钩子并且不能将状态合并到一个对象中或者使用useReducer,那么解决方案是使用:

ReactDOM.unstable_batchedUpdates(() =>  ... )

丹·阿布拉莫夫推荐here

看到这个example

【讨论】:

将近一年后,这个功能似乎仍然完全没有文档记录并且仍然被标记为不稳定:-(【参考方案7】:

要从类组件复制this.setState 合并行为, React docs recommend 以使用 useState 的函数形式与对象传播 - no need for useReducer

setState(prevState => 
  return ...prevState, loading, data;
);

这两种状态现在合并为一个,这将为您节省渲染周期。

使用一个状态对象还有另一个优点:loadingdata依赖状态。将状态放在一起时,无效的状态更改会变得更加明显:

setState( loading: true, data ); // ups... loading, but we already set data

您甚至可以通过 1 更好地 ensure consistent states。)在您的state 和 2.) 使用 useReducer 将 state 逻辑封装在 reducer 中:

const useData = () => 
  const [state, dispatch] = useReducer(reducer, /*...*/);

  useEffect(() => 
    api.get('/people').then(test => 
      if (test.ok) dispatch(["success", test.data.results]);
    );
  , []);
;

const reducer = (state, [status, payload]) => 
  if (status === "success") return  ...state, data: payload, status ;
  // keep state consistent, e.g. reset data, if loading
  else if (status === "loading") return  ...state, data: undefined, status ;
  return state;
;

const App = () => 
  const  data, status  = useData();
  return status === "loading" ? <div> Loading... </div> : (
    // success, display data 
  )

const useData = () => 
  const [state, dispatch] = useReducer(reducer, 
    data: undefined,
    status: "loading"
  );

  useEffect(() => 
    fetchData_fakeApi().then(test => 
      if (test.ok) dispatch(["success", test.data.results]);
    );
  , []);

  return state;
;

const reducer = (state, [status, payload]) => 
  if (status === "success") return  ...state, data: payload, status ;
  // e.g. make sure to reset data, when loading.
  else if (status === "loading") return  ...state, data: undefined, status ;
  else return state;
;

const App = () => 
  const  data, status  = useData();
  const count = useRenderCount();
  const countStr = `Re-rendered $count.current times`;

  return status === "loading" ? (
    <div> Loading (3 sec)... countStr </div>
  ) : (
    <div>
      Finished. Data: JSON.stringify(data), countStr
    </div>
  );


//
// helpers
//

const useRenderCount = () => 
  const renderCount = useRef(0);
  useEffect(() => 
    renderCount.current += 1;
  );
  return renderCount;
;

const fetchData_fakeApi = () =>
  new Promise(resolve =>
    setTimeout(() => resolve( ok: true, data:  results: [1, 2, 3]  ), 3000)
  );

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<div id="root"></div>
<script>var  useReducer, useEffect, useState, useRef  = React</script>

PS:确保使用useuseData 而不是getData)对prefix 自定义Hooks。还传递给useEffect 的回调不能是async

【讨论】:

点赞!这是我在 React API 中发现的最有用的特性之一。当我尝试将函数存储为状态时发现它(我知道存储函数很奇怪,但由于某种原因我不记得了)并且由于这个保留的调用签名而没有按预期工作跨度> 【参考方案8】:

您还可以使用useEffect 来检测状态变化,并相应地更新其他状态值

【讨论】:

您能详细说明一下吗?他已经在使用useEffect【参考方案9】:

除了Yangshun Tay 的answer 之外,您最好记住setMergedState 函数,这样每次渲染都会返回相同的引用而不是新函数。如果 TypeScript linter 强制您将 setMergedState 作为父组件中的 useCallbackuseEffect 的依赖项传递,这可能至关重要。

import useCallback, useState from "react";

export const useMergeState = <T>(initialState: T): [T, (newState: Partial<T>) => void] => 
    const [state, setState] = useState(initialState);
    const setMergedState = useCallback((newState: Partial<T>) =>
        setState(prevState => (
            ...prevState,
            ...newState
        )), [setState]);
    return [state, setMergedState];
;

【讨论】:

以上是关于从组件中的 useState 多次调用 state updater 会导致多次重新渲染的主要内容,如果未能解决你的问题,请参考以下文章

react usestate 重新设置数据后,不渲染页面

useState - 回调函数的参数

useState & useReducer

useState hook如何知道react中的调用上下文

多次在 API 调用上以反应原生方式呈现屏幕

useReducer 和 useState