useReducer Hook指南

269 阅读15分钟

useReducer是与React v16.8一起发布的附加Hook之一。作为useState Hook的替代品,useReducer有助于管理React应用程序中的复杂状态逻辑。当与useContext等其他Hook结合使用时,useReducer可以很好地替代Redux、Recoil或MobX。在某些情况下,它是一个更好的选择。

虽然Redux、Recoil和MobX通常是管理大型React应用程序全局状态的最佳选择,但许多React开发者在本可以有效地使用Hooks处理状态时,过早地转向这些第三方状态管理库。在本文章中,我们将深入探讨useReducer Hook。

useReducer Hook是如何工作的?

useReducer Hook用于存储和更新状态,就像useState Hook一样。它接受一个reducer函数作为其第一个参数,并将初始状态作为第二个参数。useReducer返回一个数组,包含当前状态值和一个dispatch函数,你可以向其传递一个动作并在之后调用它。虽然这与Redux使用的模式类似,但有一些差异。

例如,useReducer函数与特定的reducer紧密耦合。我们只向该reducer分发动作对象,而在Redux中,分发函数将动作对象发送到存储。在分发时,组件不需要知道哪个reducer将处理该动作。

对于那些可能不熟悉Redux的人,我们将更深入地探讨这个概念。Redux有三个主要构建块:

  • 存储:一个不可变的存储应用程序状态数据的对象
  • Reducer:一个函数,根据动作type返回一些状态数据
  • 动作:一个告诉reducer如何更改状态的对象。它必须包含一个type属性,并且可以包含一个可选的payload属性

让我们看看这些构建块如何与管理useReducer Hook的状态相比较。以下是Redux中的存储示例:

import { createStore } from 'redux'

const store = createStore(reducer, [preloadedState], [enhancer])

在下面的代码中,我们使用useReducer Hook初始化状态:

const initialState = { count: 0 }

const [state, dispatch] = useReducer(reducer, initialState)

Redux中的reducer函数将接受之前的应用程序状态和被分发的动作,计算下一个状态,并返回新对象。Redux中的reducers遵循以下语法:

(state = initialState, action) => newState

考虑以下示例:

// 注意state = initialState和返回一个新状态

const reducer = (state = initialState, action) => {
   switch (action.type) {
      case 'ITEMS_REQUEST':
         return Object.assign({}, state, {
            isLoading: action.payload.isLoading
         })
      caseITEMS_REQUEST_SUCCESS’:
         return Object.assign({}, state, {
            items: state.items.concat(action.items),
            isLoading: action.isLoading
         })
      default:
         return state;
   }
}
export default reducer;

useReducer不使用(state = initialState, action) => newState的Redux模式,因此其reducer函数的工作方式略有不同。下面的代码显示了如何使用React的useReducer创建reducers:

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();
  }
}

以下是一个可以在Redux中执行的动作示例:

{ type: ITEMS_REQUEST_SUCCESS, payload: { isLoading: false } }

// 动作创建者
export function itemsRequestSuccess(bool) {
   return {
      type: ITEMS_REQUEST_SUCCESS,
      payload: {
      isLoading: bool,
    }
   }
}

// 使用Redux分发动作
dispatch(itemsRequestSuccess(false))    // 要调用分发函数,你需要将动作作为参数传递给分发函数

useReducer中的动作工作方式类似:

// 不是完整代码
switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    default:
      throw new Error();
  }

// 使用useReducer分发动作
 <button onClick={() => dispatch({type: 'increment'})}>Increment</button>

如果上述代码中的action类型是increment,我们的状态对象将增加1

reducer函数

JavaScript的reduce()方法在数组的每个元素上执行一个reducer函数,并返回一个单一值。reduce()方法接受一个reducer函数,该函数本身可以接受多达四个参数。下面的代码片段说明了reducer的工作方式:

const reducer = (accumulator, currentValue) => accumulator + currentValue;
[2, 4, 6, 8].reduce(reducer)
// 预期输出:20

在React中,useReducer本质上接受一个reducer函数,该函数返回一个单一值:

  const [count, dispatch] = useReducer(reducer, initialState);

