我应该使用useState还是useReducer?(翻译)

目录#

简介#

本文翻译自 Should I useState or useReducer?,原文作者为 Kent C. Dodds

本文通过两个个示例讲解了useState和useReducer的适用场景,同时介绍了一个useReducer的简单用法。

另外文末将会附上作者原文中讲解代码的视频。

前言#

useStateuseReducer 这两个用于处理状态的React hooks,我们应该使用哪一个呢?

每当有两个选项来处理同一件事时,人们常常提出这样的疑问:『我应该选择哪一个呢?』

有两种可能性可以来解释我们为什么会有多种方法用于做同一件事:

  1. 其中一个方法是传统的做法,另一些方法是对传统方法的改进,是『新事物』。具体来说,我们通常为了向后兼容,会保留原有的旧方法,而新方法则是更新迭代所需要的新的基础设施。例如:React的 Class组件(旧方法) vs 函数组件(新方法)
  2. 它们侧重于不同的角度,应该根据需求进行分析与考虑,从而分别应用于适合它们的情况(有时这也意味着在一段程序中会使用到上述提及的多个方法);例如:useState vs useReducer 或是 Static vs Unit vs Integration vs E2E tests,亦或是 “Control Props” vs “State Reducers”

useStateuseReducer 的对比,符合上面的第二条,所以让我们一起来讨论一下它们各自的适用场景吧~

(悄悄说一句,这不是一个很难回答的问题 😂)

我们举例来说吧#

我认为讨论适用场景的最好办法就是通过实际例子来进行分析,我们将集中关注于两个典型例子,一个更适合 useState,另一个更适合 useReducer。这两个例子并不能覆盖所有的场景,但仅作为抛砖引玉,希望你能从这得到启发,形成你自己的判断。

一个自定义hook: useDarkMode#

我最近为我的教程项目编写了这段代码,看起来蛮有趣的,让我们来对比一下 useStateuseReducer 的不同实现吧。

useState的版本#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function useDarkMode() {
const preferDarkQuery = '(prefers-color-scheme: dark)'
const [mode, setMode] = React.useState(
() =>
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
)
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
return [mode, setMode]
}

这段代码描述的是:我们根据用户所设定的系统主题颜色,来选择将网页初始化为深色模式或是日常模式,并将返回 modesetMode,随着主题颜色的改变(不论是直接调用 setMode,或是用户直接修改了系统主题色),我们都会把修改后的值存入 localStorage 以便于后续开发;

useReducer的版本#

