React基礎講座 #11 stateのリフトアップ (lifting state up)

読了 8分

前回は外部の世界と相互作用するためのツール、useEffectを学びました。これまで扱ってきたコンポーネントは、すべて自分自身の stateを持っていました。しかし実際のアプリでは、複数のコンポーネントが同じデータを共有しなければならない場面が多くあります。今回はそのようなときに使う中核パターン、stateのリフトアップ (lifting state up) を学んでいきましょう。

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

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

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

共通の親に state を移す。

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

問題の状況 — 為替レート計算機 #

ウォンとドルを互いに変換するコンポーネントを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ウォンを入力しても、もう一方の欄に換算されたドルが自動で入力されません。2つのコンポーネントがそれぞれ別の stateを持っているからですね。

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 で受け取る形)
  • 真実の源となる 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) #

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

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

USD を別の state にせず、krw から計算しています。もし USD も別の state にしていたら、2つの値を常に一致させるのが難しくなります。一方が変わったときにもう一方を追従させる effect が必要になり、下手をすると同期バグが発生します。

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

この原則は useEffect の記事で見た「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つのコンポーネントが直接つながっていたら、このような再利用は不可能だったでしょう。

自分でやってみる #

#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="..." />) だけ書き足せば済みます。

まとめ #

今回の記事では、2つの兄弟コンポーネントが同じデータを共有する中核パターン、stateのリフトアップ (lifting state up) を学びました。整理すると:

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

stateのリフトアップは強力なパターンですが、そのデータを必要とするコンポーネント間の距離が遠くなると、中間のコンポーネントたちが自分と無関係な props をただ渡すだけの状況が起きます。コンポーネントツリーが深くなるほどこの「prop drilling」が負担になりますね。次の記事「React基礎講座 #12 useContext」では、この問題を解決するツールである Context を学んでいきます。

X