翻译自:https://blog.logrocket.com/modern-guide-react-state-patterns/

介绍

自 2013 年成立以来,React 推出了一系列强大的工具,以帮助开发人员摆脱创建 Web 应用程序的一些细节,并使他们能够专注于重要的事情。
尽管 React 有许多特性在社区中一直很受欢迎,但我们一次又一次地发现,许多人都会有同样的疑问:我们如何使用 React 来管理复杂的状态?
在本文中,我们将研究什么是状态,我们如何组织它,以及如何伴随着应用程序规模的增长而采用的不同模式。

了解React中的状态

就其最纯粹的形式而言,React 可以被视为一个蓝图。 通过定义状态来描述应用程序将要展现的方式。 React 更偏向声明式而不是命令式,它看起来有一点奇特,在编码中,你需要编写想要发生的事情,而不是编写实现它的步骤。 因此,正确的管理状态变得极其重要,因为状态控制着应用程序的行为方式。
illustrating-react-state.png

State in action

在我们开始之前,简要讨论一下什么是状态会很有帮助。 就我个人而言,我认为状态是随时间变化并直接影响组件行为的可变值的集合。
State 与 props 非常相似,但不同的是 state 可以在定义它的上下文中更改,而接收到的 props 无法在不传递回调函数的情况下更改。
我们来看一下下面的例子:

  1. const UserList = () => {
  2. const [users, setUsers] = useState([])
  3. useEffect(() => {
  4. const getUsers = async () => {
  5. const response = await fetch("https://myuserapi.com/users")
  6. const users = await response.json()
  7. setUsers(users)
  8. }
  9. getUsers()
  10. }, [])
  11. if (users.length < 1) return null;
  12. return <ul>
  13. {users.map(user => <li>{user.name}</li>)}
  14. </ul>
  15. }

在此示例中,我们在组件挂载时从 API 获取用户数据,并在收到响应后更新用户数组。 我们假设调用将始终成功以降低示例的复杂性。
我们可以看到 state 被用来渲染带有用户名的列表项,如果数组中没有用户,它将返回 null。 状态随时间变化并用于直接影响组件行为。
这里值得注意的另一件事是,我们使用 React 的内置状态管理方法 useState Hook。根据你的应用程序和状态管理的复杂程度,有些场景可能只需要使用 React 的内置 Hook 来管理你的状态。
然而,从 React 的大量状态管理解决方案可以清楚地看出,内置的状态管理方法往往是不够用的。 让我们来看看其中的一些原因。

属性钻取

让我们考虑一个稍微复杂的应用程序。 随着应用程序的增长,你不得不创建多层组件以分离关注点或提高代码的可读性。 当在树中不同位置的多个组件需要共享状态时,就会出现问题。
basic-react-component-tree.png
如果我们想为 UserMenu 和 Profile 组件提供用户数据,我们必须将状态放在 App 中,因为这是唯一可以将数据向下传播到每个需要它的组件的地方。这意味着我们将通过可能不需要数据的组件(例如Dashboard和Settings)传递它,并污染它们。
现在,如果您需要操作另一个组件中的数据怎么办?好吧,您需要为需要进行更新的组件提供更新属性的回调函数(上一个示例中的 setUsers 函数),添加另一个要向下传播的属性——所有这些都是为了一个状态。现在想象如果通过添加另外五个属性来复合它。它很快就会失控。
这时候别人就会跟你说,“你会意识到需要一个状态管理的库来控制这种情况”
对我来说,这意味着当前通过多层组件钻取属性和传递修改属性的回调函数的开发方式是否仍可以很好的满足我的开发需求。就我个人而言,我有一个硬性限制:使用这种传递不超过三层组件 ;如果需要更深层级的属性传递,我会寻求另一个解决方案。但在那之前,我坚持使用 React 中的内置功能。
额外使用状态库也有成本,在您确定绝对需要它之前,没有理由给应用程序增加不必要的复杂性。

关于重复渲染的问题

由于一旦状态更新,React 会自动触发重新渲染,因此随着应用程序代码量不断增长,内部状态处理就容易出现问题。 组件树的不同节点可能需要共享一些数据,为这些组件提供共享数据的唯一方法是将状态提升到最近的公共祖先。
随着应用程序的增长,很多状态将需要在组件树中向上提升,这将增加 prop 钻取的级别并在状态更新时导致不必要的重新渲染。

关于测试

将所有状态保存在组件中的另一个问题是使得状态处理变得难以测试。 有状态组件要求您设置复杂的测试场景,您可以在其中调用触发状态并匹配结果的操作。 以这种方式测试状态很快就会变得复杂,改变状态管理方式通常需要完全重写你对应的组件测试用例。

使用Redux管理状态

