Reactの基礎 その6:React hooks その4 useReducer()

新しい現場で本格的にReactを触ることになったのでReactについて学んだことを備忘録として記事にしていきます。
いくつかの記事にわけてReactでステートメントを保持・管理するための機能であるReact hooksについてまとめていきます。

サンプルコードについて

サンプルコードはこちらにあります。
記事ごとにコミットログで分けてあるので該当するコミットをご覧ください。

React hooksとは

React hooksとは関数コンポーネント内の状態やライフサイクルなどのReactの機能を「フック」するために用意されている機能群の総称です。
React hooksを副作用や状態管理の実行などを行うことができ、その用途に合わせて様々な種類のhooksを利用することができます。
React hooksはuse〇〇と名付けられており、〇〇の部分に利用する機能を表す単語が入ります。
React hooksはあらかじめ用意されている機能が多数あり、いくつかの記事に分けて紹介していきたいと思います。
今回は状態管理を行うためのhooksで、更新ロジックの定義や前の状態に依存する更新を行うことができるuseReducer()についてまとめてきます。

useReducer()とは

useReducer()は状態管理を行うhooksの一つで、複雑な状態管理のロジックを扱う場合や次の状態が前の状態に依存する場合に利用されます。
useReducer()の基本的な使い方は以下の通りです。

import { useReducer } from 'react';

// 前の状態とactionを受け取り、更新後の状態を返すreducer関数を定義する。
const reducer = (prevState, action) => {
  switch (action.type) {
    case 'typeA':
      // typeAを指定した場合の処理
      return nextState;
    case 'typeB':
      // typeBを指定した場合の処理
      return nextState;
    default:
      throw new Error();
  }
};

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

  return (
    <div>
      <button onClick={() => dispatch({ type: 'typeA' })}>typeA</button>
      <button onClick={() => dispatch({ type: 'typeB' })}>typeB</button>
    </div>
  );
};

useReducer()を呼び出している箇所を見てみましょう。
まず、引数に注目するとreducerinitialStateが渡されています。
reducerは状態を更新するための関数で、第一引数に前の状態、第二引数に実行するアクションを渡します。
この例では、上に定義されているreducer関数で定義しています。
initialStateは状態の初期値となります。
次に、useReducer()の戻り値に注目してみます。
useReducer()[現在の状態, 更新関数]という形で戻り値を返しており、上記の例ではstateに現在の状態を、dispatchに更新関数を代入しています。
dispatchはアクションを引数として受け取り、受け取ったアクションに応じた処理をreducerで定義されている通りに実行して状態の更新を行います。

ここで、statereducer関数についてもう少し具体的に説明します。
stateは通常、キーバリューで複数の状態を一括で管理します。
reducer関数ではアクションを引数で受け取りますが、このアクションは一般的に何らかのtypeを持つオブジェクトです。
例の中ではtype属性をswitch文で評価して処理を分岐させていますが、どの状態の値を更新させるかを指定するstate属性を持たせるなど、実装したい機能に合わせて柔軟に指定することができます。
このように、useReducer()を使うことで関連する複数の状態とその更新のための処理を一元管理できるようになることも利点の一つになります。

useReducer()実装例

では、前回までのシリーズで作成しているTODOリストアプリにカードの編集機能を追加して、カードの状態管理をuseReducer()を利用して行うように修正していきます。

画像のような画面を作成していきます。
最上位のコンポーネントであるApp.jsuseReducer()を使用してカードの状態と更新関数を定義していきます。

import './App.css';
import { useReducer, useEffect, createContext } from 'react';
import Header from './Header';
import Body from './Body';
import { getRandomId } from './util/utils';
import { fetchCards, toggleStatus } from './util/cards';
export const CardsContext = createContext();

