🎛️

Reactの状態管理におけるuseReducerとuseContextの活用例

2023/11/02に公開

こんにちは、アルダグラムでエンジニアをしている松田です。

Reactで状態を管理するにあたっては、useStateの利用が挙げられることが多いです。
実際、数値や文字列、真偽値などのプリミティブな値であれば、useStateが適切なケースが大半でしょう。
ただ、値を投入するだけではなく、追加・削除・更新といった操作が要求されるケースもあると思います。
その際に、配列やオブジェクトなどといった形式でデータを取り扱うことに併せて、useReducerやuseContextの利用も視野に入れると、開発手法の幅が広がります。

今回は、次の目標に掲げるUIを実現する過程を、useReducerとuseContextの活用方法の1つとしてご紹介します。

目標

以下のような操作ができるUIを作成する。

  • 名前と年齢を入力して、ユーザーを作成
  • 作成したユーザーは一覧に表示
  • 各ユーザーの権限(一般 or 管理者)をボタンで切り替え
  • 最後に追加したユーザーを削除

UIの概観イメージ
UIの概観イメージ

設計

実装に入る前に、目標物の要件をもとに、簡単な設計を行います。

必要とされるデータ操作

  • ユーザーの追加
    • 名前と年齢の情報も同時に取得する
  • ユーザーの削除
  • ユーザーの権限変更
    • 一般 or 管理者

ユーザーデータの構造

Typescriptであれば、Utility TypesのRecordを使ってマッピングすると、取り回しやすいと考えられます。
今回は、Recordを使って進めてみます。

props 説明 参照されるデータ操作
uuid 識別子 (Recordのkeyにも使用) 削除/権限変更
isAdmin 権限 (trueであれば管理者) 権限変更
name 名前 追加
age 年齢 追加

コンポーネントの構成

  • Root (最上位コンポーネント)
    • UsersProvider (ユーザーデータを提供するコンポーネント)
      • UserListEditor (ユーザー追加/削除UIコンポーネント)
      • UserList (ユーザー一覧UIコンポーネント)

コンポーネントの構成
コンポーネント構成図

実装

目標物を実現するための実装を行います。

UIの大枠を作成

最上位のRootコンポーネントの作成

後工程で実装予定のコンポーネントをTODOで置きます。

export const Root: FC = () => {
  return (
    <>
      {/* TODO: <UsersProvider> */}
        {/* TODO: <UserListEditor /> */}
        {/* TODO: <UserList /> */}
      {/* TODO: </UsersProvider> */}
    </>
  )
}

ユーザーデータを保持するContextの作成

createContextでUsersのContextを作成します。
作成したContextはuseUsersなどいった名前のCustom hooksで参照できるようにしておくと、取り回しがよいでしょう。

export type User = {
  uuid: string
  isAdmin: boolean
  name: string
  age?: number
}

export type Users = Record<User['uuid'], User>

const UsersContext = createContext<Users>({})
// usersを参照する際に、このカスタムフックを用いる
export const useUsers = () => useContext(UsersContext)

UsersProviderコンポーネントの作成

Users関連のContextを提供を目的とした、コンポーネントを作成します。
childrenの内部で、UsersのContextおよびそれを用いたカスタムフックを利用できるようになります。

// users関連のContextを提供するProviderを定義
// children内では、useUsersを用いて、usersを参照・更新できるようになる
export const UsersProvider: FC = ({ children }) => {
  const defaultUser = {
    uuid: window.crypto.randomUUID(),
    isAdmin: true,
    name: 'デフォルトユーザー'
  }
  const defaultUsers: Users = { [defaultUser.uuid]: defaultUser }

  return (
    <UsersContext.Provider value={users}>
      {children}
    </UsersContext.Provider>
  )
}
export const Root: FC = () => {
  return (
    <>
      <UsersProvider>
        {/* TODO: <UserListEditor /> */}
        {/* TODO: <UserList /> */}
      </UsersProvider>
    </>
  )
}

UserListコンポーネントの作成

UsersのContextの内容を表示するための、コンポーネントを作成します。

// ユーザー一覧を表示するコンポーネント
export const UserList: FC = () => {
  const users = useUsers()

  // 権限を切り替える関数を定義
  const switchRole = (uuid: User['uuid']) => {
    // TODO: 権限情報の更新処理
  }

  return (
    <>
      <h2>ユーザー一覧</h2>
      <ul>
        {Object.values(users).map(user => (
          <div key={user.uuid}>
            <li>
              <div>
                <span>名前: </span>
                <span>{user.name || '匿名'}</span>
              </div>
              <div>
                <span>権限: </span>
                <button onClick={() => switchRole(user.uuid)}>
                  {user.isAdmin ? '管理者' : '一般'}
                </button>
              </div>
              <div>
                <span>年齢: </span>
                <span>{user.age ?? '未入力'}</span>
              </div>
            </li>
            <hr />
          </div>
        ))}
      </ul>
    </>
  )
}

switchRoleは後工程で実装します。
また、useUsersの利用に際しては、UsersProvider(の中にあるUsers.Provider)でラップすることが必要な点に留意してください。

