目次
11 章

状態のリフトアップ(lifting state up)

2つの兄弟コンポーネントが同じデータを共有するときに使う核心パターン。いつリフトアップし、いつしないか、そして12章 useContext への自然な橋渡しまで扱います。

10章で外の世界とやり取りする道具である useEffect を扱いました。ここまで見てきたコンポーネントはすべて 自分自身の state を持っていました。しかし実際のアプリでは、複数のコンポーネントが同じデータを共有 しなければならないことが多いです。本章ではそのときに使う核心パターンである 状態のリフトアップ(lifting state up) を扱います。

本章のモデルは12章(useContext)へ自然につながります。リフトアップで解ける場合と、深さが深くなって別の道具が必要になる場合の境目を本章の最後で示します。

データは一方向に流れる #

4章で軽く触れた原則です。React のデータは 親から子へ一方向 に流れます。子が親のデータを直接変えることはできず、子同士で直接データをやり取りすることもできません。

では、2つの兄弟コンポーネントが同じデータを共有 しなければならないときはどうすればよいでしょうか。答えはシンプルです。

共通の親に state を移す。

これが「状態のリフトアップ」です。2つの子が共通で使う state を、彼らのもっとも近い共通の親に置き、その親が props として子に渡す方法です。

問題状況 — 為替計算機 #

KRW(韓国ウォン)と USD(米ドル)を相互に変換するコンポーネントを2つ作るとします。最初は各自が自分の入力値を持つ形にしてみます。

src/CurrencyInput.jsx(最初の試み)
import { useState } from 'react';

function CurrencyInput({ label }) {
  const [amount, setAmount] = useState('');

  return (
    <div>
      <label>{label}: </label>
      <input
        type="number"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
    </div>
  );
}

export default CurrencyInput;
src/App.jsx(最初の試み)
import CurrencyInput from './CurrencyInput';

function App() {
  return (
    <div>
      <CurrencyInput label="KRW" />
      <CurrencyInput label="USD" />
    </div>
  );
}

2つの入力欄はそれぞれ動作しますが、互いに無関係です。一方に 1000 ウォンを入れても、もう一方の欄に換算された USD が自動では入りません。2つのコンポーネントが それぞれ別の state を持っているからです。

状態のリフトアップを適用する #

この問題は、2つの入力欄の state を 共通の親である App にリフトアップして 解決します。

src/App.jsx(修正版)
import { useState } from 'react';
import CurrencyInput from './CurrencyInput';

const RATE = 1300; // 1 USD = 1300 KRW

function App() {
  const [krw, setKrw] = useState('');

  const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);

  function handleKrwChange(value) {
    setKrw(value);
  }

  function handleUsdChange(value) {
    setKrw(value === '' ? '' : (Number(value) * RATE).toString());
  }

  return (
    <div>
      <CurrencyInput label="KRW" value={krw} onChange={handleKrwChange} />
      <CurrencyInput label="USD" value={usd} onChange={handleUsdChange} />
    </div>
  );
}

export default App;
src/CurrencyInput.jsx(修正版)
function CurrencyInput({ label, value, onChange }) {
  return (
    <div>
      <label>{label}: </label>
      <input
        type="number"
        value={value}
        onChange={(e) => onChange(e.target.value)}
      />
    </div>
  );
}

export default CurrencyInput;

主な変更点:

  • CurrencyInput は自分の state を持たなくなった(controlled component を props で受け取る形)
  • 真実の源(source of truth)となる krw state は App にたった1つだけ存在
  • USD は krw から計算して表示(別の state にしない)
  • ユーザーがどちらの欄に入力しても、最終的に同じ krw state を更新
  • state が変わると2つの子がどちらも再レンダリングされ、画面が同期される

これで一方の欄に値を入れると、もう一方の欄が自動で換算された値で埋まります。2つのコンポーネントが互いに通信しているように見えますが、実際には 共通の親を経由して相互作用 しています。

子 → 親へデータを送る方法 #

上の例で、子(CurrencyInput)は自分が受け取った入力を親にどうやって伝えたのでしょうか。

<CurrencyInput onChange={handleKrwChange} />

親がハンドラ関数を props として渡し、子がその関数を呼び出して値を引数で渡すパターンです。子が親の state を直接いじるのではなく、親が用意した「こういうことが起きたら教えて」というチャンネル(コールバック関数)を通じて知らせるわけです。

