⚛️ React Boundary
基礎 — 設計思想

💡 宣言的UIとは何か?

命令的UIと宣言的UIの本質的な違い。Reactがなぜ宣言的な設計を採用したのか、 そしてそれが開発体験にどんな影響を与えるのかを深掘りします。

🤔 問題提起:なぜUIの書き方が2種類あるのか?

JavaScriptでDOMを操作したことがある人なら、こういうコードを書いたことがあるはずです。

// 「命令的」なアプローチ
const btn = document.getElementById('like-btn');
const count = document.getElementById('count');
let likes = 0;

btn.addEventListener('click', () => {
  likes++;
  count.textContent = likes;         // DOMを直接更新
  if (likes > 0) {
    btn.classList.add('liked');       // クラスを直接操作
    btn.textContent = '♥ いいね済み';
  }
  if (likes >= 10) {
    btn.disabled = true;              // 属性を直接操作
    count.style.color = 'gold';       // スタイルを直接操作
  }
});

一方、Reactでは同じUIをこう書きます。

// 「宣言的」なアプローチ
function LikeButton() {
  const [likes, setLikes] = useState(0);

  return (
    <div>
      <button
        onClick={() => setLikes(l => l + 1)}
        disabled={likes >= 10}
        className={likes > 0 ? 'liked' : ''}
      >
        {likes > 0 ? '♥ いいね済み' : 'いいね'}
      </button>
      <span style={{ color: likes >= 10 ? 'gold' : 'inherit' }}>
        {likes}
      </span>
    </div>
  );
}

この2つの違いは単なる「書き方のスタイル」ではありません。 根本的な思想の違いがあります。それが「命令的 vs 宣言的」という概念です。

🎯 結論から言う

宣言的UIとは「状態からUIを導出する」設計です。

「どう変えるか(How)」ではなく「どうあるべきか(What)」を記述します。

命令的 (Imperative)
  • DOM操作の手順を記述する
  • 「ボタンのクラスを追加せよ」
  • 現在の状態を把握して差分を計算するのは開発者
  • 状態が増えるほど複雑化
宣言的 (Declarative)
  • あるべきUIの姿を記述する
  • 「likes が 0 より大きいとき liked クラスが付く」
  • 差分計算はReactが担う
  • 状態が増えても管理しやすい

🗺️ 構造図:命令的 vs 宣言的

UI更新のフロー比較
命令的アプローチ
イベント発生
現在のDOM状態を確認
どう変えるかを計算
DOM操作を実行

開発者がDOM操作の全手順を記述する責任を持つ

宣言的アプローチ(React)
イベント発生
stateを更新
Reactがdiff計算
最小限のDOM更新

開発者はstateを更新するだけ。DOM操作はReactが担う

📚 概念説明:命令的UIの何が問題だったのか

jQuery時代のWebアプリを想像してください。ページには複数のUIパーツがあり、 それぞれが互いに影響し合います。

// jQuery時代の典型的なコード
// ユーザーがログインしたとき、
// 複数の場所を「手動で」更新しなければならない

function onUserLogin(user) {
  // ヘッダーのログインボタンを非表示
  $('#login-btn').hide();
  // ログアウトボタンを表示
  $('#logout-btn').show();
  // ユーザー名を表示
  $('#username').text(user.name);
  // アバター画像を更新
  $('#avatar').attr('src', user.avatarUrl);
  // ウェルカムメッセージを表示
  $('#welcome-msg').show().find('.name').text(user.name);
  // ナビゲーションのリンクを変更
  $('#nav-profile').attr('href', '/users/' + user.id);
  // カートアイコンを表示
  if (user.cartItems > 0) {
    $('#cart-icon').show();
    $('#cart-count').text(user.cartItems);
  }
  // プレミアム会員なら特別表示
  if (user.isPremium) {
    $('body').addClass('premium-user');
  }
}

問題の本質

このコードは「ユーザーがログインしたとき、どのDOMをどう変えるか」という手順書です。 状態の数が増えるほど、「どの状態からどのDOMを更新するか」というマッピングが爆発的に複雑になります。 ログイン・ログアウト・プロフィール更新・権限変更…それぞれで別の手順書が必要です。

命令的UIが抱える3つの問題

① 状態とDOMの同期が難しい

「現在のDOMの状態」と「アプリケーションの状態」が別々に管理されます。 DOMを更新し忘れたり、古い状態のままDOMが残ったりするバグが起きやすくなります。 どこかで更新を1つ忘れれば、UIは不整合な状態になります。

② スケールしない

状態が n 個あるとき、その組み合わせは理論上 2ⁿ 通りになります。 全ての状態遷移に対してDOMを正しく更新するコードを書くことは、 アプリが大きくなるほど現実的ではありません。

③ テストとデバッグが困難

「ある状態のときUIがどう見えるか」を確認するには、 実際にその状態に至るまでのすべての操作を再現する必要があります。 宣言的なコードなら、stateを直接セットしてレンダリング結果を確認できます。

💻 コード例:state → UI マッピング

Reactの宣言的UIの核心は「stateが決まれば、UIが一意に決まる」という関係です。 数学的な関数に似ています:UI = f(state)

例:フォームの状態管理

// 命令的アプローチ(問題がある)
function setupForm() {
  const form = document.getElementById('login-form');
  const submitBtn = form.querySelector('button[type=submit]');
  const emailInput = form.querySelector('#email');
  const errorMsg = form.querySelector('.error');

  // 送信中の状態にする関数
  function setLoading() {
    submitBtn.disabled = true;
    submitBtn.textContent = '送信中...';
    errorMsg.style.display = 'none';
  }

  // エラー状態にする関数
  function setError(message) {
    submitBtn.disabled = false;
    submitBtn.textContent = 'ログイン';
    errorMsg.textContent = message;
    errorMsg.style.display = 'block';
    emailInput.classList.add('error');
  }

  // 成功状態にする関数
  function setSuccess() {
    submitBtn.disabled = false;
    form.style.display = 'none';
    // ...redirect logic
  }
  // すべての状態遷移を手動管理している...
}
// 宣言的アプローチ(React)
type FormStatus = 'idle' | 'loading' | 'error' | 'success';

