👻

Jotai を使って Props Drilling を回避する

2025/01/14に公開

はじめに

私は Context API や Redux のような巨大な grobal state に抵抗感があり、できるだけ state を小さく細かくして極力 component や route をまたぐ state を作らないようにしています。
そのため1、2階層の props の受け渡しで済む場合は親コンポーネントの state や set state action を渡すことが多く、いわゆる Recoil や Jotai の Atom を作ることはできるだけ少なくしたいと思っています。

各コンポーネントにそれぞれの責務に応じたデータや処理をもたせるのですが、その分処理が分散するので props のバケツリレー(Props Drilling)が発生しやすくなります。
データ構造がシンプルで、コンポーネントの階層構造が浅いうちは問題になりませんが、データ構造が複雑になったり階層構造が深くなると1つ項目を追加するのにも大変な苦労となります。

今回はその問題を Jotai を使って回避しようというお話です。

以下はこの問題の解消を試したサンプルプロジェクトです。

リポジトリ
https://github.com/hiroki-sato-workman/prop-drilling-solution-sample

Jotai に変更した内容
https://github.com/hiroki-sato-workman/prop-drilling-solution-sample/compare/fix-prop-drilling

とあるユーザー情報を編集、表示する機能があります。
役割毎に細かくコンポーネント分けたところ以下のような構造になりました。

コンポーネントの構造

/app
└── /routes
    ├── _index.tsx(Main.tsx)
    └── components
        └── UserInput.tsx
            └── AddressInput.tsx
                ├── BuildingInput.tsx
                ├── CityInput.tsx
                ├── PrefectureInput.tsx
                └── TownInput.tsx

画面イメージ

現状

現状は以下のようなデータフローになっており、各コンポーネントで意識する内容は少なくなっています。

  • Main.tsx → UserInput.tsx → AddressInput.tsx → 各Input コンポーネント
    • 住所更新時は逆方向に データ → 更新処理 が伝播

問題点

親コンポーネントの更新処理が依存関係となっており、また階層が深いため全体の見通しが悪くなっています。

  • 住所データの更新には必ず4階層分のpropsバケツリレーが必要
  • 中間コンポーネント(UserInput.tsx)は単なる props の受け渡し役となっている
  • 新しい入力項目を追加する際は全ての中間コンポーネントの修正が必要
変更前のコード

_index(Main.tsx)

export type AddressType ={
  prefecture: string;
  city: string;
  town: string;
  building: string;
}

export type UserType = {
  id: string;
  dob: string;
  firstName: string;
  lastName: string;
  address: AddressType,
}

const INITIAL_USER: UserType[] = [{
  id: '1111-2222-3333',
  dob: '2000-01-01',
  firstName: '太郎',
  lastName: '山田',
  address: {
    prefecture: '東京都',
    city: '千代田区',
    town: '千代田1-1-1',
    building: 'マンションA 101号室',
  }
}]

export default function Main() {
  const [users, setUsers] = useState(INITIAL_USER)

  /**
   * 住所を更新する
   * @param updateUserId 更新するユーザーid
   * @param newAddress 新しい住所
   */
  const handleChangeAddress = (updateUserId: string, newAddress: AddressType) => {
    setUsers((prevState) => {
      return prevState.map((prevUser: UserType) => {
        if (prevUser.id === updateUserId) {
          return {
            ...prevUser,
            address: newAddress
          }
        }
        return prevUser
      })
    })
  }

  // 住所以外は省略
  
  return (
    <div>
      <h2>Main</h2>
      {users.map((user) => {
        return (
          <div key={user.id}>
            {/* ユーザー情報表示 */}
            <UserDisplay user={user}/>
            {/* ユーザー情報入力 */}
            <UserInput user={user} onChangeUserInfo={handleChangeUserInfo} onChangeAddress={handleChangeAddress}/>
          </div>
        )
      })}
    </div>
  )
}

UserInput.tsx

type Props = {
  user: UserType
  onChangeUserInfo: (updateUserId: string, userInfo: NewUserInfoType) => void
  onChangeAddress: (updateUserId: string, newAddress: AddressType) => void
}

