React useState的错误用法避坑详解
KooFE 人气:0引言
本文源于翻译 Avoid These Common Pitfalls Of React useState 由公众号KooFE前端团队完成翻译
useState 是我们使用最频繁的 React hook,在代码中随处可见,但是也经常会出现一些错误的用法。
或许你已经经历过这些错误的用法,但是可能还没有意识到这是错误,比如写出了一些冗余的、重复的、矛盾的 state,让你不得不额外使用 useEffect 来处理它们。由于这些错误用法的存在,会让代码的可读性变差,提高了代码的维护成本。
了解这些易犯的错误,可以让我们获得如下收益:
- 代码更容易阅读和维护
- 减少代码出 Bug 的可能性
- 降低代码的复杂程度
在本文中,将介绍一些关于 useState 的常见错误,以便在今后的工作中避免这些错误。
冗余的 state
对于初级开发者来说,定义和使用冗余的 state 是一个比较常见的错误。如果一个 state 依赖了另外一个 state,就是这种典型的错误用法。
简单示例
下面是一个简单的组件,允许用户编辑自己的姓名,其中第一个输入框是用户的姓氏,后一个输入框是用户的名字,然后将姓名组合在一起渲染在输入框的下面。
代码实现如下:
import { useState } from "react"; function RedundantState() { const [firstName, setFirstName] = useState(""); // 姓氏 const [lastName, setLastName] = useState(""); // 名字 const [fullName, setFullName] = useState(""); // 姓名 const onChangeFirstName = (event) => { setFirstName(event.target.value); setFullName(`${event.target.value} ${lastName}`); }; const onChangeLastName = (event) => { setLastName(event.target.value); setFullName(`${firstName} ${event.target.value}`); }; return ( <> <form> <input value={firstName} onChange={onChangeFirstName} placeholder="First Name" /> <input value={lastName} onChange={onChangeLastName} placeholder="Last Name" /> </form> <div>Full name: {fullName}</div> </> ); }
很明显,这段代码中的 fullName 是冗余的 state
问题分析
可能你会说,先后依次更新 firstName 和 fullName 会导致额外的渲染周期。
const onChangeFirstName = (event) => { setFirstName(event.target.value); setFullName(`${event.target.value} ${lastName}`); };
但是,React state 的更新是批量更新,所以不会为每个 state 更新做单独的渲染。
因此,在大多数情况下,性能方面的差异不大。问题在于可维护性和引入错误的风险。让我们再次看一下示例代码:
const onChangeFirstName = (event) => { setFirstName(event.target.value); setFullName(`${event.target.value} ${lastName}`); }; const onChangeLastName = (event) => { setLastName(event.target.value); setFullName(`${firstName} ${event.target.value}`); };
每次更新firstName 或 lastName 时,我们都必须要更新 fullName。在更复杂的场景中,这很容易被遗漏。因此,这会导致代码更难重构,引入 bug 的可能性也会增加。
如前所述,在大多数情况下,我们不必担心性能。但是,如果被依赖的 state 是大型的数组或需要大量的计算,则可以使用 useMemo 来做优化处理。
解决方案
fullName 可以由 firstName 和 lastName 直接拼接而成。
export function RedundantState() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const fullName = `${firstName} ${lastName}`; ... return ( <> <form> ... </form> <div>Full name: {fullName}</div> </> ); }
重复的 state
在多个 state 中存在重复的数据,也是一个比较常见的错误。通常在做数据的转换、排序或过滤时会遇到这种情况。另一种常见情况是选择展示不同的数据,比如接下来介绍的例子。
简单示例
这个组件用于显示项目列表,用户可以单击相应的按钮来打开 modal 弹窗。
在下面的代码中就存在这种错误用法。
import { useState } from "react"; // const items = [ // { // id: "item-1", // text: "Item 1", // }, // ... // ] function DuplicateState({ items }) { const [selectedItem, setSelectedItem] = useState(); const onClickItem = (item) => { setSelectedItem(item); }; return ( <> {selectedItem && <Modal item={selectedItem} />} <ul> {items.map((row) => ( <li key={row.id}> {row.text} <button onClick={() => onClickItem(row)}>Open</button> </li> ))} </ul> </> ); }
这段代码中的问题是,将 item 原封不动地拷贝到了 state 中。
问题分析
在上面的代码中,这种重复的数据违反了单一数据源原则。事实上,一旦用户选择了任何一项,我们就会出现两个数据源:selectedItem 状态和 items 数组中的数据。
假如,用户能够在 modal 弹窗中编辑这些数据。可能会是这样的:
- 用户在 modal 弹窗中更改数据并提交
- 将向服务器发送请求并更新数据库中的 item
- 前端更新 item 数据(通过服务器的响应或重新请求 items 列表)。
- 前端使用新的 items 数组重新渲染。
- 现在的问题是:DuplicateState 组件内部发生了什么?
这就是问题所在。selectedItem 状态仍将包含旧数据。它将不同步。你可以想象,在更复杂的情况下,这可能会成为一个令人讨厌的 bug。
当然,我们可以写代码来实现 selectedItem 状态同步。但我们不得不使用 useEffect 来监听 items 数组中的变化。
解决方案
一个更简单的解决方案是只跟踪选定的 id。正如你所看到的,该解决方案 “冗余的 state” 部分中的解决方案非常相似:我们只需从 id 中计算出 selectedItem 变量。
// const items = [ // { // id: "item-1", // text: "Item 1", // }, // ... // ] function DuplicateState({ items }) { const [selectedItemId, setSelectedItemId] = useState(); const selectedItem = items.find(({ id }) => id === selectedItemId); const onClickItem = (itemId) => { setSelectedItemId(itemId); }; return ( <> {selectedItem && <Modal item={selectedItem} />} <ul> {items.map((row) => ( <li key={row.id}> {row.text} <button onClick={() => onClickItem(row.id)}>Open</button> </li> ))} </ul> </> ); }
使用 useEffect 更新 state
另一个常见问题是使用 useEffect 来监听变量的变化。
简单示例
我们继续使用上一节的示例:
在组件中,当 items 发生变化后,使用 useEffect 同步给 selectedItem。
import { useEffect, useState } from "react"; // const items = [ // { // id: "item-1", // text: "Item 1", // }, // ... // ] function DuplicateState({ items }) { const [selectedItem, setSelectedItem] = useState(); useEffect(() => { if (selectedItem) { setSelectedItem(items.find(({ id }) => id === selectedItem.id)); } }, [items]); const onClickItem = (item) => { setSelectedItem(item); }; return ( <> {selectedItem && <Modal item={selectedItem} />} <ul> {items.map((row) => ( <li key={row.id}> {row.text} <button onClick={() => onClickItem(row)}>Open</button> </li> ))} </ul> </> ); }
这段代码能够正常工作,并同步保持 selectedItem 状态。是不是觉得它的实现方式有点 hack?
问题分析
这种方法存在多个问题:
- useEffect 不容易阅读和理解。因此,使用 useEffect 的次数越少越好。
- 在 useEffect 中更新 state 会导致额外的渲染。虽然不会引起性能方面的大问题,但也需要考虑。
- 在代码中,我们在 selectedItem 状态和 items 属性之间引入了某种隐藏的关系。在阅读或更改代码时,这很容易错过。
- 在正确的时间触发 useEffect 中的代码可能很困难。在这种模式中,我们经常要额外引入其他解决方法,例如避免在第一次渲染时运行代码。下面是一个示例:
function DuplicateState({ items }) { const [selectedItem, setSelectedItem] = useState(); const firstRender = useRef(true); useEffect(() => { if (firstRender.current) { firstRender.current = false; return; } setSelectedItem(items.find(({ id }) => id === selectedItem.id)); }, [items]); ...
如果你想使用 useEffect 或在另一个开发人员的代码中看到它,问问自己是否真的需要它。也许可以通过前面介绍的方法来避免这种情况。
解决方案
您可能已经猜到了:上一节的解决方案也帮助我们删除 useEffect。如果我们只存储所选项目的 ID 而不是整个对象,那么就没有什么可同步的。
import { useState } from "react"; // const items = [ // { // id: "item-1", // text: "Item 1", // }, // ... // ] function DuplicateState({ items }) { const [selectedItemId, setSelectedItemId] = useState(); const selectedItem = items.find(({ id }) => id === selectedItemId); const onClickItem = (id) => { setSelectedItem(id); }; return ( <> {selectedItem && <Modal item={selectedItem} />} <ul> {items.map((row) => ( <li key={row.id}> {row.text} <button onClick={() => onClickItem(row.id)}>Open</button> </li> ))} </ul> </> ); }
使用 useEffect 监听 state 变化
与上一节相关的另外一个常见问题是使用 useEffect 对状态的变化做出反应。但解决方案略有不同。
简单示例
这是一个显示产品的组件。用户可以通过单击按钮显示或隐藏产品详细信息。无论何时显示或隐藏产品信息,我们都会触发一个动作(在本例中,会触发一个埋点数据上报)。
import { useEffect, useState } from "react"; function ProductView({ name, details }) { const [isDetailsVisible, setIsDetailsVisible] = useState(false); useEffect(() => { trackEvent({ event: "Toggle Product Details", value: isDetailsVisible }); }, [isDetailsVisible]); const toggleDetails = () => { setIsDetailsVisible(!isDetailsVisible); }; return ( <div> {name} <button onClick={toggleDetails}>Show details</button> {isDetailsVisible && <ProductDetails {...details} />} </div> ); }
代码中的 useEffect 会侦听 isDetailsVisible 是否变化,并相应地触发埋点事件。
问题分析
代码中的问题如下:
- useEffect通常不容易理解。
- 它可能会导致不必要的渲染周期(如果在效果内部更新了状态)。
- 很容易引入与渲染生命周期相关的错误。事实上,这段代码在初始渲染期间运行trackEvent,这会导致一个 bug。
- 它将影响与实际原因分开。在这段代码中,我们看到 trackEvent 正在运行,是因为 isDetailsVisible 发生了更改。但真正的原因是用户按下了 “显示详细信息” 按钮。
解决方案
在许多情况下,可以删除用于监听 state 变化的 useEffect。通常,我们可以将这些功能放在更新 state 的代码旁边。在这里,我们可以将 trackEvent(...) 移动到 toggleDetails 函数中。
function ProductView({ name, details }) { const [isDetailsVisible, setIsDetailsVisible] = useState(false); const toggleDetails = () => { setIsDetailsVisible(!isDetailsVisible); trackEvent({ event: "Toggle Product Details", value: !isDetailsVisible }); }; return ( <div> {name} <button onClick={toggleDetails}>Show details</button> {isDetailsVisible && <ProductDetails {...details} />} </div> ); }
矛盾的 state
当您使用相互依赖的多个 state 时,这些状态可能存在多种组合,稍有不慎就会设置出错误的 state,让这些 state 呈现出相互矛盾的渲染结果。因此,我们需要更直观的方式来组织和管理这些状态组合。
简单示例
下面是一个很基本的数据请求的示例,组件可以处于不同的状态:要么正在加载数据,要么发生错误,要么已成功获取数据。
export function ContradictingState() { const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setIsLoading(true); setError(null); fetchData() .then((data) => { setData(data); setIsLoading(false); }) .catch((error) => { setIsLoading(false); setData(null); setError(error); }); }, []); ...
问题分析
这种方法的问题是,如果我们不小心,我们可能会产生有矛盾的 state。例如,在上面的示例中,当发生错误时,我们可能忘记将 isLoading 设置为 false。
对于哪些 state 是允许组合的,也是很难理解的。在上面的例子中,理论上我们可以有 8 种不同的 state 组合。但你不能很直观的看到哪些状态组合是真正存在的。
解决方案
多个状态之间相互依赖,更推荐用 useReducer 来替代 useState。
const initialState = { data: [], error: null, isLoading: false }; function reducer(state, action) { switch (action.type) { case "FETCH": return { ...state, error: null, isLoading: true }; case "SUCCESS": return { ...state, error: null, isLoading: false, data: action.data }; case "ERROR": return { ...state, isLoading: false, error: action.error }; default: throw new Error(`action "${action.type}" not implemented`); } } export function NonContradictingState() { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { dispatch({ type: "FETCH" }); fetchData() .then((data) => { dispatch({ type: "SUCCESS", data }); }) .catch((error) => { dispatch({ type: "ERROR", error }); }); }, []); ...
这样一来,就可以大大减少了我们的理解成本。我们可以很直观地看到我们有 3 个动作和 4 个可能的组件状态(“FETCH”、“SUCCESS”、“ERROR”和初始状态)。
深度嵌套的 state
我们这里提到的最后一个常见问题是(深度)嵌套对象的 state。如果我们只是渲染数据,这可能不存在什么问题。但是,一旦开始更新嵌套数据项,就会遇到一些麻烦。
简单示例
这里我们有一个组件,用于渲染深度嵌套的注释。JSX 在这里并不重要,所以省略了,我们假设 updateComment 是绑定到按钮上的回调函数。
function NestedComments() { const [comments, setComments] = useState([ { id: "1", text: "Comment 1", children: [ { id: "11", text: "Comment 1 1" }, { id: "12", text: "Comment 1 2" } ] }, { id: "2", text: "Comment 2" }, { id: "3", text: "Comment 3", children: [ { id: "31", text: "Comment 3 1", children: [ { id: "311", text: "Comment 3 1 1" } ] } ] } ]); const updateComment = (id, text) => { // this gets complicated }; ...
问题分析
这种嵌套 state 的问题是,我们必须以不可变的方式更新它,否则组件不会重新渲染。上面示例中的深度嵌套注释,我们以硬编码的方式来实现:
const updateComment = (id, text) => { setComments([ ...comments.slice(0, 2), { ...comments[2], children: [ { ...comments[2].children[0], children: [ { ...comments[2].children[0].children[0], text: "New comment 311" } ] } ] } ]); };
这种实现方式非常复杂。
解决方案
与深度嵌套的 state 不同,使用扁平的数据结构要容易得多。我们可以为每一个数据项增加 ID 字段,通过 ID 之间相互引用来描述嵌套关系。代码看起来像这样:
function FlatCommentsRoot() { const [comments, setComments] = useState([ { id: "1", text: "Comment 1", children: ["11", "12"], }, { id: "11", text: "Comment 1 1" }, { id: "12", text: "Comment 1 2" }, { id: "2", text: "Comment 2", }, { id: "3", text: "Comment 3", children: ["31"], }, { id: "31", text: "Comment 3 1", children: ["311"] }, { id: "311", text: "Comment 3 1 1" } ]); const updateComment = (id, text) => { const updatedComments = comments.map((comment) => { if (comment.id !== id) { return comment; } return { ...comment, text }; }); setComments(updatedComments); }; ...
现在,通过它的 ID 找到正确的数据项,并在数组中替换它就容易多了。
加载全部内容