function LoginForm() {
  const [status, setStatus] = useState<FormStatus>('idle');
  const [errorMessage, setErrorMessage] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setStatus('loading');

    try {
      await login(/* ... */);
      setStatus('success');
    } catch (err) {
      setErrorMessage(err.message);
      setStatus('error');
    }
  }

  // ↓ stateからUIを「宣言」するだけ
  if (status === 'success') return <div>ログイン成功!</div>;

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" className={status === 'error' ? 'border-red-500' : ''} />
      {status === 'error' && (
        <p className="text-red-500">{errorMessage}</p>
      )}
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? '送信中...' : 'ログイン'}
      </button>
    </form>
  );
}

ポイント

宣言的アプローチでは、「フォームがloading状態のとき どう見えるか」が1か所に集約されています。 statusを変えるだけでUIが自動的に正しい状態になります。

🔧 仕組み分解:Reactのコンポーネントモデル

Reactが宣言的UIを実現できる理由は、レンダリングを「純粋関数」として扱う設計にあります。

純粋関数としてのコンポーネント

// コンポーネントは純粋関数に近い
// 同じprops/stateを渡せば、常に同じJSXを返す

function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);

  // stateが決まれば、UIが一意に決まる
  // 同じstateなら何度呼び出しても同じ結果
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

// 純粋関数との比較
// 純粋関数: f(x) = x * 2  → 常に同じ入力 = 同じ出力
// Reactコンポーネント: UI = f(props, state) → 常に同じ入力 = 同じUI

なぜ「再レンダリング」という概念が生まれるのか

宣言的UIの設計上、Reactは「stateが変わったら、UIを最初から計算し直す」アプローチを取ります。 これを再レンダリングと呼びます。

// 概念的なイメージ(実際の実装は最適化されている)
function render(component, state) {
  // stateから新しいUIツリーを計算
  const newUI = component(state);
  
  // 現在のUIと比較(diff)
  const changes = diff(currentUI, newUI);
  
  // 差分だけDOMに反映
  applyChanges(changes);
  
  // 新しいUIが「現在のUI」になる
  currentUI = newUI;
}

// stateが変わるたびにrender()が呼ばれる
// → 開発者はDOMを意識せず、stateだけ管理すればいい
宣言的UIの3つの柱
① コンポーネント = 純粋な描画ロジック

コンポーネントはstateとpropsを受け取ってJSXを返す。DOM操作のコードは書かない。

② stateの変更 → 再レンダリング

setStateを呼ぶと、 Reactがそのコンポーネント(と子コンポーネント)を再レンダリングする。 開発者はDOMを直接触らない。

③ Virtual DOMによるdiff

毎回UIを「全部計算し直す」が、実際のDOMへの反映は差分だけ。 パフォーマンスを保ちながら「宣言的」な書き方を実現している。

宣言的設計の制約

宣言的UIには利点だけでなく、理解すべき制約もあります。

制約1:副作用はコンポーネント外で行う

レンダリング中にDOMを直接操作したり、外部APIを呼んではいけません。 そうした「副作用」は useEffect のような専用フックで扱います。

制約2:stateは直接変更してはいけない

state.count++ のような直接変更は、 Reactが変更を検知できないため再レンダリングが発生しません。 必ず setState を通じて更新します。

制約3:レンダリングは冪等でなければならない

同じprops/stateを渡したとき、何度呼んでも同じ結果になる必要があります。 レンダリング中にランダム値を生成したり、グローバル変数を変更してはいけません。

🧩 宣言的UIの本当の価値

宣言的UIの最大の価値は「パフォーマンス」ではありません(むしろ純粋なDOM操作より遅い面もある)。 本当の価値は複雑さの管理にあります。

「状態空間」という考え方

アプリに n 個の独立したboolean状態があるとき、UIの取りうる状態は最大 2ⁿ 通りです。

// 5つの状態があるフォーム
const [isLoading, setIsLoading] = useState(false);   // 2通り
const [hasError, setHasError] = useState(false);     // 2通り
const [isSuccess, setIsSuccess] = useState(false);   // 2通り
const [isDisabled, setIsDisabled] = useState(false); // 2通り
const [isDirty, setIsDirty] = useState(false);       // 2通り

// 命令的: 全ての状態遷移(最大2^5=32通り)のDOM操作を記述?
// 宣言的: stateを宣言すればUIは自動計算。
//         組み合わせを意識しなくていい。

宣言的UIでは、全ての状態の組み合わせに対して「その時UIがどう見えるべきか」を 単一のJSXで表現できます。Reactが「現在のstateではこういうUI」を計算します。

📌 まとめ

  • ✓ 命令的UIは「どうDOMを変えるか」の手順を記述する。状態が増えると複雑化する
  • ✓ 宣言的UIは「このstateのとき、UIはこう見えるべき」を記述する
  • ✓ ReactのモデルはUI = f(state)。stateが決まればUIが一意に決まる
  • ✓ Reactコンポーネントは純粋関数に近い設計。DOM操作ではなくJSXを返す
  • ✓ 宣言的設計の価値はパフォーマンスではなく「複雑さの管理」にある
  • ✓ 制約(副作用の分離、stateの不変性、冪等性)を守ることで宣言的モデルが成立する

関連記事