亲宝软件园·资讯

展开

React useState的错误用法避坑详解

KooFE 人气:0

引言

本文源于翻译 Avoid These Common Pitfalls Of React useState 由公众号KooFE前端团队完成翻译

useState 是我们使用最频繁的 React hook,在代码中随处可见,但是也经常会出现一些错误的用法。

或许你已经经历过这些错误的用法,但是可能还没有意识到这是错误,比如写出了一些冗余的、重复的、矛盾的 state,让你不得不额外使用 useEffect 来处理它们。由于这些错误用法的存在,会让代码的可读性变差,提高了代码的维护成本。

了解这些易犯的错误,可以让我们获得如下收益:

在本文中,将介绍一些关于 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 弹窗中编辑这些数据。可能会是这样的:

这就是问题所在。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?

问题分析

这种方法存在多个问题:

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 是否变化,并相应地触发埋点事件。

问题分析

代码中的问题如下:

解决方案

在许多情况下,可以删除用于监听 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 找到正确的数据项,并在数组中替换它就容易多了。

加载全部内容

相关教程
猜你喜欢
用户评论