reducer函数本身接受两个参数并返回一个值。第一个参数是当前状态,第二个是动作。状态是我们正在操作的数据。reducer函数接收一个动作,由dispatch函数执行:

function reducer(state, action) { }

dispatch({ type: 'increment' })

动作就像你传递给reducer函数的指令。基于指定的动作,reducer函数执行必要的状态更新。如果你之前使用过像Redux这样的状态管理库,那么你可能已经遇到过这种状态管理模式。

指定初始状态

传递给useReducer Hook的第二个参数是初始状态,它代表默认状态:

const initialState = { count: 1 }

// 我们的useReducer所在的位置
const [state, dispatch] = useReducer(reducer, initialState, initFunc)

如果你没有向useReducer传递第三个参数,它将把第二个参数作为初始状态。第三个参数,即init函数,是可选的。这种模式也遵循Redux状态管理的一个黄金规则:状态应该通过发出动作来更新。永远不要直接写入状态。

然而,值得注意的是,Redux的state = initialState约定与useReducer的工作方式不同,因为初始值有时取决于props。

延迟创建初始状态

在编程中,延迟初始化是一种策略,它将对象的创建、值的计算或其他一些昂贵的过程推迟到第一次需要时。

如上所述,useReducer可以接受第三个参数,这是一个可选的init函数,用于延迟创建初始状态。它允许你将计算初始状态的逻辑提取到reducer函数之外,如下所示:

const initFunc = (initialCount) => {
    if (initialCount !== 0) {
        initialCount=+0
    }
  return {count: initialCount};
}

// 我们的useReducer所在的位置
const [state, dispatch] = useReducer(reducer, initialCount, initFunc);

如果值不是0,上面的initFunc将在页面挂载时将initialCount重置为0,然后返回状态对象。注意,这个initFunc是一个函数,不仅仅是一个数组或对象。

React中的dispatch

dispatch函数接受一个表示我们想要执行的动作类型的参数。基本上,它将动作类型发送给reducer函数以执行其工作,当然是更新状态。把dispatch想象成一个信使,它将指令(动作)传递给状态管理器(reducer)。

要由我们的组件分发的动作应该总是表示为一个包含typepayload键的对象,其中type作为分发动作的标识符,payload是这个动作将添加到状态的信息。dispatchuseReducer Hook返回的第二个值,可以在JSX中用于更新状态:

// 创建我们的reducer函数
function reducer(state, action) {
  switch (action.type) {
   // ...
      case 'reset':
          return { count: action.payload };
    default:
      throw new Error();
  }
}

// 我们的useReducer所在的位置
const [state, dispatch] = useReducer(reducer, initialCount, initFunc);

// 在按钮点击时使用dispatch函数更新状态
<button onClick={() => dispatch({type: 'reset', payload: initialCount})}> 重置 </button>

注意我们的reducer函数如何使用从dispatch函数传递的payload。它将我们的状态对象设置为payload,即initialCount是什么。请注意,我们可以将dispatch函数作为props传递给其他组件,这允许我们用useReducer替换Redux。

假设我们有一个组件,我们想要将dispatch函数作为props传递。我们可以很容易地从父组件中做到这一点:

<Increment count={state.count} handleIncrement={() => dispatch({type: 'increment'})}/>

现在,在子组件中,我们接收props,当发出时,将触发dispatch函数并更新状态:

<button onClick={handleIncrement}>Increment</button>

退出分发

如果useReducer Hook返回的值与当前状态相同,React将不会渲染子组件或触发效果,因为它使用Object.is比较算法。

使用useReducer Hook构建一个简单的计数器应用程序

现在,让我们将我们的知识应用于使用useReducer Hook构建一个简单的计数器应用程序:

import React, { useReducer } from 'react';

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 }
    case 'reset':
      return {count: state.count = 0}
    default:
     return { count: state.count  }
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <div>
      Count: {state.count}
       <br />
       <br/>
       <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
       <button onClick={() => dispatch({ type: 'decrement'})}>Decrement</button>
       <button onClick={() => dispatch({ type: 'reset'})}>Reset</button>
    </div>
  );
};

