3
4

【状態管理の救世主】React の useReducer で複雑なコードにさようなら

Last updated at Posted at 2024-05-22

はじめに

恥ずかしながら、私はこれまで useStateフックのみで状態管理を行ってきました。

しかし、useReducerフックなるものを知り、その便利さに驚きました。そこで、その利便性を共有するためにこの記事を書くことにしました。

useReducer を使うと、複数の関連する状態をひとまとめにして管理できるようになります。これは、複数の useState を使っていたときよりも整理されていて、扱いやすくなります。ぜひ使いこなせるようになりたいですよね。

この記事を読むことで、以下のことが理解できるようになります。

  • useReducer の基本的な使い方
  • useStateuseReducer の比較と使い分け方
  • useState から useReducer への段階的な移行の手順

以前の私と同じように状態管理の複雑さに悩む人の役に立てれば幸いです。
では、早速見ていきましょう!

1. useReducer の基本的な使い方

まず、React から useReducer をインポートします。

import { useReducer } from "react";

そして呼び出します。

他のフックと同様にコンポーネントのトップレベルもしくは独自のカスタムフックでのみ呼び出しが可能です。

基本的なシグネチャは以下のようになります。

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

それぞれの要素を説明していきます。

  • reducer:状態とアクションを引数に取り、新しい状態を返す関数
  • initialState:状態の初期値
  • state:現在の状態を保持する変数
  • dispatch:リデューサにアクションを与えてトリガーする関数

おそらく、ここで「?」が付くのは reducerdispatch かと思いますので詳しく説明します。

リデューサ関数の例

以下は、カウンターを管理するシンプルなリデューサです。

// 状態の型を定義
type State = { count: number };

// アクションの型を定義
type Action = { type: "increment" } | { type: "decrement" }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error('Unknown action type');
  }
}

このリデューサ関数は、increment および decrementという2つのアクションタイプを扱います。もちろんこれらのアクションは自分で設定することができます。

useReducer を使用する際、リデューサ関数は一般的に switch 文で記述されます。switch 文の各 case において、次の状態(state)を計算して返します。

dispatch の例

dispatch 関数は、アクションをリデューサへ送るために使用されます。具体的には、ユーザーの操作やイベントハンドラ内で dispatch を呼び出し、状態を更新します。

例を見てみましょう。

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </div>
  );
}

上記のコンポーネントでは、ボタンのクリックイベントに応じて dispatch 関数を呼び出しています。

dispatch 関数の引数は、ユーザによって実行されたアクションです。任意の型の値を指定できます。慣例として、アクションは通常オブジェクトであり、type プロパティで識別され、他のプロパティでオプションの追加情報を保持します。

2. useStateuseReducerの比較と使い分け方

それぞれの特徴を表にしてまとめてみました。

useState useReducer
コード 短い。初期コードは少ない 長い。リデューサ関数と dispatch が必要
可読性 シンプルな state 更新に最適 複雑な state 更新に最適。ロジックの分離が可能
デバッグ バグの原因特定が難しい場合がある コンソールログで更新の原因(state, action)を追跡可能
テスト コンポーネント全体でテスト 純関数として単体テストが可能
好み useReducerで代用可能 useStateで代用可能

React 公式は、「バグが頻繁に発生しておりコンポーネントのコードに構造を導入したい場合に、リデューサを利用することをお勧めします」とのことです。

しかし機能は同等なので、個人の好みやチームの規則に従って選択するのが良さそうです。
同じコンポーネントで useStateuseReducer を両方使うことも可能です。

3. useState から useReducer への段階的な移行の手順

useState から useReducer への移行は、次の 3 つのステップで行うことができます。

1. イベントハンドラからアクションをディスパッチする

リデューサを使った状態管理は state を直接セットするのとは少し異なります。

React に対して state をセットして「何をするか」を指示するのではなく、イベントハンドラから「アクション」をディスパッチすることで「ユーザが何をしたか」を指定します。

例えば、以下のような useState を使ってタスクを直接的に追加/変更/削除するイベントハンドラがあったとしたら、

const [tasks, setTasks] = useState(initialTasks);

// 新しいタスクを追加する
function handleAddTask(text) {
  setTasks([
    ...tasks,
    {
      id: nextId++,
      text: text,
      done: false,
    },
  ]);
}

// 既存のタスクを変更する
function handleChangeTask(task) {
  setTasks(
    tasks.map((t) => {
      if (t.id === task.id) {
        return task;
      } else {
        return t;
      }
    })
  );
}

// タスクを削除する
function handleDeleteTask(taskId) {
  setTasks(tasks.filter((t) => t.id !== taskId));
}

このように変更します。

function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text: text,
  });
}

function handleChangeTask(task) {
  dispatch({
    type: 'changed',
    task: task,
  });
}

function handleDeleteTask(taskId) {
  dispatch({
    type: 'deleted',
    id: taskId,
  });
}

イベントハンドラ内で state を直接更新するのではなく、dispatch にアクションタイプと state の更新に必要な情報を与えてリデューサを呼び出してあげるというイメージです。

2. state とアクションから次の state を返すリデューサ関数を書く

useReducer においては、リデューサ関数が state のロジックを記述する場所です。
現在の state とアクションオブジェクトの 2 つを引数に取り、次の state を返します。

先ほどの tasks の変更のロジックは以下のようになります。

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [
        ...tasks,
        {
          id: action.id,
          text: action.text,
          done: false,
        },
      ];
    }
    case 'changed': {
      return tasks.map((t) => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter((t) => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

リデューサ関数は state (tasks) を引数として取るため、コンポーネントの外部で宣言することができます。これにより、インデントレベルが減り、コードが読みやすくなります。

3. useStateuseReducer に置き換える

最後に、リデューサをコンポーネントに接続する必要があります。React から useReducer フックをインポートし、呼び出してください。

以下の useState を使った呼び出しを、

const [tasks, setTasks] = useState(initialTasks);

このように置き換えます。

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

フックの第一引数に先ほど作成したリデューサをセットしてやれば完成です!
必要であればリデューサを別ファイルに移動することもできます。

useReducer を使い、関心を分離することで、コンポーネントのロジックが読みやすくなります。イベントハンドラはアクションをディスパッチすることで「何が起こったか」を指定し、リデューサ関数がそれらに対する「state の更新方法」を決定します。

おわりに

いかがだったでしょうか。

個人的には useReducer は、useState を多用した時のコンポーネント内のコードやロジックの煩雑さを解消してくれるという点で、すごく便利だなと感じました。

それにしても React hooks はとても奥が深いですね。またキャッチアップがあれば記事にします。

ここまでお読みいただきありがとうございました!

参考

  • React 公式リファレンス

  • mosya React(ハンズオンもあってオススメ👇)

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4