组件中对useState的多次调用导致多次重新渲染

239
我第一次尝试使用React hooks,一切都很好,直到我意识到当我获取数据并更新两个不同的状态变量(数据和加载标志)时,我的组件(数据表)会被渲染两次,尽管状态更新器的两个调用都发生在同一个函数中。这是我的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 };
};
在一个普通的类组件中,您将进行单个调用以更新状态,该状态可以是复杂对象,但“hooks方式”似乎是将状态拆分为较小的单元,其中的副作用似乎是当它们被单独更新时会有多个重新渲染。有什么方法可以减轻这种情况吗?

4
如果你有依赖性的状态,建议使用 useReducer - thinklinux
1
哇!我刚刚发现了这个,它完全颠覆了我对React渲染工作方式的理解。我无法理解以这种方式工作的任何优势——在异步回调中的行为与普通事件处理程序中的行为似乎相当武断。顺便说一句,在我的测试中,似乎只有在所有setState调用都被处理之后,协调(即实际DOM的更新)才会发生,因此中间的渲染调用也是浪费的。 - Andy
2
“在异步回调中的行为与普通事件处理程序中的行为不同,似乎相当武断” - 这并不是武断,而是实现方式[1]。React会批量处理在React事件处理程序期间进行的所有setState调用,并在退出其自己的浏览器事件处理程序之前应用它们。但是,在事件处理程序之外的几个setStates(例如在网络响应中)将不会被批处理。因此,在这种情况下,您将获得两次重新渲染。[1] https://github.com/facebook/react/issues/10231#issuecomment-316644950 - soumyadityac
但是“hooks方式”似乎是将状态分成较小的单元,这有点误导人,因为多个重新渲染仅在异步回调中调用setX函数时发生。来源:https://github.com/facebook/react/issues/14259#issuecomment-439632622,https://blog.logrocket.com/simplifying-state-management-in-react-apps-with-batched-updates/ - allieferr
你可以使用 useReducerredux-toolkit - Aman Ghanghoriya
9个回答

210

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

注意:与类组件中的setState不同,从useState返回的setState不会将对象与现有状态合并,而是完全替换对象。如果要执行合并操作,您需要读取前一个状态并自己合并新值。请参阅文档

在确定存在性能问题之前,不必过于担心频繁地调用渲染函数。在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>


谢谢Yangshun,我猜自己合并状态是答案,但不是理想的。我只是不确定你最后一句话的意思。如果我有一个数据表,并且它用相同的数据呈现两次,那么所有行及其子元素是否都会在DOM中呈现和更新两次?不确定你所说的批处理是什么,但我来自Angular 1.x背景,那里的一切都会一直重新渲染 :) - jonhobbs
2
我同意这不是最理想的方式,但仍然是一个合理的方法。如果你不想自己进行合并,可以编写自己的自定义钩子来执行合并操作。我更新了最后一句话以使其更清晰。你熟悉React的工作原理吗?如果不熟悉,以下链接可能会帮助你更好地理解 - https://reactjs.org/docs/reconciliation.html,http://blog.vjeux.com/2013/javascript/react-performance.html。 - Yangshun Tay
3
@jonhobbs 我添加了一个替代答案,它创建了一个useMergeState hook,以便您可以自动合并状态。 - Yangshun Tay
不错的解决方案,我稍作修改后使用了它,见 https://dev59.com/KVQJ5IYBdhLWcg3wkGy-#55102681 - mschayna
4
想知道这种情况下可能会是真的吗?:“React 可能会对 setState 调用进行批处理,并使用最终的新状态更新浏览器 DOM。” - ecoe
1
不错..我考虑将状态组合起来,或者使用单独的有效载荷状态,并让其他状态从有效载荷对象中读取。非常好的帖子。10/10会再次阅读。 - Anthony Moon Beam Toorie

116

使用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提供的解决方案, 使用Typescript的人可以使用以下代码:

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: ''}
)

之前的状态支持。 在评论中,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)或普通对象。您可以在Alex Grande在此处实现的 isFunction中找到它。


注意。对于那些想要经常使用此答案的人,我决定将其转化为一个库。您可以在此处找到它:

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

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


