Tyotto good!

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

Posted: March 08, 2021

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はかなり使えるケースが多いと思うので、是非積極的に使ってみてください🙌