Jotai を使って Props Drilling を回避する
はじめに
私は Context API や Redux のような巨大な grobal state に抵抗感があり、できるだけ state を小さく細かくして極力 component や route をまたぐ state を作らないようにしています。
そのため1、2階層の props の受け渡しで済む場合は親コンポーネントの state や set state action を渡すことが多く、いわゆる Recoil や Jotai の Atom を作ることはできるだけ少なくしたいと思っています。
各コンポーネントにそれぞれの責務に応じたデータや処理をもたせるのですが、その分処理が分散するので props のバケツリレー(Props Drilling)が発生しやすくなります。
データ構造がシンプルで、コンポーネントの階層構造が浅いうちは問題になりませんが、データ構造が複雑になったり階層構造が深くなると1つ項目を追加するのにも大変な苦労となります。
今回はその問題を Jotai を使って回避しようというお話です。
以下はこの問題の解消を試したサンプルプロジェクトです。
リポジトリ
Jotai に変更した内容
例
とあるユーザー情報を編集、表示する機能があります。
役割毎に細かくコンポーネント分けたところ以下のような構造になりました。
コンポーネントの構造
/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