就状态库而言,用于管理状态的最著名和最广泛使用的库之一是 Redux。 Redux 于 2015 年推出,是一个状态容器,可帮助您编写可维护、可测试的状态。 它基于 Flux 的原则(Flux 是 Facebook 的开源的一种架构模式)
illustrating-redux-state.png
本质上,Redux 提供了一个全局状态对象,为每个组件提供它需要的状态,只重新渲染接收状态的组件(及其子组件)。 Redux 管理基于 action 和 reducer 的语句。 让我们快速看一下例子:
redux-state-management-concept.png
在这个例子中,组件dispatch一个action到reducer。 reducer 更新状态,进而触发重新渲染。

State

状态是单一的数据源; 它始终代表您的状态。 它的工作是为组件提供状态。 例子:

{
  users: [
    { id: "1231", username: "Dale" }, 
    { id: "1235", username: "Sarah"}
  ]
}

Actions

Actions是用于改变状态变化的预定义对象。 它们是遵循特定契约的对象字面量:

{
  type: "ADD_USER",
  payload: { user: { id: "5123", username: "Kyle" } }
}

Reducers

reducer 是一个接收 action 并负责返回更新后的 state 对象的处理函数:

const userReducer = (state, action) => {
    switch (action.type) {
       case "ADD_USER":
          return { ...state, users: [...state.users, action.payload.user ]}
       default:
          return state;
    }
}

最新的React 状态模式

虽然 Redux 仍然是一个很棒的工具,但随着时间的推移,React 已经发展并让我们可以使用新技术。 此外,状态管理中引入了新的思想和理念,从而产生了许多不同的状态处理方式。 让我们在本节中研究一些更新的模式。

useReducer和Context API

React 16.8 引入了 Hooks,并为我们提供了通过应用程序共享功能的新方法。 因此,我们现在可以访问 React 内置的称为 useReducer 的 Hook,它允许我们开箱即用地创建reducer。 如果我们将此功能与 React 的 Context API 配对,我们现在就有了一个轻量级的类似 Redux 的解决方案,我们可以通过我们的应用程序使用它。
让我们看一个使用 reducer 处理 API 调用的示例:

const apiReducer = (state = {}, action) => {
  switch (action.type) {
      case "START_FETCH_USERS":
        return { 
               ...state, 
               users: { success: false, loading: true, error: false, data: [] } 
         }
      case "FETCH_USERS_SUCCESS": 
        return {
              ...state,
              users: { success: true, loading: true, error: false, data: action.payload.data}
        }
      case "FETCH_USERS_ERROR":
        return {
           ...state,
           users: { success: false, loading: false, error: true, data: [] }
        }
      case default:
         return state 
    }
}

reducer已经有了,我们来创建一个context

const apiContext = createContext({})
export default apiContext;

有了这两部分,我们现在可以通过组合它们来创建一个高度灵活的状态管理系统:

import apiReducer from './apiReducer'
import ApiContext from './ApiContext
const initialState = { users: { success: false, loading: false, error: false, data: []}}
const ApiProvider = ({ children }) => {
    const [state, dispatch] = useReducer(apiReducer, initialState)
    return <ApiContext.Provider value={{ ...state, apiDispatcher: dispatch }}>
      {children}
    </ApiContext.Provider>
}

这些完成后,我们需要在根目录里提供一个provider

ReactDOM.render(document.getElementById("root"), 
   <ApiProvider>
     <App />
   </ApiProvider>
)

现在,作为 App 子组件的任何组件都可以访问我们的 ApiProviders 提供状态和apiDispatcher函数,以便通过以下方式触发操作和访问状态:

import React, { useEffect } from 'react'
import ApiContext from '../ApiProvider/ApiContext

const UserList = () => {
     const { users, apiDispatcher } = useContext(ApiContext)
     useEffect(() => {
        const fetchUsers = () => {
           apiDispatcher({ type: "START_FETCH_USERS" })
           fetch("https://myapi.com/users")
              .then(res => res.json())
              .then(data =>  apiDispatcher({ type: "FETCH_USERS_SUCCCESS", users: data.users }))
              .catch((err) => apiDispatcher({ type: "START_FETCH_ERROR" }))
        }
        fetchUsers()
     }, [])

     const renderUserList = () => {
         // ...render the list 
     }
     const { loading, error, data } = users; 
     return <div>
        <ConditionallyRender condition={loading} show={loader} />
        <ConditionallyRender condition={error} show={loader} />
        <ConditonallyRender condition={users.length > 0} show={renderUserList} />
     <div/>      
}

使用状态机和 XState 管理状态

