本文へスキップ
宣言的 UI

Part 1 · React の考え方

10 / 64

宣言的 UI

画面を書き換えるのをやめて、状態を書き換える

React の説明でよく出てくる「宣言的 UI」という言葉。 日本語として硬いので身構えてしまいますが、 言っていることはひとつだけです。

画面をどう作り変えるかではなく、画面がどうあるべきかを書く。

この違いが分かると、React のコードが「なぜあの形をしているのか」に納得がいきます。

手順を書くやり方(命令的)

ログイン状態によって、画面の 3 か所を変えたいとします。 ヘッダーの名前、本文、ボタンの文字です。

手順として書くと、こうなります。 (header.textContent = "…"「ヘッダーの文字を、これに書き換えろ」という命令です。 前の章で見た素の JavaScript と同じ書き方です)

function login() {
  header.textContent = "さとうさん";
  body.textContent = "会員向けの内容が表示されています";
  button.textContent = "ログアウトする";
}

function logout() {
  header.textContent = "ゲスト";
  body.textContent = "ログインすると内容が表示されます";
  button.textContent = "ログインする";
}

間違ってはいません。ただ、同じことを 2 回、逆向きに書いているのが気になります。

そして表示する場所が 1 つ増えるたびに、loginlogout両方に書き足す必要があります。 片方だけ直してしまうと、 「ログアウトしたのにヘッダーだけ名前が残っている」という状態が生まれます。

login-view.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export function LoginView() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  // 画面が持っている状態は、この 1 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between rounded-md border px-3 py-2">
        <span className="text-muted-foreground">ヘッダー</span>
        <span className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
        </span>
      </div>

      <div className="rounded-md border px-3 py-2">
        {isLoggedIn
          ? "会員向けの内容が表示されています"
          : "ログインすると内容が表示されます"}
      </div>

      <Button size="sm" onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? "ログアウトする" : "ログインする"}
      </Button>
    </div>
  );
}

あるべき姿を書くやり方(宣言的)

React では、手順を書きません。書くのはこれだけです。

<span>{isLoggedIn ? "さとうさん" : "ゲスト"}</span>

「ログイン中なら名前、そうでなければゲスト」。 これは手順ではなく、事実の記述です。 いつ書き換えるか、という話が一切出てきません。

状態はひとつだけ

ボタンを押すと 3 か所が同時に変わる

ヘッダーゲスト
ログインすると内容が表示されます

このデモが持っている状態は isLoggedIn ひとつだけです。truefalse を切り替えているだけで、 3 か所を書き換える処理はどこにもありません。

状態isLoggedInヘッダーの名前本文の内容ボタンの文字
状態を 1 つ変えれば、そこから決まる表示はすべてそろって変わる

表示する場所が 4 つに増えても、書き足すのはその 1 か所だけです。 「両方に書き足す」必要がないので、片方だけ直し忘れる事故が起きません。

login-view.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export function LoginView() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  // 画面が持っている状態は、この 1 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between rounded-md border px-3 py-2">
        <span className="text-muted-foreground">ヘッダー</span>
        <span className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
        </span>
      </div>

      <div className="rounded-md border px-3 py-2">
        {isLoggedIn
          ? "会員向けの内容が表示されています"
          : "ログインすると内容が表示されます"}
      </div>

      <Button size="sm" onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? "ログアウトする" : "ログインする"}
      </Button>
    </div>
  );
}

画面は、状態から決まる

この考え方は、よくこう表現されます。

UI = f(state)

記号が並んでいますが、身構えなくて大丈夫です。f関数のこと。UI は画面、state は状態です。 つまり「状態を入れると、画面が出てくる箱」と書いてあるだけです。

画面は、状態を渡すと結果が返ってくる関数のようなものだ、という意味です。 同じ状態なら、いつ何度描いても同じ画面になります。

だから React では、画面を直接いじりません。状態を変えれば、画面は勝手についてくるからです。

login-view.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export function LoginView() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  // 画面が持っている状態は、この 1 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between rounded-md border px-3 py-2">
        <span className="text-muted-foreground">ヘッダー</span>
        <span className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
        </span>
      </div>

      <div className="rounded-md border px-3 py-2">
        {isLoggedIn
          ? "会員向けの内容が表示されています"
          : "ログインすると内容が表示されます"}
      </div>

      <Button size="sm" onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? "ログアウトする" : "ログインする"}
      </Button>
    </div>
  );
}

状態が 2 つになると、差が開く

