了解useState和useReducer一起使用的情况

129 阅读10分钟

自从React钩子发布后,React中的功能组件可以使用状态和副作用。有两个主要的钩子用于React中的现代状态管理:useState和useReducer。本教程没有详细解释这两个React钩子,但解释了它们不同的使用场景。有很多人问我是使用useState还是useReducer;这就是为什么我认为把我所有的想法汇集到一篇文章中是最好的处理方式。

什么时候使用useState或useReducer?

每个开始使用React Hooks的人都会很快了解useState钩子。它通过设置初始状态并返回实际状态和一个更新函数来更新功能组件的状态:

import React, { useState } from 'react';
const Counter = () => {  const [count, setCount] = useState(0);
  const handleIncrease = () => {    setCount(count => count + 1);  };
  const handleDecrease = () => {    setCount(count => count - 1);  };
  return (    <div>      <h1>Counter with useState</h1>      <p>Count: {count}</p>
      <div>        <button type="button" onClick={handleIncrease}>          +        </button>        <button type="button" onClick={handleDecrease}>          -        </button>      </div>    </div>  );};
export default Counter;

useReducer钩子也可以用来更新状态,但它是以一种更复杂的方式进行的:它接受一个reducer函数和一个初始状态,并返回实际状态和一个dispatch函数。派遣函数通过将动作映射到状态转换中,以隐含的方式改变状态:

import React, { useReducer } from 'react';
const counterReducer = (state, action) => {  switch (action.type) {    case 'INCREASE':      return { ...state, count: state.count + 1 };    case 'DECREASE':      return { ...state, count: state.count - 1 };    default:      throw new Error();  }};
const Counter = () => {  const [state, dispatch] = useReducer(counterReducer, { count: 0 });
  const handleIncrease = () => {    dispatch({ type: 'INCREASE' });  };
  const handleDecrease = () => {    dispatch({ type: 'DECREASE' });  };
  return (    <div>      <h1>Counter with useReducer</h1>      <p>Count: {state.count}</p>
      <div>        <button type="button" onClick={handleIncrease}>          +        </button>        <button type="button" onClick={handleDecrease}>          -        </button>      </div>    </div>  );};
export default Counter;

上述每个组件都使用不同的钩子进行状态管理;因此,它们解决了相同的商业案例,但方式不同。因此,问题来了。你什么时候会使用一种状态管理解决方案或另一种?让我们深入了解一下 ...

简单状态与复杂状态的钩子

reducer的例子将count 属性封装成了一个状态对象,但我们可以通过使用count 作为实际的状态来更简单地完成这个任务。重构以消除状态对象,并将count 编码为JavaScript整数原语,我们看到这个用例并不涉及管理复杂的状态:

import React, { useReducer } from 'react';
const counterReducer = (state, action) => {  switch (action.type) {    case 'INCREASE':      return state + 1;    case 'DECREASE':      return state - 1;    default:      throw new Error();  }};
const Counter = () => {  const [count, dispatch] = useReducer(counterReducer, 0);
  const handleIncrease = () => {    dispatch({ type: 'INCREASE' });  };
  const handleDecrease = () => {    dispatch({ type: 'DECREASE' });  };
  return (    <div>      <h1>Counter with useReducer</h1>      <p>Count: {count}</p>
      <div>        <button type="button" onClick={handleIncrease}>          +        </button>        <button type="button" onClick={handleDecrease}>          -        </button>      </div>    </div>  );};
export default Counter;

在这种情况下,由于没有复杂的状态对象,我们可能最好使用一个简单的useState钩子。因此我们可以将我们的状态对象重构为一个基元。

无论如何,我认为一旦你超越了管理一个基元(即一个字符串、整数或布尔值),而必须管理一个复杂的对象(例如,有数组和额外的基元),你可能最好使用useReducer。也许一个好的经验法则是:

  • 只要你管理一个JS基元,就使用useState
  • 在管理对象或数组时使用useReducer。

这个经验法则表明,例如,一旦你在代码中发现const [state, setState] = useState({ firstname: 'Robin', lastname: 'Wieruch' }) ,你可能最好使用useReducer而不是useState。

用钩子进行简单与复杂的状态转换

如果我们在之前的状态转换中没有使用两种不同的动作类型(INCREASEDECREASE ),我们可以做什么不同的事情呢?通过使用每个派发的动作对象附带的可选有效载荷,我们可以从还原器的外部指定我们想要增加或减少多少count 。这使状态转换更加隐蔽:

