はじめに

前回の記事では、イベント処理を使ってユーザーのアクションに応答する方法を解説しました。本記事では、複数のコンポーネント間で状態を共有するための重要なパターン「状態のリフトアップ(Lifting State Up)」について詳しく解説します。

Reactでは、各コンポーネントが独自の状態を持つことができます。しかし、複数のコンポーネントが同じデータを参照・更新する必要がある場合はどうすればよいでしょうか。そこで登場するのが「状態のリフトアップ」です。状態を共通の親コンポーネントに移動し、propsを通じて子コンポーネントへ渡すことで、兄弟コンポーネント間でもデータを共有できるようになります。

本記事を読むことで、以下のことができるようになります。

  • 状態のリフトアップの概念と必要性の理解
  • 兄弟コンポーネント間でのデータ共有の実装
  • 単一の信頼できる情報源(Single Source of Truth)の設計
  • 制御コンポーネントと非制御コンポーネントの使い分け
  • 実践的なフォーム同期やフィルタリング機能の実装

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • 関数コンポーネントの基本
  • useStateの使い方
  • Propsの基本
  • イベント処理の基本

状態のリフトアップが必要な理由

問題: 独立した状態を持つコンポーネント

まず、状態のリフトアップが必要になる典型的なケースを見てみましょう。以下は、2つの独立したパネルコンポーネントを持つアコーディオンの例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { useState } from 'react';

function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div style={{ border: '1px solid #ccc', marginBottom: '8px' }}>
      <button
        onClick={() => setIsOpen(!isOpen)}
        style={{
          width: '100%',
          padding: '12px',
          textAlign: 'left',
          background: '#f5f5f5',
          border: 'none',
          cursor: 'pointer',
        }}
      >
        {title} {isOpen ? '▼' : '▶'}
      </button>
      {isOpen && (
        <div style={{ padding: '12px' }}>
          {children}
        </div>
      )}
    </div>
  );
}

function Accordion() {
  return (
    <div style={{ maxWidth: '400px' }}>
      <h2>よくある質問</h2>
      <Panel title="Reactとは何ですか?">
        ReactはUIを構築するためのJavaScriptライブラリです
        コンポーネントベースのアプローチで再利用可能なUI部品を作成できます
      </Panel>
      <Panel title="状態管理とは何ですか?">
        コンポーネントが保持する動的なデータを管理することです
        useStateフックを使って状態を定義し更新できます
      </Panel>
    </div>
  );
}

export default Accordion;

期待される結果と問題点

このコードを実行すると、以下の動作になります。

  • 各パネルは独立して開閉できる
  • 複数のパネルを同時に開くことが可能

しかし、「一度に1つのパネルだけ開く」というアコーディオンの一般的な動作を実現するには、どうすればよいでしょうか。各Panelが独自のisOpen状態を持っているため、互いの状態を認識することができません。

これが状態のリフトアップが必要になる典型的なケースです。

状態のリフトアップの3ステップ

React公式ドキュメントでは、状態のリフトアップを3つのステップで説明しています。

  1. 子コンポーネントから状態を削除する
  2. 共通の親コンポーネントからハードコードされたデータを渡す
  3. 共通の親コンポーネントに状態を追加する

順を追って実装していきましょう。

ステップ1: 子コンポーネントから状態を削除

まず、PanelコンポーネントからuseStateを削除し、代わりにisOpenをpropsとして受け取るように変更します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Before: 自分で状態を管理
function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  // ...
}

// After: 親から状態を受け取る
function Panel({ title, children, isOpen }) {
  // useStateは削除
  // ...
}

ステップ2: 親から固定値を渡す

親コンポーネントから固定値を渡して、動作を確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function Accordion() {
  return (
    <div>
      <Panel title="質問1" isOpen={true}>
        回答1の内容
      </Panel>
      <Panel title="質問2" isOpen={false}>
        回答2の内容
      </Panel>
    </div>
  );
}

ステップ3: 親に状態を追加し、イベントハンドラを渡す

最後に、親コンポーネントに状態を追加し、子コンポーネントから親の状態を更新できるようにイベントハンドラをpropsとして渡します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useState } from 'react';

