React 多个 useState 地狱的解药?

263 阅读7分钟

dev.to/builderio/a…

你有没有发现自己陷入 React useState 钩子地狱?

是的,这个好东西:

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能力提供控制状态转换的功能。

回到 using 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-ify 东西

随着事情变得越来越复杂,您甚至可以选择使用 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 和这种模式的忠实粉丝。它有它的优点,但我认为一旦你想开始在新的动作模式中分层,我个人认为MobxZustandXState更可取。

也就是说,能够在没有任何额外依赖项的情况下使用这种模式是一种优雅的方式,所以我会给那些喜欢这种格式的人。

共享减速器

另一个好处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不可变的。如果您不小心改变了 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>
    </>
  );
}

这当然是完全可选的,只有在解决了您正在积极遇到的问题时才需要。

那么什么时候应该使用useStatevsuseReducer呢?

尽管我对 充满热情,并指出了您 ***** 可以*  *** useReducer * 使用它的许多地方,但请记住不要过早抽象。******

一般来说,您可能仍然可以使用useState. 我会考虑逐步采用,useReducer因为您的状态和验证要求开始变得更加复杂,需要付出额外的努力。

然后,如果你useReducer经常采用复杂的对象,并且经常遇到变异的陷阱,那么引入Immer可能是值得的。

那,或者如果您已经达到状态管理复杂性的地步,您可能想要查看一些更具可扩展性的解决方案,例如MobxZustandXState来满足您的需求。

但不要忘记,从简单开始,只在需要时增加复杂性。

谢谢你,大卫

这篇文章的灵感来自 David Khourshid, XState as Stately的创建者,他useReducer用他史诗般的推特帖子让我看到了 hook 的可能性:

未知的推文媒体内容

David K. 🎹个人资料图片

大卫 K. 🎹

@davidkpiano

推特标志

⚛️ 这是一个有趣的 React 提示:useReducer 是一个更好的 useState,而且它比您想象的更容易采用。

将相关的值组合在一起并将它们传播到减速器中。然后,更新只是:

updateThing({ prop: newValue })

useReducer 还有更多好处......

下午 16:04 - 2022 年 12 月 19 日

推特回复动作 Twitter 转发操作 推特喜欢动作

关于我