export default function UserInput({ user, onChangeUserInfo, onChangeAddress }: Props) {
  const { id, firstName, lastName, dob } = user

  // ユーザー情報入力欄の処理なので省略

  return (
    <div>
      <h3>UserInput</h3>
      <div>id: {user.id}</div>

      {/* ユーザー情報入力欄は省略 */}

      {/* 住所入力欄 */}
      <AddressInput user={user} onChangeAddress={onChangeAddress}/>
    </div>
  )
}

AddressInput.tsx

import {AddressType, UserType} from '~/user.type';
import PrefectureInput from './PrefectureInput';
import CityInput from './CityInput';
import TownInput from './TownInput';
import BuildingInput from './BuildingInput';
import {componentContainerStyle, componentTitleStyle} from '~/classes';

type Props = {
  user: UserType
  onChangeAddress: (updateUserId: string, newAddress: AddressType) => void
}
export default function AddressInput({ user, onChangeAddress }: Props) {
  const { id , address } = user
  const { prefecture, city, town, building } = address

  /**
   * 建物を更新する
   * @param newBuilding 新しい建物
   */
  const handleChangeBuilding = (newBuilding: string) => {
    handleChangeAddress({
      ...address,
      building: newBuilding,
    })
  }
  
  // 建物以外は省略

  return (
    <div>
      <h4>AddressInput</h4>
      <div>
        <PrefectureInput onChangePrefecture={handleChangePrefecture} prefecture={prefecture}/>
        <CityInput onChangeCity={handleChangeCity} city={city}/>
        <TownInput onChangeTown={handleChangeTown} town={town}/>
        <BuildingInput onChangeBuilding={handleChangeBuilding} building={building}/>
      </div>
    </div>
  )
}

BuildingInput.tsx

type Props = {
  building: string
  onChangeBuilding: (newPrefecture: string) => void
}
export default function BuildingInput({ building, onChangeBuilding }: Props) {
  /**
   * 建物を変更したとき
   * @param e 入力イベント
   */
  const handleChangeInput = (e) => {
    onChangeBuilding(e.target.value)
  }

  return (
    <div>
      <h5>BuildingInput</h5>
      <input defaultValue={building} onChange={handleChangeInput}/>
    </div>
  )
}

変更後

userAtom.ts を追加し、ユーザーの状態(state)と更新処理を1箇所に集約しました。
これにより以下のような改善が実現できます。

  • 中間コンポーネントから余分な props や handler が除去され、コードがシンプルになる
  • コンポーネント間の依存関係が減少する
  • 各 input コンポーネントは自身の役割(入力処理)に専念できる
  • 状態更新ロジックが集約され管理が容易になる

コンポーネントの構造

/app
├── atoms
│   └── userAtom.ts(追加)
└── /routes
    ├── _index.tsx(Main.tsx)
    └── components
        ├── UserDisplay.tsx
        │   └── UserTextDisplay.tsx
        └── UserInput.tsx
                └── AddressInput.tsx
                    ├── BuildingInput.tsx
                    ├── CityInput.tsx
                    ├── PrefectureInput.tsx
                    └── TownInput.tsx

変更後のコード

userAtom.ts

import { atom } from 'jotai'
import {AddressType, UserType} from '~/user.type';

const INITIAL_USER: UserType[] = [{
  id: '1111-2222-3333',
  dob: '2000-01-01',
  firstName: '太郎',
  lastName: '山田',
  address: {
    prefecture: '東京都',
    city: '千代田区',
    town: '千代田1-1-1',
    building: 'マンションA 101号室',
  }
}]

type UpdateAddressAtomType = {
  userId: string
  target: keyof AddressType
  newValue: string
}

/**
 * ユーザー情報を保持するアトム
 * 複数ユーザーの情報を配列として管理
 */
export const usersAtom = atom<UserType[]>(INITIAL_USER)


/**
 * 住所を更新するアトム
 * @param userId 更新対象のユーザーID
 * @param target 更新する住所のフィールド
 * @param newValue 新しい値
 */
