【React Hooks】useReducer で複雑 state 管理を簡単にしよう

Posted: 2021/3/9
React/
React Hooks

useReducer とは

useReduceruseStateと同様に state を管理することができるフックです。

useStateとの使い分けは、state が複数の値にまたがるような複雑なロジックがある場合前回の state に基づいて state を更新する場合useReducerを使うのがよいとされています。

また、複数階層にまたがり更新が必要なコンポーネントにおいて、useReducerを用いて dispatch を下位コンポーネントに渡すとパフォーマンスの最適化につながります。

それでは順を追って説明していきます。

useReducer の呼び出し方

まずuseReducerの呼び出し方からです。以下のように呼び出します。

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

useReducer は以下の引数を受け取ります。

  • reducer : state を更新するための関数。state と action を受け取って新しい state を返す。
  • initialState : state の初期値

戻り値として以下の配列を返します。

  • state : state(コンポーネントの状態)
  • dispatch : reducer を実行するための呼び出し関数

まだあまりイメージを掴めないと思うので、例を見てみましょう。

以下は Todo リストのコンポーネントの例です(TypeScript を利用しています)。

import React, { useReducer, useState } from "react";

type State = {
  id: number,
  text: string,
};

type Action = {
  type: "ADD",
  text: string,
};

const initialState: State[] = [
  {
    id: 0,
    text: "initial todo",
  },
];

// stateとactionを受け取り、actionのtypeによってstateの更新方法を変える
const reducer = (state: State[], action: Action): State[] => {
  switch (action.type) {
    case "ADD":
      return [...state, { id: state.slice(-1)[0].id + 1, text: action.text }];
  }
};

const TodoList: React.FC = () => {
  const [text, setText] = useState("");
  const [todoList, dispatch] = useReducer(reducer, initialState);
  return (
    <div className="todos">
      <label>
        todo :
        <input
          type="text"
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <button onClick={() => dispatch({ type: "ADD", text: text })}>
        Add todo
      </button>
      <ul>
        {todoList.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
};

まず、以下のコードで、先ほどの定義通りuseReducerを宣言しています。

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

この時第一引数にreducer関数、第二引数に初期値の state を受け取っています。reducer関数は

const reducer = (state: State[], action: Action): State[] => {
  switch (action.type) {
    case "ADD":
      return [...state, { id: state.slice(-1)[0].id + 1, text: action.text }];
  }
};

こちらで定義されており、state とactionと呼ばれる次に何を行うかを示すオブジェクトを受け取ります。なおこれらの型も別に用意しておきます。

そしてreducer関数の内部で action の type プロパティによって処理を分岐し、新しい state を返していることがわかります。

このreducerを実行するのにはdispatchが必要です。

例では以下の部分で実行しています。

<button onClick={() => dispatch({ type: "ADD", text: text })}>

dispatchを実行することでreducerを呼び出せます。なおこの時actionを引数にします。

このようにreducerを介すことで複雑な state の更新のロジックをまとめることができます。

前回の state に依存して state を更新するユースケース

state を更新する時、setState(beforeState => ...)のような書き方をすることがあります。しかしそのような場合は useReducer を用いた方がいいかもしれません。

以下は、setState(beforeState => ...) を使った場合の例です。

const Counter: React.FC = () => {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      // 前のstateを元に更新
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return (
    <>
      <div>count : {count}</div>
    </>
  );
};

この場合、問題なくコードは動きます。setCount内にコールバック関数を入れることで前回の state を読み取ることができ、無限ループを回避できます。

しかし state が少し複雑になり 2 つ以上の state の値を元に更新するだったら、上記のような更新方法は望ましくありません。そういった場合にuseReducerが役に立ちます。

以下のように state に count に加え step というプロパティが増えた場合、そもそもロジックが複雑になり reducer に切り出した方が見通しがいいです。

import React, { useEffect, useReducer } from "react";

type State = {
  count: number,
  step: number,
};

type Action = {
  type: "COUNT" | "STEP",
};

const initialState: State = {
  count: 0,
  step: 0,
};

const reducer = (state: State, action: Action): State => {
  // action.typeによってstateの更新を切り替え
  switch (action.type) {
    case "COUNT":
      return { count: state.count + 1, step: state.step };
    case "STEP":
      return { count: state.count, step: state.step + 1 };
  }
};

const Counter: React.FC = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  useEffect(() => {
    // 毎秒dispatchしてstateを更新する
    const id = setInterval(() => {
      dispatch({ type: "COUNT" });
    }, 1000);
    return () => clearInterval(id);
    // dispatch関数はconstant
  }, [dispatch]);
  return (
    <>
      <div>count : {state.count}</div>
      <div>step : {state.step}</div>
    </>
  );
};

export default Counter;

さらに注目すべきは、useEffectの依存配列にdispatch関数を入れていることです。通常のコンポーネント内に定義した関数はレンダリングごとにべつの関数になってしまうため、それを入れると無限ループに陥ってしまいます。

しかし、dispatch関数はメモ化されたコールバック関数であるため、レンダリングごとで不変となります。よってこの例は始めのレンダリング時しか呼び出されず、無限ループを回避できます。

useReducer を用いたパフォーマンス最適化

先ほど説明した通りuseReducerを用いるとdispatch関数はメモ化されるため、React.memoと組み合わせるとパフォーマンスの最適化が期待できます。

よく利用されるケースは、useContext等を使ってコンテキスト経由で下の階層のコンポーネントにdispatch関数を渡し、子コンポーネント等でそれを使い state を更新する、といったケースです。

また子コンポーネントをReact.memoでメモ化し、props やコンテキスト経由でsetStateの代わりにdispatchを渡すと不要なレンダリングを避けることができます。

まとめ

  • useReducerは action・state を受け取り新しい state を返すreducer、初期 state を受け取る
    • 現在の state、reducerを呼び出すdispatch関数を返す
  • useReducerは state が複数の値にまたがるような複雑なロジックがある場合前回の state に基づいて state を更新する場合に用いるのがよい
  • dispatch関数はメモ化されているのでパフォーマンス最適化に使える

useReducer はかなり使えるケースが多いと思うので、是非積極的に使ってみてください 🙌