import React, { useReducer } from 'react';
const counterReducer = (state, action) => {  switch (action.type) {    case 'INCREASE_OR_DECREASE_BY':      return state + action.by;    default:      throw new Error();  }};
const Counter = () => {  const [count, dispatch] = useReducer(counterReducer, 0);
  const handleIncrease = () => {    dispatch({ type: 'INCREASE_OR_DECREASE_BY', by: 1 });  };
  const handleDecrease = () => {    dispatch({ type: 'INCREASE_OR_DECREASE_BY', by: -1 });  };
  return (    <div>      <h1>Counter with useReducer</h1>      <p>Count: {count}</p>
      <div>        <button type="button" onClick={handleIncrease}>          +        </button>        <button type="button" onClick={handleDecrease}>          -        </button>      </div>    </div>  );};
export default Counter;

但我们没有这样做,这就是使用还原器时的一个重要教训。在使用还原器时,这就是一个重要的教训:要尽量使你的状态转换明确。后面的例子,只有一个状态转换,试图把整个转换逻辑放在一个块中,但在使用还原器时,这不是很理想。相反,我们希望能够毫不费力地推理我们的状态转换。有两个独立的状态转换而不是一个,我们可以通过阅读动作类型的名称来更容易地推理转换的业务逻辑。

**与useState相比,useReducer给了我们更多可预测的状态转换。**当状态变化比较复杂时,这就变得更加重要,你希望有一个地方--还原器函数--来推理它们。一个精心设计的还原器函数可以完美地封装这种逻辑。

另一个经验法则。当你发现连续调用多个setState() ,尽量将这些变化封装在一个分配单个动作的reducer函数中。

将所有状态放在一个对象中的一大优势是,可以使用浏览器的本地存储来缓存你的状态片段,然后在你重启应用程序时将其作为useReducer的初始状态取回。

多个状态转换在一个状态对象上操作

一旦你的应用程序规模扩大,你很可能会处理更复杂的状态和状态转换。这就是我们在本教程的最后两节中所涉及的。然而,有一点需要注意的是,状态对象不仅仅是复杂度的增长,它还在必须执行的状态转换的数量方面有所增长。

以下面这个对一个有多个状态转换的状态对象进行操作的还原器为例:

const todoReducer = (state, action) => {  switch (action.type) {    case 'DO_TODO':      return state.map(todo => {        if (todo.id === action.id) {          return { ...todo, complete: true };        } else {          return todo;        }      });    case 'UNDO_TODO':      return state.map(todo => {        if (todo.id === action.id) {          return { ...todo, complete: false };        } else {          return todo;        }      });    case 'ADD_TODO':      return state.concat({        task: action.task,        id: action.id,        complete: false,      });    default:      throw new Error();  }};

在一个状态对象(如待办事项列表)中保留所有内容,同时对该对象进行多个状态转换的操作才有意义。如果用useState来实现同样的业务逻辑,其可预测性和可维护性就会差很多。

你通常会从useState开始,然后在状态对象变得更加复杂或者状态转换的数量随着时间的推移不断增加时,将你的状态管理重构为useReducer。还有其他一些情况,将不同的属性收集到一个单一的状态对象中是有意义的,尽管它们最初看起来并不属于一起。例如,这个教程展示了如何用useEffect、useState和useReducer来获取数据,它将那些相互依赖的属性集中在一个状态对象中:

const [state, dispatch] = useReducer(dataFetchReducer, {  isLoading: false,  isError: false,  data: initialData,});

人们可以说isLoadingisError 可以在两个useState钩子中分别管理,但是当看了reducer函数后,可以看到最好把它们放在一个状态对象中,因为它们有条件地相互依赖:

const dataFetchReducer = (state, action) => {  switch (action.type) {    case 'FETCH_INIT':      return {        ...state,        isLoading: true,        isError: false      };    case 'FETCH_SUCCESS':      return {        ...state,        isLoading: false,        isError: false,        data: action.payload,      };    case 'FETCH_FAILURE':      return {        ...state,        isLoading: false,        isError: true,      };    default:      throw new Error();  }};

重要的不仅是一个状态对象的复杂性和状态转换的数量,而且在有效地管理状态时,还必须考虑属性如何在应用程序的业务逻辑的上下文中配合起来。如果逻辑的不同部分在代码的不同地方用useState来管理,那么很快就会变得难以将整体作为一个逻辑单元来推理。另一个重要的优势是改善了开发者的体验。有了一个代码块(reducer函数)来管理一个状态对象的多个转换,如果出现问题,调试逻辑就容易得多。

