1. ホーム
  2. React

【React】状態のリフトアップ(Lifting State Up)で兄弟コンポーネント間の状態を共有する方法

Share

2つの子コンポーネントで同じ値を扱いたいのに、それぞれが別々に useState を持っていて、片方を変えてももう片方に反映されない——React を書いていると必ずぶつかる悩みです。これを解決する定番の考え方が「状態のリフトアップ(Lifting State Up)」です。共有したい状態を、それを必要とするコンポーネントの共通の親へ移し、単一の情報源(Single Source of Truth)にまとめます。この記事では、なぜリフトアップが必要なのかを整理したうえで、基本形、検索フィルタを使った実践例、データの流れる向き、そして「上げすぎ」で起きる問題までを、動く TypeScript のコードで初心者〜中級者向けに解説します。

兄弟コンポーネントが状態を共有できずに困る場面

たとえば、検索キーワードを入力する SearchInput と、その条件で絞り込んだ結果を出す UserList という2つのコンポーネントを、同じ親の中に並べて置いたとします。この2つは兄弟(sibling)の関係です。ここで、それぞれが自分の中に状態を持つと、次のようにお互いを知る手段がなくなってしまいます。

分離してしまった例
// SearchInput は自分の中にキーワードを抱えている
function SearchInput() {
  const [query, setQuery] = useState("");
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

// UserList からは SearchInput の query を知る方法がない
function UserList() {
  const users = ["佐藤", "鈴木", "田中"];
  return (
    <ul>
      {users.map((u) => (
        <li key={u}>{u}</li>
      ))}
    </ul>
  );
}

querySearchInput の内側に閉じこもっているため、UserList はその値を受け取れません。React には兄弟同士が直接データをやり取りする仕組みはなく、状態はそれを持つコンポーネントとその子孫にしか流せないからです。同じ情報を2つの場所で別々に持つと、片方を更新してももう片方は古いまま、という「同期できない」状態に陥ります。

共通の親に状態を移す(リフトアップの基本形)

解決策はシンプルで、共有したい状態を2つの子の共通の親へ移すことです。親が useState で状態を1つだけ持ち、その値を props で子に渡し、更新用の関数も props で渡します。こうすると状態の置き場所が1か所に決まり、単一の情報源(Single Source of Truth)になります。まずは、子が自分で状態を持たず、親から渡された値だけで表示する「制御されたコンポーネント」の形を見てみます。

SearchInput.tsx
type Props = {
  value: string;
  onChange: (value: string) => void;
};

// 自分では state を持たず、親から受け取った値だけを表示する
// こうしたコンポーネントを「制御されたコンポーネント」と呼ぶ
function SearchInput({ value, onChange }: Props) {
  return (
    <input
      type="text"
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder="名前で検索"
    />
  );
}

export default SearchInput;

value(表示する値)と onChange(値を変えてほしいときに親へ知らせる関数)を props で受け取っているのがポイントです。入力があると onChange を呼ぶだけで、実際にどう状態を更新するかは親に任せています。次に、この子を使う親側を書きます。

App.tsx
import { useState } from "react";
import SearchInput from "./SearchInput";

function App() {
  // 状態は親が1つだけ持つ(ここが唯一の情報源)
  const [query, setQuery] = useState<string>("");

  return (
    <div>
      {/* 値と更新用の関数を props で子に渡す */}
      <SearchInput value={query} onChange={setQuery} />
      <p>入力中のキーワード:{query}</p>
    </div>
  );
}

export default App;

状態を App に持ち上げた(リフトアップした)ことで、同じ querySearchInput にも、その隣の <p> にも同時に反映できるようになりました。子で入力すると setQuery が呼ばれて親の状態が変わり、その新しい値がふたたび両方に流れていきます。これで「同じ値を複数の場所で扱う」下地が整いました。

検索フィルタで兄弟同士を連動させる

基本形が分かったところで、冒頭の「入力」と「一覧」という2つの兄弟を実際に連動させてみます。絞り込みに使う query を親が持ち、入力側と表示側の両方へ渡すのがポイントです。まず、キーワードを受け取って一覧を絞り込む UserList を書きます。

UserList.tsx
type Props = {
  query: string;
};

const ALL_USERS = ["佐藤", "鈴木", "高橋", "田中", "伊藤"];

// query を props で受け取り、その条件で絞り込むだけ
function UserList({ query }: Props) {
  const filtered = ALL_USERS.filter((name) =>
    name.includes(query)
  );

  return (
    <ul>
      {filtered.map((name) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
}

export default UserList;

UserList も自分では状態を持たず、渡された query をもとに表示を組み立てるだけです。あとは親が、この querySearchInputUserList の両方に渡せば、入力に応じて一覧がリアルタイムで絞り込まれます。

App.tsx
import { useState } from "react";
import SearchInput from "./SearchInput";
import UserList from "./UserList";

function App() {
  // 兄弟が共有する状態を、共通の親 App が1つだけ持つ
  const [query, setQuery] = useState("");

  return (
    <div>
      {/* 更新は SearchInput から。値は両方の子へ流す */}
      <SearchInput value={query} onChange={setQuery} />
      <UserList query={query} />
    </div>
  );
}

export default App;

これで、SearchInput に文字を打つたびに setQuery が親の状態を更新し、その値が UserList にも渡って一覧が絞り込まれます。状態を親に1つだけ持たせたことで、2つの兄弟がひとつの値を見て動くようになりました。入力側と表示側が食い違うことは、もう起きません。

データは下へ、イベントは上へ

ここまでのコードには、React の一方向データフロー(one-way data flow)という原則がそのまま表れています。整理すると、値は props として親から子へ下へ流れ、変更のきっかけはコールバック関数を通じて子から親へ上へ伝わります。

先ほどの例でいえば、query(データ)は App から SearchInputUserList へ下っていきます。一方、入力が起きたという「出来事」は、onChange(子が呼ぶコールバック)を通じて App へ上がり、そこで setQuery が状態を更新します。子が親の状態を直接書き換えるのではなく、「変えてください」と親に依頼し、更新の主導権はつねに状態を持つ親が握る、という形です。データの流れが一方向に保たれるので、値がどこで変わったのかを追いやすく、予期しない書き換えが起きにくくなります。

状態はどこまで上げるべきか

「では、状態はとにかく上のほうへ持っていけばいいのか」というと、そうではありません。指針はシンプルで、その状態を必要とするすべてのコンポーネントの、最も近い共通の親に置く、というものです。共通の親であれば、そこから下にいるすべての子へ props で値を届けられるからです。

先ほどの例では、query を必要とするのは SearchInputUserList の2つで、両者に共通する最も近い親は App でした。だから App に置くのが自然です。もし片方でしか使わない状態なら、わざわざ親へ上げる必要はなく、その使うコンポーネントの中に持たせておけば十分です。まず「どのコンポーネントがこの値を使うのか」を洗い出し、それらの一番近い共通の親を探す——この順番で考えると、置き場所を迷いにくくなります。

上げすぎると起きるバケツリレー(prop drilling)

リフトアップは便利ですが、必要以上に高い階層へ状態を上げてしまうと、別の問題が出てきます。状態を持つ親と、それを実際に使う末端のコンポーネントとのあいだの階層が深いと、途中のコンポーネントが「自分では使わない props」をひたすら下へ受け渡すことになります。これを俗にバケツリレー(prop drilling)と呼びます。

深い受け渡しの例
// query を実際に使うのは末端の SearchInput だけなのに、
// 途中の Page も Toolbar も props を素通しさせている
function Page({ query, onChange }: Props) {
  return <Toolbar query={query} onChange={onChange} />;
}

function Toolbar({ query, onChange }: Props) {
  // Toolbar 自身は query を使わないのに受け渡すだけ
  return <SearchInput value={query} onChange={onChange} />;
}

途中のコンポーネントが本来関係のない props を抱えることになり、コードが読みづらく、修正もしづらくなります。こうした受け渡しの連鎖が深くなってきたら、useContext を使って、途中を飛ばして必要なコンポーネントへ直接値を届ける方法を検討します。Context の詳しい使い方はここでは踏み込みませんが、「リフトアップした状態のバケツリレーが深くなってきたときの次の一手」として頭に置いておくとよいでしょう。まずは近い共通の親へ素直にリフトアップし、深さが問題になってから Context を考える、という順番がおすすめです。

まとめ

状態のリフトアップは、兄弟コンポーネントなど複数の場所で同じ状態を共有したいときの基本テクニックです。共有したい状態を共通の親へ移し、値を props で下に渡し、更新用の関数(コールバック)で変更を上に伝える——この形にすることで、状態の置き場所が1か所に定まり、単一の情報源(Single Source of Truth)になります。子は自分では状態を持たない「制御されたコンポーネント」になり、データは下へ、イベントは上へという一方向データフローが保たれます。置き場所は、その状態を使うすべてのコンポーネントの最も近い共通の親が基本です。ただし上げすぎるとバケツリレー(prop drilling)が深くなるため、深くなってきたら useContext を検討する、という順序で考えると迷いにくくなります。

参考ページ