解决 React useState 地狱的良方?
您是否发现自己陷入了 React useState hook 地狱?
是的,这个好东西:
import { useState } from "react";
function EditCalendarEvent() {
const [startDate, setStartDate] = useState();
const [endDate, setEndDate] = useState();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState();
const [attendees, setAttendees] = useState([]);
return (
<>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{/* ... */}
</>
);
}
上面是一个用于更新日历事件的组件。遗憾的是,它存在一些问题。
除了看起来不好看之外,这里没有任何保障措施。没有什么可以阻止你选择早于开始日期的结束日期,这毫无意义。
对于过长的标题或描述也没有保护。
当然,我们可以屏住呼吸并相信,无论我们打电话到哪里,set*()
我们都会记得(甚至知道)在写信给州政府之前验证所有这些事情,但当我知道事情处于如此容易被破坏的状态时,我并不放心。
有一个比 useState 更强大的替代方案
您是否知道有一种替代状态钩子,它比您想象的更强大、更易于使用?
使用useReducer,我们可以将上面的代码转换为如下形式:
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(prev, next) => {
return { ...prev, ...next };
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ title: e.target.value })}
/>
{/* ... */}
</>
);
}
该useReducer
钩子可以帮助您控制从状态 A 到状态 B 的转换。
现在,您可以说“我也可以使用 useState 来做到这一点,看看”,并指向如下代码:
import { useState } from "react";
function EditCalendarEvent() {
const [event, setEvent] = useState({
title: "",
description: "",
attendees: [],
});
return (
<>
<input
value={event.title}
onChange={(e) => setEvent({ ...event, title: e.target.value })}
/>
{/* ... */}
</>
);
}
虽然你说得对,但这里有一个非常重要的问题需要考虑。除了这种格式仍然希望你始终记得展开,...event
这样你就不会因为直接改变对象而搞砸了(从而导致 React 无法按预期重新渲染),它仍然错过了最关键的好处useReducer
——提供控制状态转换的函数的能力。
回到使用useReducer
,唯一的区别是你得到一个额外的参数,它是一个函数,可以帮助我们确保每个状态转换都是安全有效的:
const [event, updateEvent] = useReducer(
(prev, next) => {
// Validate and transform event to ensure state is always valid
// in a centralized way
// ...
},
{ title: "", description: "", attendees: [] }
);
这样做的主要好处是,以完全集中的方式保证您的状态始终有效。
因此,使用此模型,即使未来的代码随着时间的推移而添加,并且团队中未来的开发人员updateEvent()
使用可能无效的数据进行调用,您的回调也将始终触发。
例如,无论状态如何以及在何处书写,我们可能希望始终确保结束日期永远不会早于开始日期(因为那样没有意义),并且标题的最大长度为 100 个字符:
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(prev, next) => {
const newEvent = { ...prev, ...next };
// Ensure that the start date is never after the end date
if (newEvent.startDate > newEvent.endDate) {
newEvent.endDate = newEvent.startDate;
}
// Ensure that the title is never more than 100 chars
if (newEvent.title.length > 100) {
newEvent.title = newEvent.title.substring(0, 100);
}
return newEvent;
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ title: e.target.value })}
/>
{/* ... */}
</>
);
}
这种防止状态直接变异的能力为我们提供了重要的安全网,特别是当我们的代码随着时间的推移变得越来越复杂时。
请注意,您也应该在 UI 中提供验证。但可以将其视为一组额外的安全保障,有点像数据库上的ORM,这样我们就可以完全确保状态在写入时始终有效。这可以帮助我们避免将来出现奇怪且难以调试的问题。
您useReducer
几乎可以在任何您想用的地方使用useState
也许你拥有世界上最简单的组件,一个基本的计数器,所以你正在使用useState
钩子:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
但即使在这个小例子中,它应该count
能够无限高吗?它应该为负吗?
好吧,也许这里不太容易想象如何实现负值,但如果我们想设置计数限制,这很简单useReducer
,我们可以完全相信状态始终有效,无论它写入何处以及如何写入。
import { useReducer } from "react";
function Counter() {
const [count, setCount] = useReducer((prev, next) => Math.min(next, 10), 0);
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}
(可选)Redux 化
随着事情变得越来越复杂,您甚至可以选择使用基于 redux 风格的动作模式。
回到我们的日历事件示例,我们也可以类似如下所示地编写它:
import { useReducer } from "react";
function EditCalendarEvent() {
const [event, updateEvent] = useReducer(
(state, action) => {
const newEvent = { ...state };
switch (action.type) {
case "updateTitle":
newEvent.title = action.title;
break;
// More actions...
}
return newEvent;
},
{ title: "", description: "", attendees: [] }
);
return (
<>
<input
value={event.title}
onChange={(e) => updateEvent({ type: "updateTitle", title: "Hello" })}
/>
{/* ... */}
</>
);
}
如果您查阅有关的任何文档或文章useReducer
,它们似乎暗示这是使用钩子的唯一方法。
但我想强调的是,这只是众多可以使用这个钩子的模式之一。虽然这很主观,但我个人并不太喜欢 Redux 和这类模式。它有其优点,但我认为,一旦你想为 Actions 分层添加新的模式,我个人认为Mobx、Zustand或XState是更好的选择。
也就是说,能够在没有任何额外依赖的情况下使用这种模式是一种优雅的做法,所以我会把它提供给喜欢这种格式的人。
共享 Reducer
的另一个优点useReducer
是,当子组件需要更新由此钩子管理的数据时,它会非常方便。与使用 时必须传递多个函数不同useState
,您只需向下传递 Reducer 函数即可。
来自React 文档中的一个示例:
const TodosDispatch = React.createContext(null);
function TodosApp() {
// Note: `dispatch` won't change between re-renders
const [todos, updateTodos] = useReducer(todosReducer);
return (
<TodosDispatch.Provider value={updateTodos}>
<DeepTree todos={todos} />
</TodosDispatch.Provider>
);
}
然后从孩子的角度来说:
function DeepChild(props) {
// If we want to perform an action, we can get dispatch from context.
const updateTodos = useContext(TodosDispatch);
function handleClick() {
updateTodos({ type: "add", text: "hello" });
}
return <button onClick={handleClick}>Add todo</button>;
}
这样,您不仅可以拥有一个统一的更新功能,而且可以保证子组件触发的状态更新符合您的要求。
常见的陷阱
务必牢记,必须始终将useReducer
hook 的状态值视为不可变的。如果在 Reducer 函数中意外修改了该对象,则可能会引发许多问题。
例如,来自React 文档的一个例子:
function reducer(state, action) {
switch (action.type) {
case "incremented_age": {
// 🚩 Wrong: mutating existing object
state.age++;
return state;
}
case "changed_name": {
// 🚩 Wrong: mutating existing object
state.name = action.nextName;
return state;
}
// ...
}
}
修复方法:
function reducer(state, action) {
switch (action.type) {
case "incremented_age": {
// ✅ Correct: creating a new object
return {
...state,
age: state.age + 1,
};
}
case "changed_name": {
// ✅ Correct: creating a new object
return {
...state,
name: action.nextName,
};
}
// ...
}
}
如果您发现经常遇到这个问题,您可以寻求一个小型图书馆的帮助:
(可选)常见陷阱解决方案:Immer
Immer是一个非常漂亮的库,可以确保数据不可变,同时又具有优雅的可变 DX 。
use -immer包还提供了一个useImmerReducer
****函数,允许您通过直接变异进行状态转换,但在底层使用JavaScript 中的代理创建一个不可变的副本。
import { useImmerReducer } from "use-immer";
function reducer(draft, action) {
switch (action.type) {
case "increment":
draft.count++;
break;
case "decrement":
draft.count--;
break;
}
}
function Counter() {
const [state, dispatch] = useImmerReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
);
}
这当然是完全可选的,并且仅当它能解决您遇到的问题时才需要。
那么我应该什么时候使用useState
vs useReducer
?
尽管我对它充满热情useReducer
,并指出了许多你可以使用它的地方,但请记住不要过早地抽象它。
一般来说,你可能仍然可以使用。随着你的状态和验证要求开始变得更加复杂,需要付出额外的努力,useState
我会考虑逐步采用。useReducer
然后,如果您useReducer
经常采用复杂对象,并且经常遇到突变陷阱,那么引入Immer
可能是值得的。
或者,如果您已经达到了状态管理复杂性的这个程度,您可能需要考虑一些更具可扩展性的解决方案,例如Mobx、Zustand或XState来满足您的需求。
但请不要忘记,从简单开始,然后仅根据需要添加复杂性。
谢谢你,大卫
这篇文章的灵感来自XState as StatelyuseReducer
的创建者 David Khourshid,他通过他的史诗般的推特帖子让我看到了钩子的可能性:
关于我
大家好!我是Builder.io的 CEO Steve。
我们通过拖放组件的方式在您的网站或应用程序上以可视化的方式创建页面和其他 CMS 内容。
您可能会发现它有趣或有用:
文章来源:https://dev.to/builderio/a-cure-for-react-usestate-hell-1ldi