const reducer = (prevState, action) => {
  switch (action.type) {
    case 'setCards':
      return { cards: action.newCards };
    case 'addCard':
      return { cards: [
        ...prevState.cards,
        {
          id: getRandomId(),
          content: action.value,
          status: 'Not Ready',
        }
      ]};
    case 'toggleCardStatus':
      return {
        cards: prevState.cards.map(c => {
          if (c.id === action.cardId) {
            return {
              ...c,
              status: toggleStatus(c.status),
            };
          } else {
            return c;
          }
        })
      };
    case 'editCard':
      return {
        cards: prevState.cards.map(c => {
          if (c.id === action.cardId) {
            return {
              ...c,
              content: action.newContent,
            };
          } else {
            return c;
          }
        })
      }
    default:
      throw new Error('無効なアクションです。')
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, { cards: [] });

  useEffect(() => {
    dispatch({ type: 'setCards', newCards: fetchCards() });
  }, []);

  return (
    <CardsContext.Provider
      value={{
        state,
        dispatch,
      }}
    >
      <Header />
      <Body />
    </CardsContext.Provider>
  );
}

export default App;

reducer()関数の中ではアクションのタイプに応じた処理が定義されています。
アクションの属性はタイプと、そのアクションタイプを実行する上で必要な値を渡します。
例えば、addCardタイプのアクション内では新規追加するカードのコンテンツをvalue属性に持たせています。
useReducer()で定義したstatedispatchuseContext()を利用して各コンポーネントで利用できるようにしています。(useContext()useReducer()の組み合わせは相性が良く、よくこのように一緒に利用されます。)
では実際にstatedispatchを利用する箇所も修正していきましょう。
Header.jsを以下のように修正します。

import React, { useState, useContext } from "react";
import { CardsContext } from "./App";

function Header() {
  const { dispatch } = useContext(CardsContext);
  const [value, setValue] = useState('');

  return (
    <div className='header'>
      <h1 className='title'>Todo List</h1>
      <div className="addCardForm">
        <input
          className="addCardInput"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
        <button
          className="addCardButton"
          onClick={() => dispatch({ type: 'addCard', value })}
        >追加</button>
      </div>
    </div>
  )
}

export default Header;

addCard()という関数を利用していた代わりにdispatch関数にアクションを指定して処理を実行するように修正されています。
次にBody.jsも修正していきます。

import React, { useContext } from "react";
import Card from "./Card";
import { CardsContext } from "./App";

function Body() {

  const { state: { cards } } = useContext(CardsContext);

  return (
    <div className='body'>
      {cards.map(c => {
        return (
          <Card
            key={c.id}
            card={c}
          />
        )
      })}
    </div>
  )
}

export default Body

stateからcardsの情報を取り出し利用していますね。
最後にCard.jsを修正していきます。

import React, { useState, useContext } from "react";
import { CardsContext } from "./App";

function Card({ card }) {
  const { dispatch } = useContext(CardsContext);
  const [isEdit, setIsEdit] = useState(false);
  const [value, setValue] = useState(card.content);

  const handleEditClick = () => {
    if (isEdit) {
      dispatch({ type: 'editCard', cardId: card.id, newContent: value });
    }
    setIsEdit(!isEdit);
  }

  return (
    <div className='card'>
      <div className="todoIcon">✔︎</div>
      {isEdit ? (
        <input
          className="todocontent"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
      ) : (
        <div className='todocontent'>{card.content}</div>
      )}
      <button
        className='editButton'
        onClick={handleEditClick}
      >
        {isEdit ? 'save' : 'edit'}
      </button>
      <div className="todoStatus">
        <span>status:</span>
        <button
          className="statusButton"
          onClick={() => dispatch({ type: 'toggleCardStatus', cardId: card.id })}
        >
          {card.status}
        </button>
      </div>
    </div>
  )
}

export default Card;

編集機能をつけるために、編集モード切り替え用のフラグとインプットされた値の状態をuseState()で定義し管理しています。
各ボタンの押下時の処理でdispatchに適切なアクションを渡して処理を呼び出しています。

まとめ

今回はuseReducer()についてまとめてきました。
一見すると複雑に見えますが、より複雑で大規模なアプリの開発になった際には複雑な状態管理をuseReducer()で行うことでコードの可読性を高めることができます。
また、useContext()と合わせて利用することで、各コンポーネントは状態を変更する際にdispatchに直接アクセスし、適切なアクションを渡すだけで状態管理を行うことができるようになります。
状態管理を行うhooksはいくつかありますが、それぞれの特徴をよく捉えて適切に利用することで複雑なシステムもシンプルにすることができるようになります。
useReducer()は少し難しいですが、しっかりと身につけていきましょう。
ここまで読んでいただきありがとうございました。
それでは、また。