このパターンは6章(イベントハンドリング)で軽く見たものの拡張です。あのときは単にクリックを通知する程度でしたが、今回は変更された値まで伝えます。

どこまでリフトアップするか #

答えは 「そのデータを必要とするすべてのコンポーネントのもっとも近い共通の祖先まで」 です。

次のコンポーネントツリーを想像してください。

コンポーネントツリー
App
├── Header
└── Main
    ├── Sidebar
    └── Content
        ├── Article
        └── Comments

ArticleComments が同じデータを共有するなら、その state は Content にあれば十分です。App までリフトアップする必要はありません。

HeaderArticle が共有するなら、App までリフトアップする必要があります。それが2つのもっとも近い共通の祖先だからです。

上に持ち上げすぎると、そのstate が必要のないコンポーネントまで props を受け取って下に渡すだけ という事態が起きます。これを 「prop drilling(プロップドリリング)」 と呼びます。次の12章でこの問題を解決する道具である Context を扱います。本章では「共通の親まで」の原則を覚えておいてください。

唯一の真実の源(Single Source of Truth) #

状態のリフトアップのもうひとつ重要な示唆は、同じ情報を複数の場所に重複して保存しない という原則です。上の為替の例をもう一度見てください。

const [krw, setKrw] = useState('');
const usd = krw === '' ? '' : (Number(krw) / RATE).toFixed(2);

USD を別の state にせず、krw から計算 しています。もし USD も別の state にしていたら、2つの値を常に一致させるのが難しくなります。片方が変わるたびにもう片方を追従させる effect が必要になり、ちょっとした拍子に同期バグが入り込みます。

計算可能な値は state にしないでください。 本物の state はユーザーが直接入力するか、外部から受け取った「源の情報」だけであり、そこから派生する値はただの 変数として計算 すればよいです。

この原則は10章で見た「useEffect を使うべきでない場合」と同じ文脈です。単純な計算はただの変数で、本当に外の世界との同期だけを useEffect で。

より大きな例 — カウンター + 表示コンポーネント #

もうひとつ見てみます。+1 / -1 ボタンが入った Controls と、カウントと偶数 / 奇数の判定を見せる Display があるとしましょう。

src/Controls.jsx
function Controls({ onIncrement, onDecrement, onReset }) {
  return (
    <div>
      <button onClick={onIncrement}>+1</button>
      <button onClick={onDecrement}>-1</button>
      <button onClick={onReset}>リセット</button>
    </div>
  );
}

export default Controls;
src/Display.jsx
function Display({ count }) {
  return (
    <div>
      <h2>{count}</h2>
      <p>{count % 2 === 0 ? '偶数' : '奇数'}</p>
    </div>
  );
}

export default Display;

2つのコンポーネントは 同じカウント を共有します。Controls はカウントを変更し、Display はカウントを見せます。共通の親である App に state を置きます。

src/App.jsx
import { useState } from 'react';
import Controls from './Controls';
import Display from './Display';

function App() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ padding: '16px' }}>
      <Display count={count} />
      <Controls
        onIncrement={() => setCount(prev => prev + 1)}
        onDecrement={() => setCount(prev => prev - 1)}
        onReset={() => setCount(0)}
      />
    </div>
  );
}

export default App;

ControlsDisplay は互いの存在すら知りません。それぞれ親とだけ会話し、親がその間を仲介します。各子コンポーネントが単純になり、再利用性も高まる のが lifting state up の大きな利点です。

Display だけを切り出して別の画面で使うこともできるし、Controls だけを別のカウンターのコントローラーとして使うこともできます。2つのコンポーネントが直接つながっていたら、こうした再利用は不可能でした。

リフトアップの限界 — 次の章への橋渡し #

状態のリフトアップは強力なパターンですが、ひとつ弱点があります。そのデータを必要とするコンポーネント同士の距離が遠くなると、間のコンポーネントが自分とは無関係な props をただ伝達するだけ という事態が起きます。コンポーネントツリーが深くなるほど、この prop drilling が負担になります。

深さが深くなると
<App user={user}>
  <Layout user={user}>
    <Sidebar user={user}>
      <ProfileMenu user={user}>
        <UserAvatar user={user} />  {/* 実際に user が必要な場所 */}
      </ProfileMenu>
    </Sidebar>
  </Layout>
</App>

LayoutSidebarProfileMenuuser に関心がありません。ただ下に渡すために props を受け取っているだけです。次の 12章 useContextでは、この問題を解決する Context を扱います。