function Panel({ title, children, isOpen, onToggle }) {
  return (
    <div style={{ border: '1px solid #ccc', marginBottom: '8px' }}>
      <button
        onClick={onToggle}
        style={{
          width: '100%',
          padding: '12px',
          textAlign: 'left',
          background: isOpen ? '#e3f2fd' : '#f5f5f5',
          border: 'none',
          cursor: 'pointer',
          fontWeight: isOpen ? 'bold' : 'normal',
        }}
      >
        {title} {isOpen ? '▼' : '▶'}
      </button>
      {isOpen && (
        <div style={{ padding: '12px', background: '#fafafa' }}>
          {children}
        </div>
      )}
    </div>
  );
}

function Accordion() {
  const [activeIndex, setActiveIndex] = useState(null);
  
  return (
    <div style={{ maxWidth: '400px' }}>
      <h2>よくある質問</h2>
      <Panel
        title="Reactとは何ですか?"
        isOpen={activeIndex === 0}
        onToggle={() => setActiveIndex(activeIndex === 0 ? null : 0)}
      >
        ReactはUIを構築するためのJavaScriptライブラリです
        コンポーネントベースのアプローチで再利用可能なUI部品を作成できます
      </Panel>
      <Panel
        title="状態管理とは何ですか?"
        isOpen={activeIndex === 1}
        onToggle={() => setActiveIndex(activeIndex === 1 ? null : 1)}
      >
        コンポーネントが保持する動的なデータを管理することです
        useStateフックを使って状態を定義し更新できます
      </Panel>
      <Panel
        title="リフトアップとは何ですか?"
        isOpen={activeIndex === 2}
        onToggle={() => setActiveIndex(activeIndex === 2 ? null : 2)}
      >
        複数のコンポーネントで共有する状態を共通の親コンポーネントに
        移動させるパターンです兄弟コンポーネント間でのデータ共有が可能になります
      </Panel>
    </div>
  );
}

export default Accordion;

期待される結果

ブラウザで確認すると、以下の動作になります。

  • パネルをクリックすると、そのパネルが開く
  • 別のパネルをクリックすると、以前開いていたパネルが閉じ、新しいパネルが開く
  • 同じパネルを再度クリックすると閉じる
  • 常に1つのパネルだけが開いた状態になる

データフローの図解

状態のリフトアップでは、データは以下のように流れます。

     ┌─────────────────────────────┐
     │        Accordion           │
     │   activeIndex = 0          │
     │   setActiveIndex()         │
     └─────────────────────────────┘
              │         │
    ┌─────────┘         └─────────┐
    │                             │
    ▼                             ▼
┌─────────────────┐       ┌─────────────────┐
│     Panel 1     │       │     Panel 2     │
│  isOpen={true}  │       │  isOpen={false} │
│  onToggle={...} │       │  onToggle={...} │
└─────────────────┘       └─────────────────┘

ポイントは以下のとおりです。

  • 状態は親が所有: activeIndexAccordionが管理
  • データは下へ流れる: isOpenはpropsとして子に渡される
  • イベントは上へ伝わる: onToggleを通じて親の状態を更新

単一の信頼できる情報源(Single Source of Truth)

状態のリフトアップは、「単一の信頼できる情報源(Single Source of Truth)」という設計原則に基づいています。

原則の意味

アプリケーション内の各データは、1つの場所でのみ管理されるべきです。同じデータを複数の場所で管理すると、以下の問題が発生します。

  • データの不整合(同期が取れなくなる)
  • バグの原因になりやすい
  • デバッグが困難

実装のポイント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 悪い例: 同じデータを複数の場所で管理
function Parent() {
  const [items, setItems] = useState(['A', 'B', 'C']);
  return <Child initialItems={items} />;
}

function Child({ initialItems }) {
  // 親のitemsをコピーして独自の状態として管理
  const [localItems, setLocalItems] = useState(initialItems);
  // → 親と子でitemsが不整合になる可能性
}

