Reactのリフトアップ(Lifting State Up)を検索機能の実装例で理解する
個人開発中のアプリに検索モーダルを実装していて、「サイドバーの検索ボタン」と「検索結果を出すモーダル」という兄弟関係にある2つのコンポーネントを、どうやって連携させるかで手が止まりました。調べてみると リフトアップ(Lifting State Up) というReactの基本的な状態共有パターンで解決できることがわかったので、そのときに整理した内容を備忘録として残しておきます。
そもそも何が問題なのか
今回実装したかったのは、サイドバーの検索ボタンを押すと検索モーダルが開く、という単純な機能です。しかしこの2つのコンポーネントは、コンポーネントツリー上では次のような兄弟関係にありました。
Layout
├─ SideBar ← 検索ボタンがある
└─ SearchModal ← モーダルの開閉状態を持ちたい
モーダルの開閉状態をSideBarかSearchModalのどちらかが単独で持っても、もう一方には届きません。Reactのデータは親から子へ一方向に流れるため、兄弟同士は直接やり取りできないからです。
リフトアップの発想
リフトアップは、この問題を「状態を、両方から見える共通の親に引き上げる(lift up)」ことで解決します。
兄弟同士で直接やり取りしようとせず、共通の親に状態を預ける。子はその状態を読むか、更新をお願いするだけ。
今回はSideBarとSearchModalの共通の親であるLayoutが、開閉状態isShowModalをuseStateで持つことにしました。
// Layout.tsx
export default function Layout() {
const [isShowModal, setIsShowModal] = useState(false);
return (
<div className="layout-container">
<SideBar onSearchButtonClick={() => setIsShowModal(true)} />
<main className="layout-main">
<Outlet />
</main>
<SearchModal isOpen={isShowModal} onClose={() => setIsShowModal(false)} />
</div>
);
}
SideBarには「クリックされたらtrueにする」関数を、SearchModalには「現在の開閉状態」と「閉じる関数」を、それぞれpropsとして渡します。状態の実体はLayoutにしかなく、子はそれを知りません。
子コンポーネント側の実装
SideBarは受け取った関数をボタンのonClickにそのまま渡すだけです。
// SideBar/index.tsx
interface Props {
onSearchButtonClick: () => void;
}
export default function SideBar({ onSearchButtonClick }: Props) {
return (
// ...
<Item label="検索" icon={FiSearch} onClick={onSearchButtonClick} />
// ...
);
}
SearchModalも同様に、受け取ったisOpenをそのまま表示条件に、onCloseをそのままモーダルの閉じるイベントに渡します。
// SearchModal.tsx
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function SearchModal({ isOpen, onClose }: Props) {
return (
<CommandDialog open={isOpen} onOpenChange={onClose}>
{/* ... */}
</CommandDialog>
);
}
ポイントは、SideBarもSearchModalも「自分が何をしているか」を一切知らないことです。SideBarはボタンが押されたことを伝えるだけ、SearchModalは渡された真偽値で開閉するだけ。ロジックは全部親に集約されています。
データの流れを追う
クリックからモーダルが開くまでの流れを図にすると次のようになります。
┌──────────────────── Layout(isShowModalを保持)────────────────────┐
│ │
│ ユーザーがクリック │
│ │ │
│ ▼ │
│ SideBar の onClick ──onSearchButtonClick()──▶ setIsShowModal(true)│
│ │ │
│ ▼ │
│ Layout が再レンダリング │
│ │ │
│ ▼ │
│ SearchModal に isOpen={true} │
│ │
└──────────────────────────────────────────────────────────────────┘
setIsShowModal(true)が呼ばれるとLayoutが再レンダリングされ、新しいisOpenの値がSearchModalにpropsとして渡り直します。子は「勝手に変化する」のではなく、親が状態を更新するたびに新しい値を受け取って再描画される、というのがReactの一方向データフローの基本です。
検索キーワードの入力欄でも、まったく同じパターンを使いました。「入力するたびにAPIを叩いて結果を取得する」処理もLayoutに置き、SearchModalには結果の配列と、キーワード変更を伝えるコールバックだけを渡しています。
// Layout.tsx(抜粋)
const searchNotes = async (keyword: string) => {
const notes = await noteRepository.find({ keyword });
setSearchResult(notes);
};
<SearchModal notes={searchResult} onKeywordChange={searchNotes} />
「状態を持つのは1箇所」「子はpropsで受け取ったものを表示するかコールバックを呼ぶだけ」という同じ形が、モーダルの開閉にもキーワード検索にも当てはまります。
他の状態共有手法との使い分け
リフトアップは基本形ですが、万能ではありません。プロジェクトの規模やstateの性質によって、他の選択肢も検討します。
| 手法 | 向いているケース |
|---|---|
| リフトアップ | 今回のように、共通の親が近い(1〜2階層)シンプルな状態共有 |
| Context API | 間に多くのコンポーネントを挟む、遠く離れたコンポーネント間の共有(詳しくはReact Contextの記事を参照) |
| Jotai / Redux などの状態管理ライブラリ | アプリ全体で参照される、コンポーネントツリーの構造に依存しないグローバルな状態 |
| TanStack Query / SWR | サーバーから取得したデータのキャッシュ・再取得・ローディング管理 |
| URLのクエリパラメータ | 検索キーワードなど、ブラウザの戻る操作や共有リンクと相性を持たせたい状態 |
実はこのプロジェクトでは、ノート一覧やログインユーザー情報はJotaiでグローバル管理しつつ、検索モーダルの開閉状態のような「その場限りの小さな状態」はリフトアップで済ませる、という使い分けをしています。すべてをグローバルstateに寄せる必要はなく、状態の寿命と影響範囲に応じて手法を選ぶのがポイントです。
今回の実装の改善余地
現状の検索機能は、入力のたびに無条件でAPIリクエストを送っています。
<CommandInput onValueChange={onKeywordChange} />
1文字打つたびにリクエストが飛ぶため、サーバー負荷やAPIコール数の観点ではデバウンス(入力が一定時間止まってからリクエストする)を挟むのが望ましいです。ここはリフトアップ自体の話ではなく、コールバックの呼び出し頻度の制御の話なので、別途対応する予定です。
まとめ
- 兄弟関係にあるコンポーネント同士は、propsを直接やり取りできない
- リフトアップは、状態を共通の親に引き上げて、子には値とコールバックだけをpropsで渡すパターン
- 子は「表示する」「コールバックを呼ぶ」だけに徹し、ロジックは親に集約する
- 状態の寿命や影響範囲によって、Context・グローバル状態管理・URLなど他の手法との使い分けを検討する
小さな機能ほど、まずはリフトアップで素直に実装してみて、本当に必要になったときに他の手法へ乗り換える、というのがちょうど良い距離感だと感じています。