React基礎講座 #11 stateのリフトアップ (lifting state up)
前回は外部の世界と相互作用するためのツール、useEffectを学びました。これまで扱ってきたコンポーネントは、すべて自分自身の stateを持っていました。しかし実際のアプリでは、複数のコンポーネントが同じデータを共有しなければならない場面が多くあります。今回はそのようなときに使う中核パターン、stateのリフトアップ (lifting state up) を学んでいきましょう。
データは一方向に流れる #
#4で少し触れた原則です。Reactのデータは親から子へ一方向に流れます。子が親のデータを直接変更することはできず、子同士が直接データをやり取りすることもできません。
それでは、2つの兄弟コンポーネントが同じデータを共有しなければならないときはどうすればよいのでしょうか。答えは意外とシンプルです。
共通の親に state を移す。
これが「stateのリフトアップ」です。2つの子が共通して使う state を、彼らの最も近い共通の親に置き、その親が props として子たちに渡す方式ですね。
問題の状況 — 為替レート計算機 #
ウォンとドルを互いに変換するコンポーネントを2つ作るとします。最初はそれぞれが自分の入力値を持つように作ってみましょう。
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;import CurrencyInput from './CurrencyInput';
function App() {
return (
<div>
<CurrencyInput label="ウォン (KRW)" />
<CurrencyInput label="ドル (USD)" />
</div>
);
}2つの入力欄はうまく動作しますが、互いに無関係です。片方に1000ウォンを入力しても、もう一方の欄に換算されたドルが自動で入力されません。2つのコンポーネントがそれぞれ別の stateを持っているからですね。
stateのリフトアップを適用 #
この問題は、2つの入力欄の state を共通の親である App にリフトアップして解決します。
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;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 で受け取る形)- 真実の源となる
krwstate はAppにただ1つだけ存在 - USD は
krwから計算して表示 (別の state にしない) - ユーザーがどちらの欄に入力しても、結局同じ
krwstate を更新 - state が変わると2つの子が両方とも再レンダリングされて画面が同期される
これで一方の欄に値を入れると、もう一方の欄が自動で換算された値で埋まります。2つのコンポーネントがあたかも互いに通信しているように見えますが、実際には共通の親を経由して相互作用しているのです。
子 → 親へデータを送り上げる方法 #
上の例で子 (CurrencyInput) は、自分が受け取った入力をどうやって親に伝えたでしょうか。
<CurrencyInput onChange={handleKrwChange} />親がハンドラ関数を props として渡し、子がその関数を呼び出しながら値を引数として渡すパターンです。子が直接親の state を触るのではなく、親が用意した「こういうことが起きたら知らせて」というチャネル (= コールバック関数) を通じて知らせるのです。
このパターンは#6で少し見たものの拡張です。あのときは単純にクリックを知らせる程度でしたが、今回は変更された値まで伝えるのです。
どこまでリフトアップすべきか? #
答えは「そのデータを必要とするすべてのコンポーネントの最も近い共通の祖先まで」です。
次のコンポーネントツリーを想像してみてください。
App
├── Header
└── Main
├── Sidebar
└── Content
├── Article
└── Commentsもし Article と Comments が同じデータを共有するなら、その state は Content にあれば十分です。App までリフトアップする必要はありませんね。
もし Header と Article が共有するなら、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 があるとします。
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;function Display({ count }) {
return (
<div>
<h2>{count}</h2>
<p>{count % 2 === 0 ? '偶数' : '奇数'}</p>
</div>
);
}
export default Display;2つのコンポーネントは同じカウントを共有します。Controls はカウントを変更し、Display はカウントを表示しますね。共通の親である App に state を置きます。
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;Controls と Display は互いの存在すら知りません。それぞれ親とだけ会話して、親がその間を仲介します。各子コンポーネントはシンプルになり、再利用性が高まるというのが lifting state up の大きな利点です。
Display だけを切り離して別の画面で使うこともできますし、Controls だけを別のカウンターのコントローラとして使うこともできます。2つのコンポーネントが直接つながっていたら、このような再利用は不可能だったでしょう。
自分でやってみる #
#9で作った会員登録フォームを、子コンポーネントに分解しながら state を親 (SignupForm) に置く形にリファクタリングしてみましょう。
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:
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 (
onChange、onClickなど) - 計算可能な値は別の state にせず変数で (Single Source of Truth)
- 子コンポーネントが controlled になると、より小さく再利用しやすい単位になる
stateのリフトアップは強力なパターンですが、そのデータを必要とするコンポーネント間の距離が遠くなると、中間のコンポーネントたちが自分と無関係な props をただ渡すだけの状況が起きます。コンポーネントツリーが深くなるほどこの「prop drilling」が負担になりますね。次の記事「React基礎講座 #12 useContext」では、この問題を解決するツールである Context を学んでいきます。