宣言的 UI と命令的 UI — 状態から UI を導く
React も Vue も Svelte も「宣言的 UI」と説明されます。一方で jQuery で書かれたコードは「命令的」と呼ばれます。この違いは書き方の流行ではなく、状態と表示のずれをどう防ぐかという設計の分かれ目です。
この章で学ぶこと
- 命令的 UI と宣言的 UI が、それぞれ何を記述しているか
- 宣言的 UI で状態と表示の不整合が減る理由
- 宣言的 UI でも DOM を直接触る必要が残る場面
- 宣言的 UI が払っている代償
JavaScript で DOM を操作した経験があると比較が実感できます。React などのフレームワークの経験は不要です。
同じ画面を 2 通りで書く
「読み込み中はスピナー、失敗ならエラー文、成功なら一覧」を表示する画面を考えます。
命令的に書く
// 命令的 — DOM を変える手順を並べる
function showLoading() {
document.querySelector('#spinner').style.display = 'block';
document.querySelector('#error').style.display = 'none';
document.querySelector('#list').style.display = 'none';
}
function showError(message) {
document.querySelector('#spinner').style.display = 'none';
document.querySelector('#error').style.display = 'block';
document.querySelector('#error').textContent = message;
document.querySelector('#list').style.display = 'none';
}
function showList(items) {
document.querySelector('#spinner').style.display = 'none';
document.querySelector('#error').style.display = 'none';
const list = document.querySelector('#list');
list.style.display = 'block';
list.textContent = '';
items.forEach((i) => {
const li = document.createElement('li');
li.textContent = i.name;
list.appendChild(li);
});
}
書いているのは DOM を変える手順です。3 つの関数それぞれが、3 つの要素すべての表示状態を面倒みています。
宣言的に書く
// 宣言的 — 状態から画面への対応を書く
type Status =
| {kind: 'loading'}
| {kind: 'error'; message: string}
| {kind: 'success'; items: Item[]};
function ItemList({status}: {status: Status}) {
if (status.kind === 'loading') return <Spinner />;
if (status.kind === 'error') return <ErrorText>{status.message}</ErrorText>;
return (
<ul>
{status.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
書いているのは 状態から画面への対応です。DOM をどう書き換えるかは一行も出てきません。状態が変われば、フレームワークが必要な箇所だけを DOM に反映します。
記述しているものが違う
| 命令的 UI | 宣言的 UI | |
|---|---|---|
| 書くこと | DOM を変える手順 | 状態から画面への対応 |
| DOM 操作 | 開発者が手順を書く | フレームワークが差分から導く |
| 代表例 | jQuery、素の DOM API | React、Vue、Svelte |
| 状態の置き場 | DOM そのもの、または変数と DOM の両方 | 状態変数 (単一の真実の源) |
命令的 UI では「今どういう表示になっているか」が DOM の中に散らばります。宣言的 UI では状態変数だけが真実で、画面はそこから毎回導かれます。
なぜ不整合が減るのか
命令的 UI が壊れやすいのは、同じ要素の表示状態を、状態の数だけある関数すべてに書き写すからです。
状態が 3 つ・面倒をみる要素が 3 つなら、書く指定は 3 × 3 = 9 通りです。状態が 4 つ・要素が 4 つに増えれば 16 通りになります。どの状態のときも、すべての要素について消すか出すかを明示する必要があります。
先ほどの showError を見てください。スピナーを消し、エラーを出し、一覧を消す。3 つすべてに触れています。ここで 4 つ目の要素 (たとえば「再試行ボタン」) を足すと、既存の 3 つの関数すべてに追記が要ります。1 つでも忘れると、エラー表示なのに再試行ボタンが消えたまま、といったずれが残ります。
宣言的 UI では、書くのは状態の数だけです。しかも各分岐に書くのは、その状態で出したい要素だけです。再試行ボタンを足すときも、それを出したい分岐に 1 行足すだけで済みます。他の分岐は関数が最初から「その状態のときの完成形」を返しているので、意識せずとも正しくなります。
これが「状態と UI の不整合が起きにくい」と言われる理由です。不整合が減るのは宣言的 UI のメリットであって、デメリットではありません。
宣言的 UI でも DOM を直接触る場面はある
「宣言的 UI なら DOM 操作は一切不要」ではありません。状態から導けない操作が残ります。
- 入力欄にフォーカスを当てる
- 特定の位置までスクロールする
- 動画や音声の再生と停止
canvasへの描画- 要素の実寸を測る
これらは「画面がどう見えるか」ではなく「今この瞬間に何をするか」なので、状態の写像として表せません。React ではこうした操作のために ref という逃げ道が用意されています。
つまり宣言的 UI は、DOM 操作を禁止する仕組みではなく、大部分を状態からの導出に置き換えたうえで、残りを明示的な例外として隔離する仕組みです。
代償
宣言的 UI はただで手に入るものではありません。以下は仮想 DOM で差分を取る React / Vue を念頭にした整理で、Svelte のようにコンパイル時に更新コードを生成する方式では上 2 行の重みが変わります。
| 代償 | 内容 |
|---|---|
| ランタイムの重さ | 差分計算の仕組みをブラウザに送り込む必要がある |
| 差分計算のコスト | 状態が変わるたびに前回との比較が走る |
| 制御の間接性 | 「なぜこの DOM がこうなったか」がフレームワークの中に隠れる |
| 学習コスト | 状態の設計、再レンダリングの条件、副作用の扱いを覚える必要がある |
小さなランディングページに 1 か所だけ動きを付けるなら、素の DOM API のほうが軽くて速い場合があります。宣言的 UI が効くのは、状態の種類が多く、それが画面の複数箇所に影響するアプリケーションです。実際にどう状態を設計するかは React ガイド — State が扱っています。
よくある誤解
「宣言的 UI は状態と UI の不整合が起きやすい」 — 逆です。不整合を減らすことが宣言的 UI の主な動機です。画面を状態から毎回導出するので、関数のどれか 1 つへの書き足しを忘れるという失敗の形そのものが無くなります。
「宣言的 UI では開発者が DOM の操作手順を細かく制御する」 — それは命令的 UI の説明です。宣言的 UI では手順を書かず、状態と表示の対応だけを書きます。
「宣言的 UI なら DOM に触れることは一切ない」 — フォーカス、スクロール、メディア再生などは状態から導けないので、ref のような明示的な経路で直接操作します。
「宣言的に書けば速くなる」 — 速くなるとは限りません。差分計算のぶんだけ実行時のコストは増えます。得られるのは実行速度ではなく、状態が増えたときの正しさと保守性です。
確認問題
問 1. 次のコードは宣言的と命令的のどちらですか。理由も答えてください。
const badge = document.querySelector('#cart-badge');
badge.textContent = String(count);
badge.classList.toggle('hidden', count === 0);
解答と解説
答え: 命令的
要素を取得し、テキストを書き換え、クラスを付け外しする、という操作の手順を書いているためです。「カート件数が 0 なら非表示、そうでなければ件数を表示する」という対応関係は、コードの形からは直接読み取れず、3 行を追って組み立てる必要があります。
同じことを宣言的に書くと次のようになります。対応関係が式の形でそのまま現れます。
function CartBadge({count}: {count: number}) {
if (count === 0) return null;
return <span className="badge">{count}</span>;
}
問 2. 命令的 UI で状態が 4 つ・面倒をみる要素が 4 つに増えたとき、書く表示指定はいくつになりますか。
解答と解説
答え: 16 通り (4 × 4)
状態ごとの関数が 4 つの要素それぞれについて消すか出すかを指定するので、4 × 4 = 16 通りです。要素をもう 1 つ足すと 4 × 5 = 20 通りになり、4 つの関数すべてに追記が要ります。
宣言的 UI で書くのは状態の数だけ (4 つなら 4 分岐) です。それぞれの分岐にはその状態で出したい要素だけを書くので、要素を足しても書き足すのは出したい分岐だけで済みます。この差が保守性の差になります。
問 3. 宣言的 UI を使っているプロジェクトで「送信ボタンを押したら入力欄にフォーカスを戻す」を実装したいです。状態として持つべきでしょうか。
解答と解説
答え: 状態として持たず、ref で直接操作します。
フォーカスは「その瞬間に一度だけ行う操作」で、「今の状態ならこう見える」という対応では表せません。仮に shouldFocus のような状態を持たせても、フォーカスを当てたあとにその状態を戻す処理が必要になり、かえって命令的な手続きが増えます。
宣言的 UI は DOM 操作を禁止しません。状態から導ける部分を導出に任せ、導けない部分を ref という明示的な例外として切り出す、という分担です。
まとめ
- 命令的 UI は現在の画面をどう変えるかという手順を書き、宣言的 UI はこの状態ならこう見えるという結果を書きます
- React や Vue は宣言的 UI、jQuery は命令的 UI の代表例です
- 命令的 UI では状態ごとの関数すべてに全要素の表示指定を書き写します。宣言的 UI で書くのは状態の数だけの分岐で、その状態に出したい要素だけです
- 宣言的 UI の主な動機は状態と表示の不整合を減らすことです。不整合が増えるわけではありません
- フォーカス・スクロール・メディア再生など状態から導けない操作は、宣言的 UI でも直接 DOM を触ります
- 代償はランタイムの重さ、差分計算のコスト、制御の間接性です
- React ガイド — この本について — React が宣言的 UI をどう実現しているか
- React ガイド — State (useState) — 状態を単一の真実の源として設計する方法
- MDN — DOM の紹介
次に読む
- レンダリングパターン — その UI を「どこで」組み立てるかの選択