2
我在使用 useReducer 时遇到了一个错误,具体表现为 Expected 0 arguments, but got 1.ts(2554) 错误。更准确地说是在使用 setState 时出现的错误。你该如何解决这个问题呢?虽然文件是 js 格式的,但我们仍然使用 ts 检查。 - ZenVentzi
3
对于那些使用 TypeScript 的开发者,可以使用 useReducer<Reducer<MyState, Partial<MyState>>>(...) 这样的写法,其中 MyState 是你的状态对象的类型。 - P. Galbraith
1
谢谢伙计,这真的帮了我很多。还要感谢 @P.Galbraith 提供的 TypeScript 解决方案 :) - Johan Jarvi
1
我想实现这个解决方案,但是我们如何像类组件的this.setState函数一样访问先前的状态?就像这样:this.setState(prevState => {}) - user2420374
1
@VahidAl,我最终使用了扩展运算符实现了setState,但从现在开始我会选择这种实现方式,感谢你的更新! - user2420374
显示剩余3条评论

95

使用React Hooks批量更新 https://github.com/facebook/react/issues/14259

如果状态更新触发于React事件内,如按钮点击或输入框改变,React会将它们批量处理。而如果状态更新触发于非React事件处理器内,如异步调用,React则不会批量处理。


9
这个答案非常有帮助,因为它解决了大多数情况下无须采取任何措施即可解决问题的方法。谢谢! - davnicwil
16
更新:从React 18开始(可选功能),所有更新都将自动批处理,无论它们来自何处。https://github.com/reactwg/react-18/discussions/21 - Sureshraj
1
Sureshraj应该成为最受欢迎的答案。如果您的项目允许,只需运行npm install react@beta react-dom@beta(或v18+)。稍微更改一下索引文件(请参见链接),所有问题都将得到解决。您不需要编写任何代码。https://blog.saeloun.com/2021/07/15/react-18-adds-new-root-api.html 此外,与v17相比,v18可以更快地编译您的项目! - Tom

21

这样就可以了:

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

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

12
为了复制类组件中this.setState合并行为,React文档建议使用带对象扩展的useState函数形式,不需要使用useReducer
setState(prevState => {
  return {...prevState, loading, data};
});

现在这两种状态已经合并成一个,这将节省您的渲染周期。
只有一个状态对象还有另一个优点:加载和数据是相互依赖的状态。当状态被整合在一起时,无效的状态变化更加明显。
setState({ loading: true, data }); // ups... loading, but we already set data

你甚至可以通过以下两种方式更好地确保状态一致性:1)在状态中明确指定状态 - loadingsuccesserror等;2)使用useReducer将状态逻辑封装在一个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>

注意:一定要用use前缀来prefix自定义Hooks(例如使用useData而不是getData)。同时,传递给useEffect的回调函数不能是async


1
点赞!这是我在 React API 中发现的最有用的功能之一。当我尝试将一个函数作为状态存储时(我知道存储函数很奇怪,但出于某些原因我不记得了),我发现它并没有按照预期工作,因为这个保留调用签名。 - elquimista

6
如果您正在使用第三方hooks并且无法将状态合并为一个对象或使用useReducer,那么解决方法是使用:
ReactDOM.unstable_batchedUpdates(() => { ... })

这里是 Dan Abramov 推荐的 链接

可以查看这个 示例


将近一年过去了,这个功能似乎仍然完全没有记录,并且仍被标记为不稳定 :-( - Andy

1

这是对https://dev59.com/KVQJ5IYBdhLWcg3wkGy-#53575023答案的补充。

很棒!对于那些计划使用此钩子的人,可以用更加健壮的方式编写它,以便使用函数作为参数,例如:

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(() => setMergedState, [])包装setMergedState,因为文档中说setState在重新渲染之间不会改变:React保证setState函数的身份是稳定的,并且不会在重新渲染时更改。这就是为什么可以安全地从useEffect或useCallback依赖列表中省略它。这样,在重新渲染时不会重新创建setState函数。 - tonix

0
除了Yangshun Tay答案之外,你最好记忆化setMergedState函数,这样它每次渲染时都会返回相同的引用而不是新函数。如果TypeScript linter强制你在父组件的useCallbackuseEffect中将setMergedState作为依赖项传递,这可能非常关键。
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];
};

-4

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


你能详细说明一下吗?他已经在使用 useEffect 了。 - bluenote10

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