将所有的状态转换整齐地组织到一个还原器函数中的另一个巨大优势是能够为单元测试导出还原器。如果你需要用一个函数来测试所有的转换,这使得对一个有多个状态转换的状态对象的推理更加简单:(state, action) => newState 。你可以通过提供所有可用的动作类型和各种匹配的有效载荷来测试所有状态转换。

状态变化的逻辑

使用useState或useReducer时,状态转换的逻辑放置的位置是不同的。正如我们在之前的useReducer例子中看到的,状态转换的逻辑被放置在reducer函数中。该动作只提供了对当前状态进行转换所需的最小信息:(state, action) => newState 。如果你依靠当前状态来更新状态,这就特别方便。

const todoReducer = (state, action) => {  switch (action.type) {    case 'DO_TODO':      return state.map(todo => {        if (todo.id === action.id) {          return { ...todo, complete: true };        } else {          return todo;        }      });    case 'UNDO_TODO':      return state.map(todo => {        if (todo.id === action.id) {          return { ...todo, complete: false };        } else {          return todo;        }      });    case 'ADD_TODO':      return state.concat({        task: action.task,        id: action.id,        complete: false,      });    default:      throw new Error();  }};

你的React组件关注的是调度适当的动作。

import uuid from 'uuid/v4';
// Somewhere in your React components ...
const handleSubmit = event => {  dispatch({ type: 'ADD_TODO', task, id: uuid() });};
const handleChange = () => {  dispatch({    type: todo.complete ? 'UNDO_TODO' : 'DO_TODO',    id: todo.id,  });};

现在想象一下用useState执行同样的状态转换。在这种情况下,没有像减速器那样的单一实体来集中所有的业务逻辑进行处理。相反,所有与状态相关的逻辑都在单独的处理程序中结束,这些处理程序从useState调用状态更新器函数。这使得状态逻辑与视图逻辑的分离更加困难,从而导致了组件的复杂性。然而,还原器是收集所有修改状态的逻辑的完美场所。

状态改变的触发器

React的组件树自然会随着你的应用而增长。当状态在一个组件中是简单的和封装的(状态+状态触发器),就像控制组件中的搜索输入字段那样),useState可能是一个完美的选择。

import React, { useState } from 'react';
const App = () => {  const [value, setValue] = useState('Hello React');
  const handleChange = event => setValue(event.target.value);
  return (    <div>      <label>        My Input:        <input type="text" value={value} onChange={handleChange} />      </label>
      <p>        <strong>Output:</strong> {value}      </p>    </div>  );};
export default App;

然而,有时你想在顶层管理状态,但在组件树深处的某个地方触发状态变化。你可以通过props将useState的update函数或useReducer的dispatch函数沿着组件树向下传递;但使用React的context API可能是一个更好的选择,可以避免prop drilling(通过每个组件层传递props)。在这种情况下,拥有一个具有不同动作类型和有效载荷的调度函数可能是一个更好的选择,而不是使用必须单独向下传递的来自useState的多个更新器函数。派遣函数可以通过React的useContext钩子传递一次。在这个使用useContext的React状态管理教程中可以看到一个很好的例子。


使用useState还是useReducer的决定并不总是黑白分明的;有许多灰色的阴影。我希望这篇文章能让你对何时使用useState或useReducer有更好的理解。在这里你可以找到一个有几个例子的GitHub仓库。以下事实概括了本文的主要观点。{免责声明:它们反映了我对这个话题的看法)。

如果你有,请使用useState。

  • A)JavaScript原语作为状态
  • B) 简单的状态转换
  • C) 组件内的业务逻辑
  • D) 不同的属性不会以任何相关的方式改变,并且可以由多个useState钩子管理
  • E) 与你的组件共处一地的状态
  • F) 一个小的应用程序(但这里的界限是模糊的)

如果你有以下情况,请使用useReducer。

  • A)JavaScript对象或数组作为状态
  • B) 复杂的状态转换
  • C) 复杂的业务逻辑更适合使用还原器函数
  • D) 不同的属性捆绑在一起,应该在一个状态对象中管理
  • E) 需要在组件树的深处更新状态
  • F) 一个中等规模的应用程序(注意:这里的界限是模糊的)
  • G) 需要更容易的测试
  • H) 需要一个更可预测和可维护的状态架构

注意:如果你对比较感兴趣,请查看何时使用useReducer或Redux/

如果你想通过一个更全面的例子来了解useState和useReducer一起使用的情况,请查看这个关于React中现代状态管理的广泛演练。它几乎模仿了Redux,使用React的useContext Hook来进行 "全局 "状态管理,在这里有可能传递一次调度函数。