· yuta

Reactのリフトアップ(Lifting State Up)を検索機能の実装例で理解する

#react#typescript#frontend

個人開発中のアプリに検索モーダルを実装していて、「サイドバーの検索ボタン」と「検索結果を出すモーダル」という兄弟関係にある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コール数の観点ではデバウンス(入力が一定時間止まってからリクエストする)を挟むのが望ましいです。ここはリフトアップ自体の話ではなく、コールバックの呼び出し頻度の制御の話なので、別途対応する予定です。

まとめ

小さな機能ほど、まずはリフトアップで素直に実装してみて、本当に必要になったときに他の手法へ乗り換える、というのがちょうど良い距離感だと感じています。