// 良い例: 親が状態を管理し、子はpropsを使用
function Parent() {
  const [items, setItems] = useState(['A', 'B', 'C']);
  return <Child items={items} onItemsChange={setItems} />;
}

function Child({ items, onItemsChange }) {
  // 親の状態を直接参照・更新
  // → 常に一貫性が保たれる
}

実践例1: 同期するテキスト入力

2つの入力フィールドを同期させる例を実装してみましょう。一方に入力すると、もう一方にも同じテキストが反映されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { useState } from 'react';

function TextInput({ label, value, onChange }) {
  return (
    <label style={{ display: 'block', marginBottom: '16px' }}>
      {label}
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        style={{
          display: 'block',
          width: '100%',
          padding: '8px',
          fontSize: '16px',
          marginTop: '4px',
        }}
      />
    </label>
  );
}

function SyncedInputs() {
  const [text, setText] = useState('');
  
  return (
    <div style={{ maxWidth: '400px', padding: '20px' }}>
      <h2>同期する入力フィールド</h2>
      <TextInput
        label="入力フィールド1"
        value={text}
        onChange={setText}
      />
      <TextInput
        label="入力フィールド2"
        value={text}
        onChange={setText}
      />
      <p style={{ marginTop: '16px', color: '#666' }}>
        現在の値: {text || '(空)'}
      </p>
    </div>
  );
}

export default SyncedInputs;

期待される結果

  • どちらの入力フィールドに入力しても、両方の値が同時に更新される
  • 2つのフィールドは常に同じテキストを表示する

なぜこれが動作するのか

  1. text状態は親のSyncedInputsが所有
  2. 両方のTextInputは同じtextvalueとして受け取る
  3. どちらのTextInputで入力しても、同じsetTextが呼ばれて親の状態が更新される
  4. 親が再レンダリングされ、両方の子に新しいtextが渡される

実践例2: 温度変換コンバーター

摂氏と華氏の温度を相互変換するアプリを実装してみましょう。一方を入力すると、もう一方が自動計算されます。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
import { useState } from 'react';

function TemperatureInput({ scale, temperature, onTemperatureChange }) {
  const scaleNames = {
    c: '摂氏(°C)',
    f: '華氏(°F)',
  };
  
  return (
    <div style={{ marginBottom: '16px' }}>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {scaleNames[scale]}
      </label>
      <input
        type="number"
        value={temperature}
        onChange={(e) => onTemperatureChange(e.target.value)}
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '18px',
          border: '2px solid #ccc',
          borderRadius: '4px',
        }}
        placeholder={`${scaleNames[scale]}を入力`}
      />
    </div>
  );
}

function toCelsius(fahrenheit) {
  return ((fahrenheit - 32) * 5) / 9;
}

function toFahrenheit(celsius) {
  return (celsius * 9) / 5 + 32;
}

function tryConvert(temperature, convert) {
  const input = parseFloat(temperature);
  if (Number.isNaN(input)) {
    return '';
  }
  const output = convert(input);
  const rounded = Math.round(output * 1000) / 1000;
  return rounded.toString();
}

function BoilingVerdict({ celsius }) {
  const temp = parseFloat(celsius);
  if (Number.isNaN(temp)) {
    return <p style={{ color: '#666' }}>温度を入力してください</p>;
  }
  if (temp >= 100) {
    return <p style={{ color: '#e53935', fontWeight: 'bold' }}>水は沸騰します</p>;
  }
  return <p style={{ color: '#1976d2' }}>水はまだ沸騰しません</p>;
}

function TemperatureConverter() {
  const [temperature, setTemperature] = useState('');
  const [scale, setScale] = useState('c');
  
  function handleCelsiusChange(value) {
    setScale('c');
    setTemperature(value);
  }
  
  function handleFahrenheitChange(value) {
    setScale('f');
    setTemperature(value);
  }
  
  const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;
  const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
  
  return (
    <div style={{ maxWidth: '400px', padding: '20px' }}>
      <h2>温度変換コンバーター</h2>
      <TemperatureInput
        scale="c"
        temperature={celsius}
        onTemperatureChange={handleCelsiusChange}
      />
      <TemperatureInput
        scale="f"
        temperature={fahrenheit}
        onTemperatureChange={handleFahrenheitChange}
      />
      <div style={{ 
        marginTop: '20px', 
        padding: '16px', 
        background: '#f5f5f5', 
        borderRadius: '8px' 
      }}>
        <BoilingVerdict celsius={celsius} />
      </div>
    </div>
  );
}

