在React中应该避免的5个useState错误

399 阅读14分钟

开发任何应用程序最具挑战性的方面通常是管理其状态。然而,我们经常需要在应用程序中管理多个状态片段,例如从外部服务器检索数据或在应用程序中更新数据时。

状态管理的难度是当今存在如此多的状态管理库的原因——并且仍在开发更多的库。幸运的是,React通过钩子(hooks)的形式提供了几种内置解决方案,这使得在React中管理状态变得更加容易。

钩子在React组件开发中变得越来越重要,特别是在函数式组件中,因为它们已经完全取代了传统的基于类(class-based)组件的状态管理方式。useState钩子是React引入的众多钩子之一,尽管useState钩子已经存在几年了,但由于理解不足,开发者仍然容易犯一些常见的错误。

对于新的React开发者或者从基于类的组件迁移到函数式组件的开发者来说,useState钩子可能特别难以理解。在本指南中,我们将探讨React开发者经常犯的前5个关于useState的常见错误以及如何避免它们。

例如:

const [count, setCount] = useState(0);

在上面的代码中,count是一个状态变量,setCount是一个更新它的函数。每当我们需要改变这个状态时,我们调用setCount并传入新值。这只是在组件中轻松处理状态的一种方式,而无需使用类组件。

我们喜欢useState是因为它使我们的组件保持超级干净且非常容易阅读。这是函数式组件成为React中强大特性的主要原因之一。

错误地初始化useState 使用useState钩子时,开发者常犯的一个错误是错误地初始化它。什么是初始化useState?初始化意味着设置它的初始值。

问题在于useState允许你使用任何你想要的东西来定义它的初始状态。然而,没有人直接告诉你的是,根据你的组件在那个状态中期待的是什么,使用错误的数据类型值初始化你的useState可能会导致你的应用程序出现意外的行为,例如无法渲染UI,导致空白屏幕错误。

例如,我们有一个组件期待一个包含用户名称、图片和简介的用户对象状态——在这个组件中,我们正在渲染用户的属性。

使用与期望的数据类型不同的数据类型,例如空状态或值,将导致空白页面错误,如下所示。

import { useState } from "react";
function App() {
  // 初始化状态
  const [user, setUser] = useState();
  // 渲染UI
  return (
    <div className="App">
      <img
        src="https://refine.ams3.cdn.digitaloceanspaces.comundefined"
        alt="profile image"
      />
      <p>User: {user.name}</p>
      <p>About: {user.bio}</p>
    </div>
  );
}
export default App;

输出:

检查控制台可能会抛出如下错误:

新开发者在初始化状态时经常犯这个错误,尤其是当从服务器或数据库获取数据时,因为检索到的数据预期将使用实际的用户对象更新状态。然而,这是不良实践,可能会导致如上所示的预期行为。

初始化useState的首选方式是传递预期的数据类型以避免潜在的空白页面错误。例如,可以传递一个空对象,如下所示。

import { useState } from "react";
function App() {
  // 使用预期的数据类型初始化状态
  const [user, setUser] = useState({});
  // 渲染UI
  // ...
}

我们可以进一步定义用户对象的预期属性,当初始化状态时。

import { useState } from "react";
function App() {
  // 使用预期的数据类型初始化状态
  const [user, setUser] = useState({
    image: "",
    name: "",
    bio: "",
  });
  // 渲染UI
  // ...
}
export default App;

不使用可选链 有时,即使使用预期的数据类型初始化useState也不足以防止意外的空白页面错误。这在尝试访问嵌套在一系列相关对象中的深层对象属性时尤其如此。

你通常会尝试使用点(.)链接运算符通过相关对象链访问此对象,例如user.names.firstName。然而,如果任何链接的对象或属性缺失,页面将会崩溃,用户将得到空白页面错误。

例如:

import { useState } from "react";
function App() {
  // 使用预期的数据类型初始化状态
  const [user, setUser] = useState({});
  // 渲染UI
  return (
    <div className="App">
      <img
        src="https://refine.ams3.cdn.digitaloceanspaces.comundefined"
        alt="profile image"
      />
      <p>User: {user.names.firstName}</p>
      <p>About: {user.bio}</p>
    </div>
  );
}
export default App;

输出错误:

解决此错误并使UI渲染的典型方法是使用条件检查来验证状态的存在,以检查它是否可访问,然后才渲染组件,例如user.names && user.names.firstName,这只有在左侧表达式为真时才评估右侧表达式(如果user.names存在)。然而,这个解决方案是混乱的,因为它将需要对每个对象链进行多次检查。

使用可选链运算符(?.),你可以读取一个深埋在相关对象链中的属性的值,而无需验证每个引用的对象是否有效。可选链运算符(?.)就像点链接运算符(.),只是如果引用的对象或属性缺失(即,或未定义),表达式就会短路并返回未定义的值。简单来说,如果任何链接的对象缺失,它不会继续链操作(短路)。

例如,user?.names?.firstName不会抛出任何错误或破坏页面,因为一旦它检测到usernames对象缺失,它就会立即终止操作。

直接更新useState

对React如何调度和更新状态的理解不足,很容易导致应用程序状态更新中的bug。使用useState时,我们通常会定义一个状态并直接使用设置状态的函数来更新状态。

例如,我们创建一个计数状态和一个附加到按钮的处理函数,当点击时会给状态加一(+1):

import { useState } from "react";
function App() {
  const [count, setCount] = useState(0);
  // 直接更新状态
  const increase = () => setCount(count + 1);
  // 渲染UI
  return (
    <div className="App">
      <span>Count: {count}</span>
      <button onClick={increase}>Add +1</button>
    </div>
  );
}
export default App;

输出:

这按预期工作。然而,直接更新状态是一种不好的做法,当处理一个多个用户使用的活跃应用程序时,可能会导致潜在的错误。为什么?因为与你可能想的相反,React在点击按钮时并不会立即更新状态,就像示例演示中显示的那样。相反,React会获取当前状态的快照,并安排稍后进行更新(+1)以获得性能提升——这发生在毫秒之间,所以对人眼来说并不明显。然而,当计划的更新仍在等待转换时,当前状态可能会被其他事物改变(例如多用户的情况)。计划的更新将不知道这个新事件,因为它只知道在点击按钮时获取的状态快照。

这可能会导致应用程序中的主要错误和奇怪的行为。让我们通过添加另一个异步更新计数状态的按钮来演示这个问题,该按钮在2秒延迟后更新。

import { useState } from "react";
function App() {
  const [count, setCount] = useState(0);
  // 直接更新状态
  const update = () => setCount(count + 1);
  // 2秒后直接更新状态
  const asyncUpdate = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 2000);
  };
  // 渲染UI
  return (
    <div className="App">
      <span>Count: {count}</span>
      <button onClick={update}>Add +1</button>
      <button onClick={asyncUpdate}>Add +1 later</button>
    </div>
  );
}

注意输出中的bug:

注意bug了吗?我们首先点击第一个“Add +1”按钮两次(将状态更新为1 + 1 = 2)。之后,我们点击“Add +1 later”——它获取当前状态(2)的快照,并计划在两秒后将该状态加1。但在计划的更新仍在转换时,我们继续点击“Add +1”按钮三次,将当前状态更新为5(即,2 + 1 + 1 + 1 = 5)。然而,异步计划的更新在两秒后尝试使用它记忆中的快照(2)更新状态(即,2 + 1 = 3),没有意识到当前状态已被更新为5。结果,状态被更新为3而不是6。

这种无意的错误经常困扰那些使用setState(newValue)函数直接更新状态的应用程序。建议的更新useState的方式是通过函数更新,即向setState()传递一个回调函数,在该回调函数中我们传递当前状态的实例,例如,setState(currentState => currentState + newValue)。这将计划更新时的当前状态传递给回调函数,使其在尝试更新之前知道当前状态。