export default Counter;

首先,我们用0初始化状态,然后我们创建一个reducer函数,它接受我们的计数的当前状态作为参数和一个动作。状态由reducer根据动作类型更新。incrementdecrementreset都是动作类型,当分发时,相应地更新我们的应用程序的状态。

要增加状态计数const initialState = { count: 0 },我们只需在分发increment动作类型时将count设置为state.count + 1

useStateuseReducer

useState是一个用于管理简单状态转换的基本Hook,而useReducer是一个用于管理更复杂状态逻辑的附加Hook。然而,值得注意的是,useState在内部使用useReducer,这意味着你可以用useReducer做所有可以用useState做的事情。

然而,这两个Hook之间有一些主要区别。使用useReducer,你可以避免将回调通过不同级别的组件传递。相反,useReducer允许你传递一个提供的dispatch函数,这反过来可以提高触发深层更新的组件的性能。

然而,这并不意味着useState的更新函数在每次渲染时都被新调用。当你有复杂的逻辑更新状态时,你不会直接使用setter来更新状态。相反,你会编写一个复杂的函数,该函数反过来会用更新后的状态调用setter。

因此,建议使用返回dispatch方法的useReducer,它在重新渲染之间不会改变,并允许你在reducers中拥有操作逻辑。

还值得注意的是,使用useState时,状态更新函数被调用以更新状态,但使用useReducer时,dispatch函数被调用,并且至少传递一个类型的动作给它。现在,让我们看看两个Hook是如何声明和使用的。


使用useState声明状态

useState返回一个数组,包含当前状态值和用于更新状态的setState方法:

const [state, setState] = useState('default state');

使用useReducer声明状态

useReducer返回一个数组,包含当前状态值和一个dispatch方法,逻辑上实现了与setState相同的目标,更新状态:

const [state, dispatch] = useReducer(reducer, initialState)

使用useState更新状态如下:

<input type='text' value={state} onChange={(e) => setState(e.currentTarget.value)} />

使用useReducer更新状态如下:

<button onClick={() => dispatch({ type: 'decrement'})}>Decrement</button>

我们将在本教程后面更深入地讨论dispatch函数。可选地,动作对象也可以有一个payload

<button onClick={() => dispatch({ type: 'decrement', payload: 0})}>Decrement</button>

useReducer在管理复杂状态形状时很有用,例如,当状态包含不仅仅是原始值,如嵌套数组或对象时:

const [state, dispatch] = useReducer(loginReducer,
  {
    users: [
      { username: 'Philip', isOnline: false},
      { username: 'Mark', isOnline: false },
      { username: 'Tope', isOnline: true},
      { username: 'Anita', isOnline: false },
    ],
    loading: false,
    error: false,
  },
);

管理这种本地状态更容易,因为参数相互依赖,所有逻辑都可以封装到一个reducer中。

何时使用useReducer Hook

随着应用程序的增长,你可能会处理更复杂的状态转换,此时使用useReducer会比较好。useReduceruseState提供更可预测的状态转换,这在你想要在渲染函数中管理状态时变得尤为重要,就像状态变化变得如此复杂,你想要有一个管理状态的地方一样。

当你不再管理原始数据,即字符串、整数或布尔值,而必须管理复杂的对象,如数组和额外的原始数据时,useReducer是更好的选择。

创建一个登录组件

为了更好地理解何时使用useReducer,让我们创建一个登录组件,并比较我们如何使用useStateuseReducer Hook来管理状态。

首先,我们使用useState创建登录组件:

import React, { useState } from 'react';