export const updateAddressAtom = atom(
  null,
  (get, set, { userId, target, newValue }: UpdateAddressAtomType) => {
    const users = get(usersAtom)
    const user = users.find((u) => u.id === userId)
    if (!user) return

    const newAddress: AddressType = {
      ...user.address,
      [target]: newValue,
    }

    set(
      usersAtom,
      users.map((user) =>
        user.id === userId
          ? {
            ...user,
            address: newAddress,
          }
          : user
      )
    )
  }
)

/**
 * ユーザー情報を更新するアトム
 * @param userId 更新対象のユーザーID
 * @param target 更新するユーザー情報
 * @param newValue 新しい値
 */
export const updateUserInfoAtom = atom(
  // 内容は省略。住所以外の更新処理もここに記載。
)

_index(Main.tsx)

export default function Main() {
  const [users] = useAtom(usersAtom)

  return (
    <div>
      <h2>Main</h2>
      {users.map((user) => {
        return (
          <div key={user.id}>
            {/* ユーザー情報表示 */}
            <UserDisplay user={user}/>
            {/* ユーザー情報入力 */}
            <UserInput user={user}/>
          </div>
        )
      })}
    </div>
  )
}

UserInput.tsx

type Props = {
  user: UserType
}

export default function UserInput({ user }: Props) {
  const updateUserInfo = useSetAtom(updateUserInfoAtom)
  const { id: userId, lastName, firstName, dob} = user

  return (
    <div>
      <h3>UserInput</h3>
      <div>id: {userId}</div>

      {/* ユーザー情報入力欄は省略 */}

      {/* 住所入力欄 */}
      <AddressInput user={user}/>
    </div>
  )
}

AddressInput.tsx

type Props = {
  user: UserType
}

export default function AddressInput({ user }: Props) {
  const { prefecture, city, town, building } = user.address

  return (
    <div>
      <h4>AddressInput</h4>
      <div>
        <PrefectureInput userId={user.id} prefecture={prefecture} />
        <CityInput userId={user.id} city={city}/>
        <TownInput userId={user.id} town={town}/>
        <BuildingInput userId={user.id} building={building}/>
      </div>
    </div>
  )
}

BuildingInput.tsx

type Props = {
  userId: string
  building: string
}

export default function BuildingInput({ userId, building }: Props) {
  const updateAddress = useSetAtom(updateAddressAtom)

  return (
    <div>
      <h5>BuildingInput</h5>
      <input
        defaultValue={building}
        onChange={(e) => updateAddress(
          {
            userId,
            target: 'building',
            newValue: e.target.value,
          })
        }
      />
    </div>
  )
}

[余談] Context API はだめ?

Context API でもこの問題を解決できますが、主に以下の理由により Jotai のほうが優れています。

再レンダリングの問題

Context API

  • Provider でラップしたコンポーネント配下は値が変更された場合、基本的に全て再レンダリングの対象になる(メモ化により軽減は可能)
  • useContext を使用するコンポーネントは、Context 値の一部だけを使用する場合でも Context 全体の変更を購読することになる
  • Provider 階層が深くなるほどこの問題は顕著になる

Jotai

  • 必要な値だけを各コンポーネントで購読できる(atom レベルの粒度)
  • atom の値が変更された時、その atom を使用しているコンポーネントのみが再レンダリングされる
  • Provider が不要なのでパフォーマンスへの影響を気にせずに状態管理できる

柔軟な状態管理

Context API

  • 新しい状態を追加する際は新しい Provider が必要
  • 複数の Context を連携させる場合 Provider のネストが必要(Provider 地獄の可能性)
  • Provider 階層の設計を事前に考慮する必要がある

Jotai

  • Provider なしで新しい atom を追加可能
  • 既存の atom から派生した新しい atom を作成できる
  • 複数の atom を組み合わせて新しい状態を作成できる

終わりに

適度な Props バケツリレーにはコンポーネントに責務を分離できたり、シンプルなデータフローで理解しやすいなどのメリットもあるため、一概に悪だとは言い切れません。
プロジェクトの方針にもよると思うので、バランスを見ながら「props 渡すだけの中間コンポーネントが多くなってきたなあ」と思ったら Jotai 等の導入を検討するのが良いと思います。

Discussion