export const Root: FC = () => {
  return (
    <>
      <UsersProvider>
        {/* TODO: <UserListEditor /> */}
        <UserList />
      </UsersProvider>
    </>
  )
}

UserListEditorコンポーネントの作成

Usersの追加や削除を行うための、コンポーネントを作成します。

// ユーザーを追加・削除するコンポーネント
export const UserListEditor: FC = () => {
  const [name, setName] = useState<string>('')
  const [age, setAge] = useState<number | undefined>()

  // ユーザーを追加する関数を定義
  const addUser = () => {
    // TODO: ユーザーの追加処理
  }

  // 最後に追加したユーザーを削除する関数を定義
  const removeLastUser = () => {
    // TODO: 最後に追加したユーザーの削除処理
  }

  return (
    <>
      <div>
        <input
          value={name}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setName(e.target.value)
          }
          type="text"
          name="name"
          placeholder="名前"
        />
      </div>
      <div>
        <input
          value={age}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setAge(e.target.value ? Number(e.target.value) : undefined)
          }
          type="number"
          name="age"
          placeholder="年齢"
        />
      </div>
      <div>
        <button onClick={addUser}>ユーザー追加</button>
      </div>
      <div>
        <button onClick={removeLastUser}>最後に追加したユーザーを削除</button>
      </div>
    </>
  )
}

nameとageはプリミティブな値を扱うため、useStateの利用が適切でしょう。
addUserとremoveLastUserは後工程で実装します。

Reducerの導入

ユーザーデータの更新作業を行うusersReducerの定義

ユーザーデータの更新作業の処理内容を、reducer関数に記載していきます。

// 更新作業のtypeと更新に用いる値(payload)の型定義
export type Action =
  | { type: 'ADD'; payload: User } // ユーザー追加
  | { type: 'REMOVE'; payload: Pick<User, 'uuid'> } // ユーザー削除
  | { type: 'SWITCH_ROLE'; payload: Pick<User, 'uuid'> } // 権限切り替え

// 更新作業を行うreducer関数を定義
export const usersReducer = (users: Users, action: Action): Users => {
  const { type, payload } = action

  // actionのtypeに応じた更新作業を行う
  switch (type) {
    case 'ADD':
      if (payload.uuid in users) {
        // 既にユーザーが追加済の場合は、何もしないようにする
        // reducerをそのまま返却して、更新をスキップし、再レンダリングを防止する
        return users
      }
      users[payload.uuid] = payload // payloadを用いて、データを更新する
      return { ...users } // スプレッド構文で新しいオブジェクトとして返すことで、更新をトリガーする
    case 'REMOVE':
      if (!(payload.uuid in users)) {
        return users
      }
      delete users[payload.uuid] // usersからpayload.uuidのユーザーを削除する
      return { ...users }
    case 'SWITCH_ROLE':
      if (!(payload.uuid in users)) {
        return users
      }
      users[payload.uuid].isAdmin = !users[payload.uuid].isAdmin // isAdminを反転させる
      return { ...users }
    default:
      // 想定外のactionの場合は例外を投げるようにする
      throw new Error('Invalid action')
  }
}

const UsersDispatchContext = createContext<Dispatch<Action>>(() => undefined)
// usersを更新する際に、このカスタムフックを用いる
export const useUsersDispatch = () => useContext(UsersDispatchContext)

UsersProvider内でusersReducerを利用する

UsersProviderを改修し、useReducerを用いてusersを参照・更新できる形式にします。

// users関連のContextを提供するProviderを定義
// children内では、useUsersとuseUsersDispatchを用いて、usersを参照・更新できるようになる
export const UsersProvider: FC = ({ children }) => {
  const defaultUser = {
    uuid: window.crypto.randomUUID(),
    isAdmin: true,
    name: 'デフォルトユーザー'
  }
  const defaultUsers: Users = { [defaultUser.uuid]: defaultUser }

  // useReducerの第1引数にusersReducerを設定
  // 第2引数には初期値を設定
  const [users, usersDispatch] = useReducer(usersReducer, defaultUsers)

  // usersおよびusersDispatchを、各々が対応するContext.Providerのvalueに据える
  return (
    <UsersContext.Provider value={users}>
      <UsersDispatchContext.Provider value={usersDispatch}>
        {children}
      </UsersDispatchContext.Provider>
    </UsersContext.Provider>
  )
}

useUsersDispatchを用いて、user更新作業用の関数を定義する

更新作業を行う必要があるコンポーネントにて、useUsersDispatchのhooksを使って各種更新処理を実装していきます。