export default function LoginUseState() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, showLoader] = useState(false);
  const [error, setError] = useState('');
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const onSubmit = async (e) => {
    e.preventDefault();
    setError('');
    showLoader(true);
    try {
        await new Promise((resolve, reject) => {
          setTimeout(() => {
            if (username === 'ejiro' && password === 'password') {
              resolve();
            } else {
              reject();
            }
          }, 1000);
        });
          setIsLoggedIn(true);

    } catch (error) {
      setError('Incorrect username or password!');
      showLoader(false);
      setUsername('');
      setPassword('');
    }
  };
  return (
    <div className='App'>
      <div className='login-container'>
        {isLoggedIn ? (
          <>
            <h1>Welcome {username}!</h1>
            <button onClick={() => setIsLoggedIn(false)}>Log Out</button>
          </>
        ) : (
          <form className='form' onSubmit={onSubmit}>
            {error && <p className='error'>{error}</p>}
            <p>Please Login!</p>
            <input
              type='text'
              placeholder='username'
              value={username}
              onChange={(e) => setUsername(e.currentTarget.value)}
            />
            <input
              type='password'
              placeholder='password'
              autoComplete='new-password'
              value={password}
              onChange={(e) => setPassword(e.currentTarget.value)}
            />
            <button className='submit' type='submit' disabled={isLoading}>
              {isLoading ? 'Logging in...' : 'Log In'}
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

注意我们如何处理所有这些状态转换,如usernamepasswordisLoadingerrorisLoggedIn,而我们真的应该更专注于用户想要在登录组件上执行的操作。

我们使用了五个useState Hook,并且必须担心这些状态何时转换。我们可以将上面的代码重构为使用useReducer,并将我们所有的逻辑和状态转换封装在一个reducer函数中:

import React, { useReducer } from 'react';

function loginReducer(state, action) {
  switch (action.type) {
    case 'field': {
      return {
        ...state,
        [action.fieldName]: action.payload,
      };
    }
    case 'login': {
      return {
        ...state,
        error: '',
        isLoading: true,
      };
    }
    case 'success': {
      return {
        ...state,
        isLoggedIn: true,
        isLoading: false,
      };
    }
    case 'error': {
      return {
        ...state,
        error: 'Incorrect username or password!',
        isLoggedIn: false,
        isLoading: false,
        username: '',
        password: '',
      };
    }
    case 'logOut': {
      return {
        ...state,
        isLoggedIn: false,
      };
    }
    default:
      return state;
  }
}

const initialState = {
  username: '',
  password: '',
  isLoading: false,
  error: '',
  isLoggedIn: false,
};

export default function LoginUseReducer() {
  const [state, dispatch] = useReducer(loginReducer, initialState);
  const { username, password, isLoading, error, isLoggedIn } = state;
  const onSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: 'login' });
    try {
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          if (username === 'ejiro' && password === 'password') {
            resolve();
          } else {
            reject();
          }
        }, 1000);
      });
      dispatch({ type: 'success' });
    } catch (error) {
      dispatch({ type: 'error' });
    }
  };

  return (
    <div className='App'>
      <div className='login-container'>
        {isLoggedIn ? (
          <>
            <h1>Welcome {username}!</h1>
            <button onClick={() => dispatch({ type: 'logOut' })}>
              Log Out
            </button>
          </>
        ) : (
          <form className='form' onSubmit={onSubmit}>
            {error && <p className='error'>{error}</p>}
            <p>Please Login!</p>
            <input
              type='text'
              placeholder='username'
              value={username}
              onChange={(e) =>
                dispatch({
                  type: 'field',
                  fieldName: 'username',
                  payload: e.currentTarget.value,
                })
              }
            />
            <input
              type='password'
              placeholder='password'
              autoComplete='new-password'
              value={password}
              onChange={(e) =>
                dispatch({
                  type: 'field',
                  fieldName: 'password',
                  payload: e.currentTarget.value,
                })
              }
            />
            <button className='submit' type='submit' disabled={isLoading}>
              {isLoading ? 'Logging in...' : 'Log In'}
            </button>
          </form>
        )}
      </div>
    </div>
  );
}

注意新的实现如何使用useReducer使我们更专注于用户将执行的操作。例如,当分发login动作时,我们可以清楚地看到我们想要发生什么。我们希望返回当前状态的副本,将error设置为空字符串,并将isLoading设置为true

case 'login': {
  return {
    ...state,
    error: '',
    isLoading: true,
  };
}

我们当前实现的美妙之处在于,我们不再需要关注状态转换。相反,我们专注于要执行的用户操作。

让我们扩展现有示例,展示如何处理API调用与useReducer,包括实际的API调用并用响应数据更新表格:

import React, { useReducer, useEffect } from 'react';
import axios from 'axios';

const initialState = {
  users: [],
  loading: false,
  error: null
};

function userReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, users: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function UserList() {
  const [state, dispatch] = useReducer(userReducer, initialState);

  useEffect(() => {
    const fetchUsers = async () => {
      dispatch({ type: 'FETCH_START' });
      try {
        const response = await axios.get('https://api.example.com/users');
        dispatch({ type: 'FETCH_SUCCESS', payload: response.data });
      } catch (error) {
        dispatch({ type: 'FETCH_ERROR', payload: error.message });
      }
    };

    fetchUsers();
  }, []);

  const { users, loading, error } = state;

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody>
        {users.map(user => (
          <tr key={user.id}>
            <td>{user.id}</td>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

上述示例展示了如何使用useReducer来管理API调用的状态,包括其加载和错误状态,以及如何用获取到的数据更新表格。

记住,选择useStateuseReducer通常取决于你的状态逻辑的复杂性以及个人偏好。不要害怕从useState开始,如果你发现你的状态逻辑变得太复杂,就重构为useReducer

何时不使用useReducer Hook

尽管可以使用useReducer Hook来处理应用程序中的复杂状态逻辑,但重要的是要注意,在某些场景下,像Redux这样的第三方状态管理库可能是更好的选择:

  1. 当你的应用程序需要单一事实来源时
  2. 当你想要更可预测的状态时
  3. 当提升状态到顶层组件不再足够时
  4. 当你需要在页面刷新后持久化状态时

使用像Redux这样的库,与使用纯React和useReducer相比,也有一些权衡。例如,Redux有一个陡峭的学习曲线,这可以通过使用Redux Toolkit来最小化,而且它绝对不是编写代码的最快方式。相反,它的目的是给你一个绝对和可预测的方式来管理你的应用程序的状态。

useReducer的常见问题解答

以下是你使用useReducer时可能遇到的最常见问题。它们大多是由开发者错误引起的,而不是Hook本身的问题:

  • 不一致的动作:这可能是由于分发相同动作的错误或不一致的类型或负载引起的,导致错误。为了解决这个问题并确保所有动作在整个应用程序中是一致的,你可以尝试使用像Redux的动作创建者这样的概念。
  • 错误的状态更新:另一个常见的错误是reducer函数内状态的更新方式。当直接在函数内操作初始状态时,就会发生这种情况:
state.count += 1;
return state;

这可能导致错误。解决方案是使用扩展运算符或其他不可变技术,以便初始状态不受影响:

return {…state, count: state.count + 1 }
  • 过于复杂的函数:另一个问题是状态可能变得过于复杂,难以管理。在这种情况下,考虑将其拆分为单独的函数以简化它。

React 19中的useReducer

虽然useReducer在新的React 19发布中没有更新,但了解它在更广泛的React生态系统中的位置是很重要的。

服务器组件和useReducer

随着React 19中React服务器组件的引入,值得注意的是,像所有Hook一样,useReducer只能在客户端组件中使用。服务器组件是无状态的,不能使用Hook。

useReduceruse()

React 19引入了新的use() Hook,可以用来消费承诺或上下文。虽然与useReducer没有直接关系,但use() Hook可以在处理异步数据时与useReducer配合使用。

import { use, useReducer } from 'react';

// ...
const [state, dispatch] = useReducer(reducer, initialState);

  function fetchUser() {
    dispatch({ type: 'FETCH_USER_START' });
    try {
      const user = use(fetchUserData(userId));
      dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
    } catch (error) {
      dispatch({ type: 'FETCH_USER_ERROR', payload: error.message });
    }
  }
// ...

useReduceruseTransition Hook

随着useTransition在React 18中的引入及其在React 19中的能力增强,我们可以将其与useReducer结合起来,创建更简洁的逻辑,特别是在处理数据突变和异步操作时。

useTransition允许我们将更新标记为转换,这告诉React它们可以被中断,不需要阻塞UI。这与useReducer结合使用特别有用,用于处理可能涉及API调用或其他耗时操作的复杂状态更新。