🙋‍♂️

宣言型UI・命令型UIとは?コードで見る違い

2024/11/10に公開

宣言型UIは命令型UIの問題点を解決するために登場しました。両者の違いを見ることで宣言型UIを理解しましょう!

※ReactのuseStateの使用方法が理解できている方であれば問題なく読めると思います。

宣言型UIとは

表示したい見た目(UI)をあらかじめ全て宣言しておいて、状態を使ってUIを出しわけられるようにしたUIのこと。

命令型UIとは

状態に変化があれば逐一UIに対して命令を出してUIを変更するようなUIのこと

これだけでは理解するのは難しいと思います。以降で宣言型UIと命令型UIの具体例をコードで見ていき理解を深めましょう。

Reactで見る宣言型UI

Reactでは宣言型UIを実現するためにuseStateを使用します。次の例では<p>count</p>の部分でuseStateが使用されています。

import { useState } from "react";
import "./App.css";

export default function App() {
  const [count] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
    </div>
  );
}

実際にUIの変化を見てみましょう。このままではカウントアップできないのでset関数を使用してカウントアップを実装します。

import { useState } from "react";
import "./App.css";

export default function App() {
  const [count,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        count up
      </button>
    </div>
  );
}

カウントアップを実装したことでボタンが増えました。ブラウザには次の画面が表示されます。

それではUIの変化を見ていきましょう。ボタンを2回押します。

countの部分のみUIが変更されていることがわかります。

あらためて宣言型UIの定義を確認します。

表示したい見た目(UI)をあらかじめ全て宣言しておいて、状態を使ってUIを出しわけられるようにしたUIのこと。

さて、定義と照らし合わせれば上記のUIはcountというstateを使ってUIを出し分けているはずです。上記のUIに複数のUIは存在するでしょうか。定義されたUIが1つのように見えませんか。UIの出し分けは行われているのでしょうか。
上記のUIは複数のUIが1つに重なって定義されていると考えられます。複数のUIをそれぞれ定義してみます。

// 初期表示のUI
export default function App() {
  const [0,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        0
      </button>
    </div>
  );
}
// ボタンが押されてカウントアップされたUI(1)
export default function App() {
  const [1,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        1
      </button>
    </div>
  );
}
// ボタンが押されてカウントアップされたUI(2)
export default function App() {
  const [2,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        2
      </button>
    </div>
  );
}

あらかじめ宣言しておいたUIを出し分けているイメージが段々とできてきましたか💪

条件付きレンダーを使った例も見てみましょう。さきほどの実装を踏襲してカウントが2以上になった場合、「カウントが2以上になりました」とメッセージを表示してくれるアプリで考えてみます。

export default function App() {
  const [count,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        count up
      </button>
      {count > 1 && <p>カウントが2以上になりました</p>}
    </div>
  );
}

初期表示は次のようになります。

ボタンを2回押してみます。すると想定通り「カウントが2以上になりました」のメッセージが表示されました!

今回はどういったUIがあらかじめ定義されているのか想像してみてください。

以下のUIがあらかじめ宣言されているイメージでした。

// 初期表示のUI
export default function App() {
  const [0,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        0
      </button>
    </div>
  );
}
// ボタンが押されてカウントアップされたUI(1)
export default function App() {
  const [1,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        1
      </button>
    </div>
  );
}
// ボタンが押されてカウントアップされたUI(2)
export default function App() {
  const [2,setCount] = useState(0);
  return (
    <div className="App">
      <h1>カウントアプリ</h1>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        2
      </button>
      <p>カウントアップが2以上になりました</p>
    </div>
  );
}

Reactは例のようにあらかじめ表示したいUIを全て定義しておき、stateを組み合わせることで宣言型UIを実現していました。これを図で見てみましょう。

図を見ると明らかですが宣言型UIでは開発者である我々がUIを更新することはありません。UIの更新方法はReact任せであり、私たちはUIとロジック、stateの実装をすればよいことになります。(図の黒い箱が開発者の領域です)。ボタンが押下されたときに動くロジックがstateを更新すれば、あとはReactがUIのだし分けをしてくれるわけです。

補足
useRefを使って開発者がDOM操作することがありますが、あまり多くないと思います。forcusしたりscrollするときに使うイメージです。詳細はReactの公式Docsを参照してみてください。

あまり関係ないので飛ばして良い補足
イベントハンドラ(stateを更新する処理)を書いてstateの更新するだけで、自動でUIを更新してくれる仕組みを単方向データバインディングといいます。
似たもので双方向データバインディングがあります。双方向データバインディングではstateを見てUIを更新する仕組みに加えて、UIを更新したらstateが更新されるような仕組みがあるようです。例えばinputに入力したらstateが更新される仕組みをReactで実装するならonChangeにイベントハンドラを実装する必要があります。

<input onChange={(e)=>setInput(e.target.value)}/>

双方向データバインディングではこの実装が不要になります。inputに対して更新対象の状態を指定するだけで、入力時に自動で状態を更新してくれます。

Javaで見る宣言型UI

Javaでも宣言型なUIが見られます。JavaではThymeleafというツールを使ってHTMLの中身をJavaで書き換えます。HTMLを次のように書きます。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <body>
        <h1>カウントアップ</h1>
	<p th:text="${count}"></p>
    </body>
</body>
</html>

Reactに似ていますね。Reactとの違いはマークアップとロジックが別々に記述されているということです。Reactではマークアップであるjsxとロジックが同じファイルに記述されているのでとても見やすいです。

バニラJavaScriptで見る命令型UI

宣言型UIは命令型UIの問題を解決するために誕生しました。命令型UIをコードで見ることで命令型UIの問題点を確認していきましょう。