那么,让我们修改示例演示,使用函数更新而不是直接更新。

import { useState } from "react";
function App() {
  const [count, setCount] = useState(0);
  // 直接更新状态
  const update = () => setCount(count + 1);
  // 3秒后直接更新状态
  const asyncUpdate = () => {
    setTimeout(() => {
      setCount((currentCount) => currentCount + 1);
    }, 2000);
  };
  // 渲染UI
  return (
    // ...
  );
}
export default App;

输出:

使用函数更新,setState()函数知道状态已被更新为5,所以它将状态更新为6。

更新特定对象属性

另一个常见错误是修改对象或数组的属性而不是引用本身。

例如,我们初始化一个具有定义的“name”和“age”属性的用户对象。然而,我们的组件有一个按钮,试图只更新用户的名称,如下所示。

import { useState, useEffect } from "react";
export default function App() {
  const [user, setUser] = useState({
    name: "John",
    age: 25,
  });
  // 更新用户状态的属性
  const changeName = () => {
    setUser((user) => (user.name = "Mark"));
  };
  // 渲染UI
  return (
    <div className="App">
      <p>User: {user.name}</p>
      <p>Age: {user.age}</p>
      <button onClick={changeName}>Change name</button>
    </div>
  );
}

初始状态在按钮点击之前:

点击按钮后更新的状态:

如你所见,不是特定的属性被修改,用户不再是一个对象而是被覆盖为字符串“Mark”。为什么会这样?因为setState()会将返回或传递给它的任何值赋为新状态。这个错误在从类组件迁移到函数组件的React开发者中很常见,因为他们习惯于使用this.state.user.property = newValue来更新类组件中的状态。

一种典型的老派方法是创建一个新的对象引用,并将先前的用户对象分配给它,直接修改用户的名称。

import { useState, useEffect } from "react";
export default function App() {
  const [user, setUser] = useState({
    name: "John",
    age: 25,
  });
  // 使用ES6展开运算符更新用户状态的属性
  const changeName = () => {
    setUser((user) => Object.assign({}, user, { name: "Mark" }));
  };
  // 渲染UI
  return (
    // ...
  );
}

更新状态后:

注意,只有用户的名称被修改,而其他属性保持不变。

然而,更新对象或数组的特定属性的理想和现代方式是使用ES6的展开运算符(...)。当使用函数组件中的状态时,这是更新对象或数组的特定属性的理想方式。有了这个展开运算符,你可以轻松地将现有项目的属性解包到新项目中,并同时修改或添加新属性到解包的项目中。

import { useState, useEffect } from "react";
export default function App() {
  const [user, setUser] = useState({
    name: "John",
    age: 25,
  });
  // 使用展开运算符更新用户状态的属性
  const changeName = () => {
    setUser((user) => ({ ...user, name: "Mark" }));
  };
  // 渲染UI
  return (
    // ...
  );
}

结果将与上一个状态相同。一旦按钮被点击,名称属性被更新,而用户的其他属性保持不变。

管理表单中的多个输入字段

通常,通过为每个输入字段手动创建多个useState()函数,并将每个输入字段绑定到相应的输入字段来管理表单中的多个受控输入。例如:

import { useState, useEffect } from "react";
export default function App() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [age, setAge] = useState("");
  const [userName, setUserName] = useState("");
  const [password, setPassword] = useState("");
  const [email, setEmail] = useState("");
  // 渲染UI
  return (
    // ...
  );
}

此外,你必须为这些输入创建一个处理函数,以建立数据的双向流,当输入值被输入时更新每个状态。这可能会相当冗余且耗时,因为它涉及到编写大量降低代码可读性的代码。