そしてツリー全体ではなく特定のドメイン(グローバルなユーザー情報、カート、通知)の複雑な状態が複数の場所で共有される必要があれば、Context でも足りなくなります。そのときは Zustand / Jotai / Redux Toolkit のような外部の状態ライブラリを使うほうがよいです。本書の5部はこの外部ツール陣営を直接は扱いませんが、12章で Context との境目を示します。

やってみよう #

9章で作ったサインアップフォームを、子コンポーネントに分解しつつ state を親(SignupForm)に置く形にリファクタリングしてみましょう。

src/TextField.jsx を作ります(再利用可能な入力フィールド)。

src/TextField.jsx
function TextField({ label, name, value, onChange, type = 'text' }) {
  return (
    <div style={{ marginBottom: '8px' }}>
      <label style={{ display: 'inline-block', width: '80px' }}>{label}: </label>
      <input
        type={type}
        name={name}
        value={value}
        onChange={(e) => onChange(name, e.target.value)}
      />
    </div>
  );
}

export default TextField;

src/SignupForm.jsx:

src/SignupForm.jsx
import { useState } from 'react';
import TextField from './TextField';

function SignupForm() {
  const [form, setForm] = useState({ name: '', email: '', password: '' });

  function handleFieldChange(name, value) {
    setForm(prev => ({ ...prev, [name]: value }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log('登録情報:', form);
  }

  const isValid = form.name && form.email && form.password.length >= 8;

  return (
    <form onSubmit={handleSubmit} style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>会員登録</h2>
      <TextField label="名前" name="name" value={form.name} onChange={handleFieldChange} />
      <TextField label="メール" name="email" type="email" value={form.email} onChange={handleFieldChange} />
      <TextField label="パスワード" name="password" type="password" value={form.password} onChange={handleFieldChange} />
      <button type="submit" disabled={!isValid}>登録</button>
      {!isValid && (
        <p style={{ color: 'red', fontSize: '12px' }}>すべての項目を入力しパスワードは8文字以上にしてください</p>
      )}
    </form>
  );
}

export default SignupForm;

ここで起こっていること:

  • TextField は自分の state を持ちません。表示する値(value)と変更チャンネル(onChange)だけを props で受け取ります
  • 本物の state(form)は親の SignupForm にある
  • 子が入力を受け取ると onChange(name, value) で親に伝える
  • 親がオブジェクトの該当フィールドを更新
  • state が変わると親が再レンダリングされ、子も新しい props を受け取る

子コンポーネント(TextField)が入力の種類に関係なく一般化されているので、新しいフィールドを追加するには1行(<TextField label="..." name="..." />)だけ増やせば済みます。

練習問題 #

  1. 上の為替計算機に通貨を1つ追加してみてください。KRW / USD / JPY の3つの入力欄が同時に同期します。真実の源は KRW state ひとつだけ維持し、USD と JPY はそこから計算します。レートは 1 USD = 1300 KRW、1 JPY = 9 KRW と仮定します。
  2. DisplayControls が親なしで同じカウントを共有できるか、簡単に考えてみてください。答えは「できない(lifting up なしでは)」です。その理由を React の一方向データフロー原則で1段落説明してみてください。次の12章の Context も結局はツリー内でデータを流す方法であることに注目するとよいです。
  3. 9章のサインアップフォームの例を、上の TextField + SignupForm パターンで実際にリファクタリングしてみてください。ラジオ / チェックボックス / textarea ごとの子コンポーネントも作ってみます。どのコンポーネントが props を中継するだけの「仲介者」役なのか、それが prop drilling の始まりなのかを実感できれば、12章が自然に読めるようになります。

一行まとめ: データは親 → 子の一方向に流れる。2つのコンポーネントが同じ state を共有しなければならないなら 共通の親までリフトアップする。子が親に知らせるチャンネルは コールバック関数 prop(onChangeonClick など)。計算可能な値は別の state にせず変数で(Single Source of Truth)。子コンポーネントが controlled になると、より小さく再利用しやすい単位になる。

次の章 #

次の 12章 useContextでは、prop drilling 問題を解決してくれる Context API を扱います。ツリーのどこからでも、データを一度に取り出して使えるチャンネルを作るモデルです。そして Context のコスト(value 変更時の広範囲な再レンダリング)と、外部の状態ライブラリへの境目も合わせて整理します。

X