上記の画面を命令型UIで実装します。あえて要件は伏せるのでコードからどのような機能があるのか把握してください。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <form id="form">
    <label>First name:
      <b id="nameText">Jane</b>
      <input id="nameInput" value="Jane">
    </label>
    <button type="submit" id="editButton">Edit Profile</button>
    <p>
      <i id="helloText">Hello, Jane</i>
    </p>
  </form>
</body>
  
</html>

ロジックは以下の通りです。

function handleFormSubmit(e) {
  e.preventDefault();
  if (editButton.textContent === 'Edit Profile') {
    editButton.textContent = 'Save Profile';
    hide(nameText);
    show(nameInput);
  } else {
    editButton.textContent = 'Edit Profile';
    hide(nameInput);
    show(nameText);
  }
}

function handleNameChange() {
  nameText.textContent = nameInput.value;
  helloText.textContent = (
    'Hello ' +
    nameInput.value + '!'
  );
}

function hide(el) {
  el.style.display = 'none';
}

function show(el) {
  el.style.display = '';
}

// DOM要素の取得
let form = document.getElementById('form');
let editButton = document.getElementById('editButton');
let nameInput = document.getElementById('nameInput');
let nameText = document.getElementById('nameText');
let helloText = document.getElementById('helloText');
form.onsubmit = handleFormSubmit;
nameInput.oninput = handleNameChange;

何をしているのか予想できたかたは下へ・・・








以下の2つの機能がありました

  1. 入力した文字列を入力フォームの下部に「Hello <入力した名前>!」が表示される
  2. Edit Profileボタンを押下したとき、入力フォームを消し、代わりに入力した名前を太字で表示する

いかがでしょうか。要件としてはそこまで複雑じゃないと思いませんか。しかし普段Reactを書いている私からすると少しばかり複雑に感じます。図を使って命令型UIを分析しましょう。

(※useStateの状態は「state」、useStateではない状態は「状態」とかき分けていますが、用途は同じです。)
宣言型UIとは異なり、ロジックからUIに対して命令が出ていますね。なぜこのような命令があるのかというと、ロジックが状態を更新をしたときに最新の状態でUIを更新することで「状態」と「UI」を同期させたいからです。このように状態に変化があれば逐一UIに対して命令を出してUIを変更するようなUIのことを命令型UIといいます。

命令型UIの問題が見えてきました。

命令型UIの問題その1「可読性の低下、メンテナンスの難易度UP」
宣言型UIの例と比べるとUIへの命令を書く必要があるので記述するロジックが増えてしまいます。プロダクションのアプリであれば例の要件の数倍は複雑ですので、コード量は1000行以上になることもあるでしょう。そうなれば読みづらく、変更した際の影響範囲を調べるのは容易ではなくなってくると思われます。
宣言型UIではUIの更新はReact任せなのでUI更新のロジックを書く必要はありません。より簡潔なコードを記述できます。

命令型UIの問題その2「状態とUIの同期が大変」
大規模なアプリケーションになれば「状態」と「UI」が増えることになるので、開発者側での手動での同期が大変になっていきます。開発者側で同期の仕組みを作る必要があります。
宣言型UIでは、stateによって同期が行われるので同期を手動で行う必要はありません。

命令型UIの問題その3「UIがどのように変化するのかわかりづらい」
ロジックをを読まないとUIがどのように変化するのか把握することができません。
宣言型UIはマークアップを見るだけで今後表示されるであろうUIが想像できます。なぜならあらかじめUIを宣言しているからです。

最後にReactで書き直してみましょう。

import { useState } from "react";
export default function App() {
  const [name, setName] = useState("");
  const [edit, setEdit] = useState(true);
  const handleNameChange = (e) => {
    setName(e.target.value);
  };
  const handleSubmit = (e) => {
    e.preventDefault();
    setEdit((e) => !e);
  };
  return (
    <form onSubmit={handleSubmit}>
      <label>
        First name:
        {edit ? (
          <input value={name} onChange={handleNameChange} />
        ) : (
          <b>{name}</b>
        )}
      </label>
      <button type="submit">Edit Profile</button>
      <p>
        <i id="helloText">{`Hello, ${name}`}</i>
      </p>
    </form>
  );
}

Reactのほうがコード量が少なく、読みやすいと思います。

命令型UIではuseStateのような仕組みが存在しないため次のようにコードを書いていく必要があります。

  1. 変更したいDOMを取得する
  2. DOMに対してイベントハンドラを指定する
  3. イベントハンドラを定義する
  4. イベントハンドラ内で処理をして、1で取得したDOMを更新する

命令型UIでは状態の参照・計算・更新をしてさらにUIを更新する同期ロジックを書く必要があります。これは宣言型UIより柔軟性があり細かい実装ができることを意味しますが簡単な要件でも複雑になってしまい、大規模になれば保守やDXの低下を引き起こします。

まとめ

長らく宣言型UIと命令型UIの違いが説明できずモヤモヤしていたのですが、遂に説明できそうだったので記事にしました。これで誰かのモヤモヤが解消できたら幸いです。

参考記事

https://ja.react.dev/learn/reacting-to-input-with-state

コラム

Reactは本当に宣言型?
はじめて宣言型UIの説明を読んだときに私は混乱していました。

「Reactのイベントハンドラは逐一命令を出しているように見える。Reactは命令型ではないか」

結論、宣言型UIはUIの話なのでイベントハンドラは関係ありません。イベントハンドラは命令型という認識で正しく、Reactでは1つのファイル内に「UI」「ロジック」「state」が混在するので混乱していたのでした。

Discussion