状態が 1 つのうちは、手順で書いてもそれほど困りません。2 つになると、はっきり差が出ます。

「ログイン中かどうか」に加えて「未読があるかどうか」が増えたとします。 手順で書くなら、組み合わせの数だけ書くことになります

// 2 × 2 = 4 通り。それぞれで 3 か所を書き換える
function update() {
  if (isLoggedIn && hasUnread) { /* 3 か所 */ }
  else if (isLoggedIn)         { /* 3 か所 */ }
  else if (hasUnread)          { /* 3 か所 */ }
  else                         { /* 3 か所 */ }
}

状態が 3 つになれば 8 通り、4 つなら 16 通り。倍々に増えていきます。

宣言的に書けば、増えるのは状態のほうだけです。

状態を 2 つにしてみる直した例

2 つのチェックを自由に組み合わせてみる

ゲスト

ログインすると内容が表示されます

4 通りぶんの手順は、どこにも書いていません。書いてあるのは「この状態なら、こう出す」という対応関係だけです。組み合わせは React が計算します。

two-states.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { useState } from "react";

export function TwoStates() {
  useTrackDemoRender();

  // 状態は 2 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [hasUnread, setHasUnread] = useState(false);

  return (
    <div className="flex flex-col gap-3">
      <div className="flex gap-4 text-sm">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={isLoggedIn}
            onChange={(event) => setIsLoggedIn(event.target.checked)}
          />
          ログイン中
        </label>
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={hasUnread}
            onChange={(event) => setHasUnread(event.target.checked)}
          />
          未読あり
        </label>
      </div>

      {/* 画面の 3 か所が、上の 2 つから自動的に決まる */}
      <div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
        <p className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
          {isLoggedIn && hasUnread && (
            <span className="ml-2 rounded-full bg-red-500 px-2 py-0.5 text-xs text-white">
              未読
            </span>
          )}
        </p>

        <p className="text-muted-foreground">
          {isLoggedIn
            ? hasUnread
              ? "新しいお知らせがあります"
              : "新しいお知らせはありません"
            : "ログインすると内容が表示されます"}
        </p>

        {/*
          ここは表示だけの飾り。直前の「うまくいかない例」には
          見た目がそっくりで押すと動くボタンがあるので、
          押せないことが見て分かるようにしておく。
        */}
        <button
          type="button"
          disabled
          className="self-start rounded-md border px-3 py-1.5 opacity-50"
        >
          {isLoggedIn ? "ログアウトする" : "ログインする"}
        </button>
      </div>
    </div>
  );
}

理解できたか確かめる

確認クイズ

宣言的 UI とは、どういう書き方のこと?

確認クイズ

React で「ボタンの文字を変えたい」と思ったとき、まず何を考える?

login-view.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export function LoginView() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  // 画面が持っている状態は、この 1 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between rounded-md border px-3 py-2">
        <span className="text-muted-foreground">ヘッダー</span>
        <span className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
        </span>
      </div>

      <div className="rounded-md border px-3 py-2">
        {isLoggedIn
          ? "会員向けの内容が表示されています"
          : "ログインすると内容が表示されます"}
      </div>

      <Button size="sm" onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? "ログアウトする" : "ログインする"}
      </Button>
    </div>
  );
}

この章のまとめ

  • 命令的 = 画面をどう変えるかの手順を書く
  • 宣言的 = 状態と画面の対応関係を書く
  • 対応を 1 か所書けば済むので、直し忘れによる食い違いが起きない
  • React では「表示を変えたい」ではなく「状態を変えたい」と考える

login-view.tsx

"use client";

import { useTrackDemoRender } from "@/components/lesson/demo-card";
import { Button } from "@/components/ui/button";
import { useState } from "react";

export function LoginView() {
  // このカードが描き直された回数を数えるための 1 行(教材の仕掛け)
  useTrackDemoRender();

  // 画面が持っている状態は、この 1 つだけ
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between rounded-md border px-3 py-2">
        <span className="text-muted-foreground">ヘッダー</span>
        <span className="font-semibold">
          {isLoggedIn ? "さとうさん" : "ゲスト"}
        </span>
      </div>

      <div className="rounded-md border px-3 py-2">
        {isLoggedIn
          ? "会員向けの内容が表示されています"
          : "ログインすると内容が表示されます"}
      </div>

      <Button size="sm" onClick={() => setIsLoggedIn(!isLoggedIn)}>
        {isLoggedIn ? "ログアウトする" : "ログインする"}
      </Button>
    </div>
  );
}