然而,只使用一个useState钩子就可以管理表单中的多个输入字段。这可以通过首先给每个输入字段一个唯一的名字,然后创建一个useState()函数来实现,该函数初始化为具有与输入字段相同名称的属性。

import { useState, useEffect } from "react";
export default function App() {
  const [user, setUser] = useState({
    firstName: "",
    lastName: "",
    age: "",
    username: "",
    password: "",
    email: "",
  });
  // 渲染UI
  return (
    // ...
  );
}

之后,我们创建一个处理事件函数,该函数更新用户对象的特定属性,以反映表单中的更改,每当用户输入某些内容时。这可以使用展开运算符和动态访问触发处理程序函数的特定输入元素的名称来实现,使用event.target.elementsName = event.target.value

换句话说,我们检查通常传递给事件函数的事件对象,以获取触发函数的目标元素名称(与状态中的属性名称相同),并使用该目标元素中的关联值更新它,如下所示:

import { useState, useEffect } from "react";
export default function App() {
  const [user, setUser] = useState({
    // ...
  });
  // 更新特定输入字段
  const handleChange = (e) =>
    setUser((prevState) => ({ ...prevState, [e.target.name]: e.target.value }));
  // 渲染UI
  return (
    // ...
  );
}

通过这种实现,事件处理函数将为每个用户输入触发。在这个事件函数中,我们有一个setUser()状态函数,它接受用户之前/当前的状态,并使用展开运算符解包这个用户状态。然后我们检查事件对象,获取触发函数的目标元素名称(与状态中的属性名称相关联)。一旦获得这个属性名称,我们就将其修改为反映表单中的用户输入值。

奖励:使用useState时优化性能

我们想给您一些提示,关于在使用React应用程序中的useState时如何优化性能。默认情况下,每次useState的状态变化都会重新渲染我们的组件。这完全没问题,通常工作得很好。

在更大更复杂的应用程序中,这在最坏的情况下可能会成为性能瓶颈,优化的一种方式是使用函数更新。如果您的新状态依赖于之前的状态,那么使用更新的函数可以确保没有不必要的渲染。这里有一个快速的例子:

const [count, setCount] = useState(0);
const increment = () => {
  setCount((prevCount) => prevCount + 1);
};

另一个需要始终记住的重要提示是:尽量保持应用程序中状态变量的数量最少。将相关的状态部分组合到一个状态对象中,使用一个对象而不是多次调用useState。这确实可以帮助减少渲染次数,使您的代码更易于管理。

最后,React.memo用于函数组件或PureComponent用于类组件将防止如果props或状态没有变化则重新渲染。

优化useState的状态管理将为我们的应用程序带来巨大的性能提升,提供更快的速度和更好的用户体验。

奖励:对于复杂的状态管理使用useReducer

处理React中复杂状态的另一个重要提示是使用useReducer。尽管useState对于处理简单状态很棒,但useReducer有时在处理更大的状态逻辑时可能更有效率。useReducer的运作方式与useState相同,但它让我们通过一个reducer函数来处理状态转换。

这在两种情况下很有帮助:状态具有子值,或者下一个状态依赖于前一个状态。这里有一个快速的例子:

const initialState = { count: 0 };
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}
const [state, dispatch] = useReducer(reducer, initialState);
const increment = () => {
  dispatch({ type: "increment" });
};
const decrement = () => {
  dispatch({ type: "decrement" });
};

通过这种方式,当使用useReducer时,代码变得更加干净和易于维护。它与Redux中发生的事情非常相似,所以如果您将来要使用Redux,这是一个很好的垫脚石。我发现useReducer对于管理复杂状态非常有用。它在可维护性和可读性方面为我们的代码带来了奇迹。

结论

作为一个创建高度交互式用户界面的React开发者,您可能已经犯了上述一些错误。希望这些有用的useState实践将帮助您避免在使用useState钩子的过程中犯下这些潜在的错误,同时构建您的React驱动应用程序。