export default TemperatureConverter;

期待される結果

  • 摂氏に「100」と入力すると、華氏には「212」と表示される
  • 華氏に「32」と入力すると、摂氏には「0」と表示される
  • 摂氏が100度以上のとき「水は沸騰します」と表示される

設計のポイント

この例では、親コンポーネントが2つの値を管理しています。

  • temperature: 最後に入力された値
  • scale: どちらの単位で入力されたか(‘c’ または ‘f’)

この2つの値から、摂氏と華氏の両方の表示値を計算しています。これにより、常に一貫した状態を保つことができます。

実践例3: 検索可能なリスト

入力した文字列でリストをフィルタリングする機能を実装してみましょう。検索ボックスとリスト表示が連動します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { useState } from 'react';

function SearchBox({ query, onQueryChange }) {
  return (
    <div style={{ marginBottom: '16px' }}>
      <input
        type="text"
        value={query}
        onChange={(e) => onQueryChange(e.target.value)}
        placeholder="検索..."
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          border: '2px solid #ccc',
          borderRadius: '8px',
        }}
      />
    </div>
  );
}

function ProductList({ products }) {
  if (products.length === 0) {
    return (
      <p style={{ color: '#999', textAlign: 'center', padding: '20px' }}>
        該当する商品がありません
      </p>
    );
  }
  
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {products.map((product) => (
        <li
          key={product.id}
          style={{
            padding: '12px 16px',
            borderBottom: '1px solid #eee',
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
          }}
        >
          <span>{product.name}</span>
          <span style={{ color: '#666', fontSize: '14px' }}>
            {product.category}
          </span>
        </li>
      ))}
    </ul>
  );
}

function ResultCount({ count, total }) {
  return (
    <p style={{ color: '#666', fontSize: '14px', marginBottom: '8px' }}>
      {total}件中 {count}件を表示
    </p>
  );
}

function FilterableProductList() {
  const [query, setQuery] = useState('');
  
  const allProducts = [
    { id: 1, name: 'MacBook Pro', category: 'PC' },
    { id: 2, name: 'iPhone 15', category: 'スマートフォン' },
    { id: 3, name: 'iPad Air', category: 'タブレット' },
    { id: 4, name: 'Apple Watch', category: 'ウェアラブル' },
    { id: 5, name: 'AirPods Pro', category: 'オーディオ' },
    { id: 6, name: 'Magic Keyboard', category: 'アクセサリ' },
    { id: 7, name: 'Studio Display', category: 'モニター' },
    { id: 8, name: 'Mac mini', category: 'PC' },
  ];
  
  const filteredProducts = allProducts.filter((product) =>
    product.name.toLowerCase().includes(query.toLowerCase()) ||
    product.category.toLowerCase().includes(query.toLowerCase())
  );
  
  return (
    <div style={{ maxWidth: '500px', padding: '20px' }}>
      <h2>商品検索</h2>
      <SearchBox query={query} onQueryChange={setQuery} />
      <ResultCount count={filteredProducts.length} total={allProducts.length} />
      <ProductList products={filteredProducts} />
    </div>
  );
}

export default FilterableProductList;

期待される結果

  • 「Pro」と入力すると「MacBook Pro」と「AirPods Pro」が表示される
  • 「PC」と入力するとカテゴリが「PC」の商品が表示される
  • 入力を消すと全商品が表示される

コンポーネント間の関係

     ┌──────────────────────────────┐
     │   FilterableProductList     │
     │      query = "Pro"          │
     │      setQuery()             │
     │      filteredProducts       │
     └──────────────────────────────┘
        │          │           │
        ▼          ▼           ▼
   ┌─────────┐ ┌─────────┐ ┌─────────────┐
   │SearchBox│ │ResultCnt│ │ ProductList │
   │ query   │ │ count   │ │  products   │
   │onChange │ │ total   │ │             │
   └─────────┘ └─────────┘ └─────────────┘