有几种 useReducer 的用法可以重构上面那一段代码,我从我们大部分人所熟知的写法讲起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const preferDarkQuery = '(prefers-color-scheme: dark)'
function darkModeReducer(state, action) {
switch (action.type) {
case 'MEDIA_CHANGE': {
return {...state, mode: action.mode}
}
case 'SET_MODE': {
// make sure to spread that state just in case!
return {...state, mode: action.mode}
}
default: {
// helps us avoid typos!
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
// use the init function to lazily initialize state so we don't read into
// localstorage or call matchMedia every render
function init() {
return {
mode:
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
}
}
function useDarkMode() {
const [state, dispatch] = React.useReducer(
darkModeReducer,
{mode: 'light'},
init,
)
const {mode} = state
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () =>
dispatch({
type: 'MEDIA_CHANGE',
mode: mediaQuery.matches ? 'dark' : 'light',
})
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
// We like the API the way it is, so instead of returning the state object
// and the dispatch function, we'll return the `mode` property and we'll
// create a setMode helper (which we have to memoize in case someone wants
// to use it in a dependency list):
const setMode = React.useCallback(
newMode => dispatch({type: 'SET_MODE', mode: newMode}),
[],
)
return [mode, setMode]
}

wow!单凭前后两端代码,我们应该都会认为 useState 的版本是更简洁的写法。但是!我们可以通过放弃这种典型 redux 风格细粒度处理来极大地简化我们的 reducer,例如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function useDarkMode() {
const preferDarkQuery = '(prefers-color-scheme: dark)'
const [mode, setMode] = React.useReducer(
(prevMode, nextMode) =>
typeof nextMode === 'function' ? nextMode(prevMode) : nextMode,
'light',
() =>
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
)
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
return [mode, setMode]
}

这样看起来好多了。但即使是大幅度精简了,useState 版本仍然是更加简介清晰的,所以在这种场景下,useState 更为适用。

当你所管理的这个状态,描述的是一个独立元素时,你应该使用 useState

另一个自定义hook: useUndo#

Github上的 useUndo 是一个优秀的package,我从中受到了启发,感谢你,Homer Chen

(每个状态都是通过state描述的,所以没有下划高亮噢…)

useState的版本#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function useUndo(initialPresent) {
const [past, setPast] = React.useState([])
const [present, setPresent] = React.useState(initialPresent)
const [future, setFuture] = React.useState([])
const canUndo = past.length !== 0
const canRedo = future.length !== 0
const undo = React.useCallback(() => {
if (!canUndo) return
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
setPast(newPast)
setPresent(previous)
setFuture([present, ...future])
}, [canUndo, future, past, present])
const redo = React.useCallback(() => {
if (!canRedo) return
const next = future[0]
const newFuture = future.slice(1)
setPast([...past, present])
setPresent(next)
setFuture(newFuture)
}, [canRedo, future, past, present])
const set = React.useCallback(
newPresent => {
if (newPresent === present) {
return
}
setPast([...past, present])
setPresent(newPresent)
setFuture([])
},
[past, present],
)
const reset = React.useCallback(newPresent => {
setPast([])
setPresent(newPresent)
setFuture([])
}, [])
return [
{past, present, future},
{set, reset, undo, redo, canUndo, canRedo},
]
}

看起来还不错对吧?实际上,在某些情况下,这却可能导致运行异常;但在解释何时会发生异常之前,我想解释一下大家对于按顺序调用多个状态更新函数的常见误解(正如我们在上面这些函数中所调用的形式)。

通常我们会认为 setState 函数每调用一次,就会触发一次重新渲染,所以 reset 函数会触发三次重新渲染。那么,首先要注意的是: (在优化重新渲染环节之前,应该先修复渲染缓慢的函数)[https://kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render];其次,React有着合并更新系统,所以当你从事件回调或是 useEffect 钩子函数中执行 reset 函数时,这三次set将被合并,最终只会执行一次重新渲染;

尽管如此,但当我们从一个 async 函数(例如一个HTTP请求函数)中调用 reset 时,依然会导致三次重新渲染;但在未来 React concurrent 模式中,这个问题将会被优化;所以我的主要关注点不在于重新渲染上。

我的关注点是代码中隐藏的闭包旧值的bug!你能认出他们吗?有三个!我给你们一个提示,undoredoset 中都有一个,但 reset 中没有。

下面是一个复现Bug的案例:

1
2
3
4
5
6
7
8
9
10
function Example() {
const [state, {set}] = useUndo('first')
React.useEffect(() => {
set('second')
}, [])
React.useEffect(() => {
set('third')
}, [])
return <pre>{JSON.stringify(state, null, 2)}</pre>
}

实际上是:

1
2
3
4
5
{
"past": ["first"],
"present": "third",
"future": []
}

而预想的打印结果应该是:

1
2
3
4
5
{
"past": ["first", "second"],
"present": "third",
"future": []
}

所以在这种情况下,second 发生了啥?oh!看起来是我们忘了将 set 放进 useEffect 的依赖数组了,淦,我们把它加上试试:

1
2
3
4
5
6
7
8
9
10
function Example() {
const [state, {set}] = useUndo('first')
React.useEffect(() => {
set('second')
}, [set])
React.useEffect(() => {
set('third')
}, [set])
return <pre>{JSON.stringify(state, null, 2)}</pre>
}

修改完毕,保存,重新加载… 等一下?!输出看起来就有问题!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"past": [
"first",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"... this goes on forever..."
],
"present": "third",
"future": []
}

但我们不是已经把 set 函数 memo化 过了吗?它不应该在它自身依赖未改变的情况下发生改变的呀… oh!是因为它自身包含了 pastpresent 两个值,所以每当我们调用一次 set,这两个值就会发生改变,这就导致了无限循环!

现在这些例子都是人为拟定的,但是如果你根据网络请求的回调来更新状态,并且它们的返回顺序与发送时不同,那么可能会出现类似的错误。不管怎样,你都不想去思考这种事情,对吧?

我们可以通过 useReducer 来解决这个问题,但我们也可以通过修改 useState 的使用方式,来避免这个问题,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function useUndo(initialPresent) {
const [state, setState] = React.useState({
past: [],
present: initialPresent,
future: [],
})
const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0
const undo = React.useCallback(() => {
setState(currentState => {
const {past, present, future} = currentState
if (past.length === 0) return currentState
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
})
}, [])
const redo = React.useCallback(() => {
setState(currentState => {
const {past, present, future} = currentState
if (future.length === 0) return currentState
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
})
}, [])
const set = React.useCallback(newPresent => {
setState(currentState => {
const {present, past} = currentState
if (newPresent === present) return currentState
return {
past: [...past, present],
present: newPresent,
future: [],
}
})
}, [])
const reset = React.useCallback(newPresent => {
setState(() => ({
past: [],
present: newPresent,
future: [],
}))
}, [])
return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

我做了以下内容来修复这个问题:

  1. 我使用了 setState 的回调形式来进行状态更新,这样的好处是我能从回调函数的参数中获取到先前的 state, 这样我就不需要将 state 作为依赖,并且避免闭包旧值问题;
  2. 我将所有 state 通过一个对象整合在了一起,这样的必要性是:我需要通过另一个状态来判定当前状态的值,例如在 redo 函数中,我们需要通过 present 来更新 pastfuture 来更新 present
  3. 我直接计算组合获得 canUndocanRedo 这两个状态值,所以我不需要将其列入依赖列表;

这些步骤能让我们解决掉这个先前复现出的Bug,看起来不错,接下来让我们看看在 useResucer 中会是什么样的:

useResucer的版本#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'
function undoReducer(state, action) {
const {past, present, future} = state
const {type, newPresent} = action
switch (action.type) {
case UNDO: {
if (past.length === 0) return state
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case REDO: {
if (future.length === 0) return state
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
}
case SET: {
if (newPresent === present) return state
return {
past: [...past, present],
present: newPresent,
future: [],
}
}
case RESET: {
return {
past: [],
present: newPresent,
future: [],
}
}
}
}
function useUndo(initialPresent) {
const [state, dispatch] = React.useReducer(undoReducer, {
past: [],
present: initialPresent,
future: [],
})
const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0
const undo = React.useCallback(() => dispatch({type: UNDO}), [])
const redo = React.useCallback(() => dispatch({type: REDO}), [])
const set = React.useCallback(
newPresent => dispatch({type: SET, newPresent}),
[],
)
const reset = React.useCallback(
newPresent => dispatch({type: RESET, newPresent}),
[],
)
return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

useUndo 现在看起来很简洁了。如果我们从一开始就使用 useReducer,我们甚至不需要考虑向依赖项数组添加什么东西,因为这些函数非常简单,它们不需要任何的依赖。所有的逻辑都存在于我们的 reducer 中。这让我们很自然地规避掉闭包旧值这个问题。

当 state 中的一个元素依赖于另一个 state 的值来进行更新时,请使用 useReducer

总结#

当你需要一些 『既定规则』 时(不是ESLINT的规则哦),请参考以下几条:

  • 当 state 只是用来描述一个独立的元素时,请使用 useState
  • 当 state 中的一个元素依赖于另一个 state 的值来进行更新时,请使用 useReducer

除开这些 『既定规则』,如何写更好是一件非常主观的事情,当然,老实说,即使是 『既定规则』 也是主观的,因为正如我所展示的代码,你可以使用任一方法来完成你想做的所有事情。

另外,请注意,这也是视情况而定的。 你可以在一个 hook 或 组件中同时使用多个 useStateuseReducer 。 按照使用作用域分离 state,如果它们一起更新,最好将其放在 reducer 中;如果某项 state 与该hook/组件中的其他 state 来说非常独立,那么将其与其他 state 绑定在一起只会给 reducer 增加不必要的复杂性,最好就让它保持 useState 的形式。

因此,这不仅是 『当 useStates 数量超过X个时,我就要切换到 useReducer』这样的对比,而是存在着比这更细微的差别。 希望这篇文章可以帮助你了解到这些细微差别具体是什么,并找到可以最适合你目前情况的使用方法。 通常,我建议从 useState 开始,并留心 state 需要合并更改时迁移到 useReducer

感谢!遥祝各位写码愉快!

原文视频#

Watch “When to useState instead of useReducer” on egghead.io

Watch “When to useReducer instead of useState” on egghead.io