// ユーザー一覧を表示するコンポーネント
export const UserList: FC = () => {
  const users = useUsers()
  const usersDispatch = useUsersDispatch()

  // 権限を切り替える関数を定義
  const switchRole = (uuid: User['uuid']) => {
    usersDispatch({ type: 'SWITCH_ROLE', payload: { uuid } })
  }

  return (
    <>
      <h2>ユーザー一覧</h2>
      <ul>
        {Object.values(users).map(user => (
          <>
            <li key={user.uuid}>
              <div>
                <span>名前: </span>
                <span>{user.name || '匿名'}</span>
              </div>
              <div>
                <span>権限: </span>
                <button onClick={() => switchRole(user.uuid)}>
                  {user.isAdmin ? '管理者' : '一般'}
                </button>
              </div>
              <div>
                <span>年齢: </span>
                <span>{user.age ?? '未入力'}</span>
              </div>
            </li>
            <hr />
          </>
        ))}
      </ul>
    </>
  )
}
// ユーザーを追加・削除するコンポーネント
export const UserListEditor: FC = () => {
  const [name, setName] = useState<string>('')
  const [age, setAge] = useState<number | undefined>()
  const users = useUsers()
  const usersDispatch = useUsersDispatch()

  // ユーザーを追加する関数を定義
  const addUser = () => {
    const user: User = {
      uuid: window.crypto.randomUUID(),
      isAdmin: false,
      name,
      age
    }

    usersDispatch({ type: 'ADD', payload: user })
  }

  // 最後に追加したユーザーを削除する関数を定義
  const removeLastUser = () => {
    const lastUser = Object.values(users).slice(-1)[0]
    if (lastUser) {
      usersDispatch({ type: 'REMOVE', payload: { uuid: lastUser.uuid } })
    }
  }

  return (
    <>
      <div>
        <input
          value={name}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setName(e.target.value)
          }
          type="text"
          name="name"
          placeholder="名前"
        />
      </div>
      <div>
        <input
          value={age}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setAge(e.target.value ? Number(e.target.value) : undefined)
          }
          type="number"
          name="age"
          placeholder="年齢"
        />
      </div>
      <div>
        <button onClick={addUser}>ユーザー追加</button>
      </div>
      <div>
        <button onClick={removeLastUser}>最後に追加したユーザーを削除</button>
      </div>
    </>
  )
}

結果

以下のような動作が得られました。

動作プレビュー
動作プレビュー

Codesandbox

考察

useStateとuseReducerの比較

useStateは、setState関数を通して値を入力するだけで、状態更新を実施できます。
よって、変更対象がプリミティブな値であれば、useStateを利用することで簡潔な実装を得ることが期待できます。

一方、配列やオブジェクトといった形式でデータの管理が必要な場合は、得てして複数の状態管理手法が必要になります。
setStateする前に、様々なデータの下ごしらえが必要となるでしょう。
そうすると、複数コンポーネントにまたがって散逸するなどといったリスクが生じます。
要求される機能が複雑になる程に、そのリスクは増大していくと見込まれます。

useReducerを用いると、状態更新のための処理をreducer関数に一任することで、統括的な管理を実施できます。
状態更新関連の改修を行う際は、reducer関数を確認すれば良い、と開発スコープが明確になります。
また、Dispatcherにtypeとpayloadさえ渡せればよいので、UIを主に担うコンポーネント内でレンダリングに直接影響しない処理が延々と連なってしまう、といった事態の予防も期待できます。

比較項目 useState useReducer
必要最低限の記述量 少ない 多い
更新処理の記述 コンポーネント内外に散逸しやすい 純関数のreducerが一任する
向いている対象データ 数値、真偽値、文字列など
シンプルなプリミティブな値
オブジェクトや配列など
複雑なデータ

useReducerとuseContextの組み合わせ

複雑なデータを取り扱う場合は、関連するコンポーネントも1つではなく複数、それも多階層におよぶことが見込まれます。
useReducerで管理する値やdispatcherを、以下のようにコンポーネントのpropsのバケツリレーで管轄すると、開発コストの増大や不具合の温床に繋がりかねません。

propsのバケツリレー
propsのバケツリレー

useContextを用いて、useReducerの値とdispatcherを呼び出せるカスタムフックを用意しておくと、状態の値または変更が必要となるコンポーネントでのみ対象のカスタムフックを呼び出せばよくなります。

そうすると、中継propsが一掃でき、データを取り扱う範囲が広域になっても対応しやすくなります。

hooksの適時利用
hooksの適時利用

また、ContextのProvider内のみ対象のカスタムフックが適用可能となるので、スコープが明確になる効果もあります。
さらに、他の複雑なデータと共存が必要な場合も、useContextとuseReducerで同様に作成して、Providerを組みわせることで、比較的容易に実現できます。

参考資料

React公式ドキュメント

おわりに

useReducerとuseContext、これらを用いることで複雑なデータの状態管理を簡潔かつ保守性の高い状態で実装できる、という手応えを得られました。

本稿は状態管理に焦点を当てて記載しましたが、状態管理の最適化はレンダリングの最適化にも繋がると思います。
また、複雑なデータが取り回しやすくなることで、リクエストやレスポンス周りの処理も最適化できる可能性があるとも思ってます。

今後も快適なReact開発を目指していきたいですね。


もっと、アルダグラムのエンジニア組織を知りたい人は、下記の情報をチェック!

アルダグラム Tech Blog

Discussion