另一种管理状态的流行方式是使用状态机。 简而言之,状态机是专用的状态容器,可以随时保存有限数量的状态。 这使得状态机非常具有可预测性。 由于每个状态机都遵循相同的模式,因此您可以将状态机插入到生成器中并接收包含数据流概览的状态图。
xstate-state-chart.png
在保持可预测性方面,状态机通常遵循比 Redux 更严格的规则。 在 React 状态管理领域,XState 是最流行的用于创建、解释和使用状态机的库。
让我们看一下 XState 文档中的示例:

import { createMachine, interpret, assign } from 'xstate';

const fetchMachine = createMachine({
  id: 'Dog API',
  initial: 'idle',
  context: {
    dog: null
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      invoke: {
        id: 'fetchDog',
        src: (context, event) =>
          fetch('https://dog.ceo/api/breeds/image/random').then((data) =>
            data.json()
          ),
        onDone: {
          target: 'resolved',
          actions: assign({
            dog: (_, event) => event.data
          })
        },
        onError: 'rejected'
      },
      on: {
        CANCEL: 'idle'
      }
    },
    resolved: {
      type: 'final'
    },
    rejected: {
      on: {
        FETCH: 'loading'
      }
    }
  }
});

const dogService = interpret(fetchMachine)
  .onTransition((state) => console.log(state.value))
  .start();

dogService.send('FETCH');

useSWR

多年来,状态管理变得越来越复杂。 虽然适当的状态管理加上像 React 这样的库让我们可以做一些令人惊奇的事情,但毫无疑问,我们正在将很多复杂性转移到前端。 随着复杂性的增加,我们也带来了更多的认知负担、更多的间接、潜在的bug以及更多需要彻底测试的代码。
在这方面,useSWR 已经是一股清流。 将此库与 React Hooks 的功能相结合,可以产生不可思议的效果。 该库使用 HTTP 缓存技术 stale-while-revalidate,这意味着它会保留先前数据集的本地缓存,并在后台通过 API 同步以获取新数据。
这使应用程序保持更好的性能和用户友好度,因为 UI 可以在等待查询数据时仍然可以被响应。 让我们来看看我们如何利用这个库并减少状态管理的一些复杂性。

// Data fetching hook
import useSWR from 'swr'

const useUser(userId) {
    const fetcher = (...args) => fetch(...args).then(res => res.json())
    const { data, error } = useSWR(`/api/user/${userId}`, fetcher)

    return { 
      user: data,
      error,
      loading: !data && !error
    }
}

export default useUser

现在我们有了一个可重用的 Hook,我们可以利用它来将数据放入我们的组件视图中。 无需创建reducer、action或Connect组件和状态来获取数据——只需在需要数据的组件中导入和使用 Hook:

import Loader from '../components/Loader'
import UserError from '../components/UserError'
import useUser from '../hooks/useUser';

const UserProfile = ({ id }) => {
   const { user, error, loading } = useUser(id);
   if (loading) return <Loader />
   if (error) return <UserError />
   return <div>
        <h1>{user.name}</h1>
   </div>
}

在另一个组件中

import Loader from '../components/Loader'
import UserError from '../components/UserError'
import useUser from '../hooks/useUser';

const Header = ({ id }) => {
  const { user, error, loading } = useUser(id);
  if (loading) return <Loader />
  if (error) return <UserError />
  return <div>
    <Avatar img={user.imageUrl} />         
    ...
  </div>
}

此方法允许您轻松地传递一个可以访问共享数据对象的 Hook,因为 useSWR 的第一个参数是参数:

const { data, error } = useSWR(`/api/user/${userId}`, fetcher)

基于这个参数,我们的请求被去重、缓存并在我们所有使用 useUser Hook 的组件之间共享。 这也意味着只要参数匹配,只会向 API 发送一个请求。 即使我们有 10 个组件使用 useUser Hook,只要 useSWR 键匹配,也只会发送一个请求。

总结

如果 React 是一个随时展示您的应用程序状态的画布,那么如何正确处理状态是非常重要的。在本文中,我们研究了在 React 应用程序中处理状态的各种方法,事实上,我们可以包括更多。
Recoil 和 Jotai,更不用说 React Query 和 MobX,在这样的讨论中肯定是相关的,事实上我们有很多不同的状态库是一件好事。 它促使我们尝试不同的事物,并促使库作者不断做得更好。 这就是前进的方向。
现在,您应该为您的项目选择哪种解决方案? 这是一个我无法回答的问题,但我会发表我自己的看法。
就个人而言,我倾向于支持引入最少复杂性的库。 拥有 Redux 之类的工具供我们使用真是太棒了,有时我们需要它们,但在您感到痛苦之前,我会寻求最简单的解决方案。
对我来说,使用 useSWR 是一个启示,它显着降低了我最近编写的应用程序的间接性和复杂性。