SearchBoxResultCountProductListは兄弟コンポーネントですが、親のFilterableProductListが状態を管理することで、すべてのコンポーネントが同じデータを参照できています。

制御コンポーネントと非制御コンポーネント

状態のリフトアップに関連する重要な概念として、「制御コンポーネント」と「非制御コンポーネント」があります。

制御コンポーネント(Controlled Component)

親から渡されたpropsによって動作が決まるコンポーネントです。状態のリフトアップ後のPanelTextInputがこれに該当します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 制御コンポーネント: 親が動作を制御
function Panel({ isOpen, onToggle, children }) {
  return (
    <div>
      <button onClick={onToggle}>
        {isOpen ? '閉じる' : '開く'}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

非制御コンポーネント(Uncontrolled Component)

自身で状態を管理し、親が直接制御しないコンポーネントです。リフトアップ前のPanelがこれに該当します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 非制御コンポーネント: 自分で状態を管理
function Panel({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? '閉じる' : '開く'}
      </button>
      {isOpen && <div>{children}</div>}
    </div>
  );
}

使い分けの指針

観点 制御コンポーネント 非制御コンポーネント
状態の所有者 親コンポーネント 自分自身
柔軟性 高い(親が自由に制御) 低い(内部動作が固定)
実装の複雑さ やや複雑 シンプル
複数コンポーネントの連携 容易 困難
適した場面 連携が必要な場合 独立して動作する場合

実践例4: タブ切り替えUI

複数のタブコンポーネントが連携する例を実装してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import { useState } from 'react';

function TabButton({ isActive, onClick, children }) {
  return (
    <button
      onClick={onClick}
      style={{
        padding: '12px 24px',
        border: 'none',
        borderBottom: isActive ? '3px solid #1976d2' : '3px solid transparent',
        background: isActive ? '#e3f2fd' : 'transparent',
        color: isActive ? '#1976d2' : '#666',
        fontWeight: isActive ? 'bold' : 'normal',
        cursor: 'pointer',
        fontSize: '16px',
      }}
    >
      {children}
    </button>
  );
}

function TabPanel({ isActive, children }) {
  if (!isActive) return null;
  
  return (
    <div style={{ padding: '20px', background: '#fafafa' }}>
      {children}
    </div>
  );
}

function Tabs() {
  const [activeTab, setActiveTab] = useState('profile');
  
  const tabs = [
    { id: 'profile', label: 'プロフィール' },
    { id: 'settings', label: '設定' },
    { id: 'notifications', label: '通知' },
  ];
  
  return (
    <div style={{ maxWidth: '600px' }}>
      <div style={{ 
        display: 'flex', 
        borderBottom: '1px solid #ddd',
        marginBottom: '0',
      }}>
        {tabs.map((tab) => (
          <TabButton
            key={tab.id}
            isActive={activeTab === tab.id}
            onClick={() => setActiveTab(tab.id)}
          >
            {tab.label}
          </TabButton>
        ))}
      </div>
      
      <TabPanel isActive={activeTab === 'profile'}>
        <h3>プロフィール</h3>
        <p>ユーザー名: React太郎</p>
        <p>メール: react@example.com</p>
        <p>登録日: 2024年1月1日</p>
      </TabPanel>
      
      <TabPanel isActive={activeTab === 'settings'}>
        <h3>設定</h3>
        <label style={{ display: 'block', marginBottom: '12px' }}>
          <input type="checkbox" defaultChecked /> ダークモード
        </label>
        <label style={{ display: 'block', marginBottom: '12px' }}>
          <input type="checkbox" /> 自動保存
        </label>
        <label style={{ display: 'block' }}>
          <input type="checkbox" defaultChecked /> 通知を受け取る
        </label>
      </TabPanel>
      
      <TabPanel isActive={activeTab === 'notifications'}>
        <h3>通知</h3>
        <ul style={{ listStyle: 'none', padding: 0 }}>
          <li style={{ padding: '8px 0', borderBottom: '1px solid #eee' }}>
            新しいメッセージが届きました
          </li>
          <li style={{ padding: '8px 0', borderBottom: '1px solid #eee' }}>
            プロフィールが更新されました
          </li>
          <li style={{ padding: '8px 0' }}>
            ログインがありました
          </li>
        </ul>
      </TabPanel>
    </div>
  );
}

export default Tabs;

期待される結果

  • タブをクリックすると、対応するコンテンツが表示される
  • 選択中のタブは青色でハイライトされる
  • 一度に1つのタブコンテンツだけが表示される

リフトアップの判断基準

すべての状態をリフトアップすべきではありません。以下の基準で判断しましょう。

リフトアップが必要な場合

  • 複数のコンポーネントが同じ状態を参照する
  • 兄弟コンポーネント間でデータを同期する必要がある
  • 親コンポーネントが子の状態を知る必要がある
  • 排他制御が必要(例: 1つだけ選択可能)

リフトアップが不要な場合

  • 状態がそのコンポーネント内でのみ使用される
  • 他のコンポーネントに影響しない内部状態
  • パフォーマンス上の理由で局所化したい状態
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// リフトアップ不要な例: ホバー状態
function Card({ title, content }) {
  const [isHovered, setIsHovered] = useState(false);
  
  return (
    <div
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      style={{
        padding: '16px',
        background: isHovered ? '#f0f0f0' : '#fff',
        transition: 'background 0.2s',
      }}
    >
      <h3>{title}</h3>
      <p>{content}</p>
    </div>
  );
}

ホバー状態は各カードが独立して管理すべきものであり、リフトアップする必要はありません。

オンラインで試す

本記事で解説したサンプルコードは、以下のリンクで実際に動作を確認できます。

よくある間違いとトラブルシューティング

問題1: 子コンポーネント内で状態のコピーを作成する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// NG: propsを初期値として独自の状態を作成
function Child({ value }) {
  const [localValue, setLocalValue] = useState(value);
  // → 親のvalueが変わってもlocalValueは更新されない
}

// OK: propsをそのまま使用
function Child({ value, onChange }) {
  // 状態は持たず、親のvalueを直接使用
  return <input value={value} onChange={onChange} />;
}

問題2: イベントハンドラを渡し忘れる

1
2
3
4
5
// NG: onChangeがないので子から親の状態を更新できない
<Child value={value} />

// OK: 更新関数も一緒に渡す
<Child value={value} onChange={setValue} />

問題3: 過剰なリフトアップ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// NG: すべての状態を最上位に置く(不要な再レンダリングの原因)
function App() {
  const [input1, setInput1] = useState('');
  const [input2, setInput2] = useState('');
  const [input3, setInput3] = useState('');
  // 本来連携不要な状態まですべて管理
}

// OK: 連携が必要な状態のみリフトアップ
function App() {
  const [sharedValue, setSharedValue] = useState('');
  return (
    <>
      <SyncedInputs value={sharedValue} onChange={setSharedValue} />
      <IndependentForm /> {/* 独自の状態を持つ */}
    </>
  );
}

まとめ

本記事では、Reactにおける状態のリフトアップについて解説しました。

学んだこと

  • 状態のリフトアップとは: 共有する状態を共通の親コンポーネントに移動するパターン
  • 3つのステップ: 子から状態を削除 → 親から固定値を渡す → 親に状態を追加
  • 単一の信頼できる情報源: 同じデータは1か所で管理する原則
  • 制御コンポーネント: 親から渡されたpropsで動作が決まるコンポーネント
  • 実践パターン: アコーディオン、同期入力、温度変換、フィルタリング、タブUI

次のステップ

状態のリフトアップを理解したら、次は副作用処理を学びましょう。次の記事では、API呼び出しやタイマーなどの副作用を扱う「useEffect」について解説します。

  • 次の記事: useEffect入門 - 副作用処理とライフサイクルの理解(近日公開)

また、アプリケーションが大規模になり、propsの受け渡しが深くなりすぎる場合は、Context APIやZustandなどの状態管理ライブラリの導入も検討してみてください。

参考リンク