Tech Blog

同じ業務 Web UI を Vanilla HTML / Vue / React / Thymeleaf で実装して比較した — 4スタックの違いと選定指針

Vanilla HTML Vue3 React Thymeleaf Spring Boot TypeScript Java フロントエンド 比較 ライブラリ選定

この記事でわかること

  • 同一仕様の業務 Web UI を Vanilla HTML / Vue 3 / React / Thymeleaf の4スタックで実装した時、コード上で何がどう違うのか(コード例付き)
  • プロジェクト構成・ビルドの違い(package.json / Vite / pom.xml / 素HTML)
  • API クライアント・状態管理・フォーム処理・エラー表示・デプロイの 観点別比較
  • 「どんな場面でどのスタックを選ぶか」の判断軸

対象読者

  • フレームワーク選定で Vanilla / Vue / React / Thymeleaf のどれを使うか迷っている方
  • それぞれを単体では触ったことがあっても、同じ仕様で並べたとき何が違うのか を知りたい方
  • 業務系 Web UI(フォーム + API呼び出し + 結果表示)を作る前提でスタックを比較したい方

動作環境

項目バージョン
Vanilla HTMLビルド不要(モジュールスクリプト)
Vue3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x
React18.x + Vite 5.x + TypeScript 5.x + React Router 6.x
ThymeleafSpring Boot 3.x + Java 21 + Thymeleaf 3.x

1. はじめに

正直に言うと、最初は比較記事を書く予定ではなかった

banklink-service のバックエンド開発中に「API が動いているか確認できる画面がほしい」と思い、その場で一番早く作れる Vanilla HTML で適当に組みました。「とりあえず動けばいい」 を絵に描いたような書き捨て UI です。

開発を進めるにあたっては「いずれちゃんと Vue か React に置き換えて、本格的な開発テンプレートにしよう」と考えていました。フロントエンドの本実装はそこから始めるつもりだったのです。

ところが、手を動かそうとした瞬間にふと気づきました。

この程度の処理量(フォーム + API 呼び出し + 結果表示)なら、Thymeleaf も含めて4スタック全部で実装してみてもそんなに時間はかからない。しかも全部同じ機能で揃えれば、各技術の違いを並べて比較するのに丁度いい題材になるんじゃないか? と。

開発作業の進捗としては、これは完全に 脱線 です。「テンプレートを1つ作る」予定が、いつの間にか「4スタックで並列実装して比較する」に化けていました。

ですが、書き上げてみると自分自身が技術選定の判断軸を整理できたうえ、同じ場面で迷っている人の判断材料にもなりそうだったので、記事として残すことにしました。本記事はその「楽しい脱線の副産物」です。


で、本題

業務系の Web UI を作るとき、「Vanilla HTML / Vue / React / Thymeleaf のどれで作るのが妥当か」 は最初に通る判断です。それぞれ単体ではよく語られますが、「同じ仕様で4つ並べたら何が違うのか」を実コードで横並びにした記事は意外と少ない。

そこで、銀行系 API ラッパーサービス banklink-service(個人練習用プロジェクト)で、まったく同じ仕様の業務 UI を 4スタックで並行実装 しました。本記事はその比較の記録です。

🔗 関連記事: banklink-serviceバックエンド設計 (要件定義 → 基本設計 → 詳細設計 → PoC、5ドメインの設計判断、DDD / ArchUnit / 金融品質の実装) は別記事にまとめています。本記事はそのバックエンドに接続する フロントエンド側の4スタック比較 です。 → 設計から入る銀行系API構築:5ドメインの要件定義からPoC検証、そして業務耐用へのロードマップ

この記事のスタンス 「正解の1つ」を提示する記事ではありません。同じ要件を4スタックで書いたコードを見比べて、自分のプロジェクトでどれが妥当かを判断する材料を提供する ことが目的です。


2. 共通仕様(4スタックで完全に揃えた前提)

4実装はすべて以下の仕様を満たします。

機能

  • 6ページ: ホーム / 口座 / ローン / 外貨 / 投資 / KYC
  • 各ページに複数の API 操作セクション(例: 口座ページは「一覧取得・残高取得・入金・出金・取引履歴」の5セクション)
  • 画面上部の Bearer Token 入力欄 から API 認証トークンを設定
  • ナビゲーションで各ページを行き来できる
  • ボタンを押すと API を叩いて結果を画面に整形表示

接続先バックエンド

  • 同じ Spring Boot API (/api/v1/accounts, /api/v1/loans, …)
  • レスポンスは JSON、エラーは HTTP ステータス + body の構造で統一

揃えていない点(差別化箇所)

  • CSS の見た目(外部UI / 内部UI で意図的に変える)
  • 内部実装(フレームワーク特性に従う)

つまり「画面と機能は同じ、中身だけ4通り」という比較ベースを作りました。

本記事で深掘りする「口座」画面 — Vanilla HTML 外部UI 版。4スタックすべてが同じ機能を持つ

口座画面(上記)には「口座一覧取得・残高取得・入金・出金・取引履歴」の5セクションがあります。この同じ画面・同じ機能を、4スタックで別々に実装した のが本記事の比較対象です。


3. 4スタックの構成概要

それぞれの「最小構成」をまず一望します。

Vanilla HTML

banklink-web-vanilla-html/
├─ index.html         ← トップページ
├─ accounts.html      ← 口座ページ
├─ loans.html         ← ローンページ
├─ ... (他4ページ)
├─ common-external.js ← トークン管理 + バインド共通
└─ shared/
    ├─ api/client.js  ← fetch ラッパー
    └─ common.js      ← bindAction / renderResponse

ビルドツールなし.html を直接ブラウザで開ける(または nginx で配信)。<script type="module"> で JS をインポート。

Vue

banklink-web-vue/
├─ vite.config.ts
├─ index.html         ← SPA エントリ
└─ src/
    ├─ main.ts         ← createApp + mount
    ├─ App.vue         ← レイアウト + RouterView
    ├─ router/index.ts ← Vue Router 設定
    ├─ api/client.ts   ← fetch ラッパー (TypeScript)
    └─ views/
        ├─ HomeView.vue
        ├─ AccountsView.vue   ← 口座ページ
        ├─ ... (他4ページ)

npm run builddist/ に静的ファイル生成 → nginx で配信。

React

banklink-web-react/
├─ vite.config.ts
├─ index.html
└─ src/
    ├─ main.tsx           ← createRoot + render
    ├─ App.tsx            ← 全6ページを1ファイルに集約(小規模なため)
    └─ ... (shared/api/client.ts)

Vue と類似だが、App.tsx に全ページを書く構成にした(コンポーネント数を最小化)。

Thymeleaf

banklink-web-thymeleaf/
├─ pom.xml
└─ banklink-external-web-thymeleaf/
    └─ src/main/
        ├─ java/com/y104autumn/banklink/external/
        │   ├─ BanklinkExternalApplication.java
        │   ├─ controller/
        │   │   ├─ ExternalTopPageController.java
        │   │   ├─ AccountsController.java
        │   │   └─ ... (他4ページ)
        │   ├─ service/
        │   │   ├─ AccountsService.java   ← RestClient で API 呼び出し
        │   │   └─ ...
        │   └─ form/
        │       ├─ AccountsForm.java      ← @ModelAttribute 用
        │       └─ ...
        └─ resources/templates/
            ├─ index.html
            ├─ accounts.html              ← th:field 付き
            └─ ...

mvn package で実行可能 jar 生成 → java -jar または Docker で起動。

構成ファイル数の比較

プロジェクト全体1ページ実装に必要なファイル
Vanilla HTML約 10 ファイル1 (accounts.html のみ)
Vue約 15 ファイル1 (AccountsView.vue)
React約 5 ファイル1 (App.tsx 内の関数)
Thymeleaf約 25 ファイル3 (Controller + Service + Form + template)

Thymeleaf は MVC を3層に分けるための定型コード が多いです。ファイル数は最多ですが、各ファイルの役割は明確です。

実装した6ページ(参考スクリーンショット)

口座画面以外の5ページも全4スタックで同じ機能を持っています。参考までに Vanilla HTML 版の画面を列挙します。

トップ画面(外部UI)— トークン入力 + ナビゲーション + 各ページへの導線

ローン画面 — 申込・残高照会・支払いシミュレーション等のAPI操作セクション

外貨画面 — レート取得・両替実行 等の操作

投資画面 — ポートフォリオ・売買注文 等の操作

KYC画面 — 本人確認状況・書類提出 等の操作

外部UIと内部UIの見た目の差別化

機能は同じ、見た目は外部UI / 内部UI で意図的に変える」という方針を採っており、内部UI(業務端末寄り)はこのような外観です。

内部UI トップ画面(Vanilla HTML 版)— 業務端末寄りのデザインに意図的に差別化

本記事の比較は 外部UI を題材にしていますが、各スタックとも external/internal の2セットを実装しており、CSS だけ入れ替えれば両方に対応できる構造になっています。


4. プロジェクト構成・ビルドの違い

Vanilla HTML — ビルド設定なし

package.jsontsconfig.json も無し。ブラウザでファイルを直接開くか、nginx で静的配信するだけ。

# 開発: 直接ブラウザで開く
open accounts.html

# 本番: nginx の document root に置く
nginx -c nginx-external.conf

依存パッケージなし、ビルドプロセスなし、node_modules なし。

Vue — Vite + TypeScript

// package.json (主要部分)
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.5.0",
    "vite": "^5.3.0",
    "vue-tsc": "^2.0.0"
  }
}
npm install      # node_modules を作る
npm run dev      # http://localhost:5173 でホットリロード開発
npm run build    # dist/ に静的ファイル生成

React — Vite + TypeScript (Vue と同じ Vite)

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "react-router-dom": "^6.24.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.5.0",
    "vite": "^5.3.0"
  }
}

Vue とほぼ同じ操作感。違いは @vitejs/plugin-vue vs @vitejs/plugin-react だけ。

Thymeleaf — Maven + Spring Boot

<!-- pom.xml (主要部分) -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.5</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
</dependencies>
mvn clean package      # target/*.jar 生成
java -jar target/banklink-external-web-thymeleaf.jar  # 起動

JVM が必要。target/ に生成される jar には Tomcat も埋め込まれているので、追加でアプリケーションサーバーは不要。

ビルド時間の体感比較

スタック初回 install本番ビルド起動時間
Vanilla HTML0秒0秒即時
Vue30〜60秒 (npm install)5〜15秒nginx 起動分
React30〜60秒5〜15秒nginx 起動分
Thymeleaf30〜120秒 (Maven 依存DL)20〜40秒 (mvn package)JVM起動分 (5〜15秒)

5. 同じ画面(口座ページ)を4スタックで実装

ここがこの記事の中心です。全く同じ「口座一覧取得・残高取得・入金・出金・取引履歴」の5セクションを、4通りに書いたコード を順に見ます。

Vanilla HTML 版

HTML と JS が同じ accounts.html に同居(<script type="module"> でモジュール)。

<!-- accounts.html (口座一覧と入金部分の抜粋) -->
<section class="section-card">
  <h2>口座一覧取得</h2>
  <button id="accounts-list-btn">GET /api/v1/accounts</button>
  <div id="accounts-list-response"></div>
</section>

<section class="section-card">
  <h2>入金</h2>
  <label>accountId<input id="deposit-account-id" value="ACC-0001" /></label>
  <label>amount<input id="deposit-amount" type="number" value="10000" /></label>
  <label>Idempotency-Key<input id="deposit-key" value="dep-key-001" /></label>
  <button id="accounts-deposit-btn">POST /api/v1/accounts/{id}/deposit</button>
  <div id="accounts-deposit-response"></div>
</section>

<script type="module">
  import { bindAction, numberValue, value } from "./common-external.js";

  // ボタン id とレスポンス表示先 id をマッピング
  bindAction("accounts-list-btn", "accounts-list-response", () => ({
    method: "GET",
    path: "/api/v1/accounts",
  }));

  bindAction("accounts-deposit-btn", "accounts-deposit-response", () => ({
    method: "POST",
    path: `/api/v1/accounts/${value("deposit-account-id")}/deposit`,
    idempotencyKey: value("deposit-key"),
    body: {
      amount: numberValue("deposit-amount"),
      currency: value("deposit-currency"),
      reference: value("deposit-reference"),
    },
  }));
</script>

特徴:

  • HTML 要素を id で識別 → JS から document.getElementById で参照(bindAction 関数内で行う)
  • 入力値は value("input-id") ヘルパで都度取得(リアクティブではなく「クリック時点の値」を読みに行く)
  • 結果は innerHTML で文字列として描画

Vue 版

<!-- AccountsView.vue (script setup + template) -->
<script setup lang="ts">
import { inject, ref } from "vue";
import type { Ref } from "vue";
import { requestApi } from "../api/client";

const token = inject<Ref<string>>("token")!;
function fmt(r: unknown) { return JSON.stringify(r, null, 2); }

// 口座一覧
const listRes = ref<string>("");
const listStatus = ref<number | null>(null);
async function getAccounts() {
  const r = await requestApi({ method: "GET", path: "/api/v1/accounts", token: token.value });
  listStatus.value = r.status; listRes.value = fmt(r.body);
}

// 入金
const depId = ref("ACC-0001"), depAmount = ref(10000),
      depKey = ref("dep-key-001"), depCurrency = ref("JPY"), depRef = ref("TEST-DEP-001");
const depRes = ref(""); const depStatus = ref<number | null>(null);
async function deposit() {
  const r = await requestApi({
    method: "POST",
    path: `/api/v1/accounts/${depId.value}/deposit`,
    token: token.value,
    idempotencyKey: depKey.value,
    body: { amount: depAmount.value, currency: depCurrency.value, reference: depRef.value },
  });
  depStatus.value = r.status; depRes.value = fmt(r.body);
}
</script>

<template>
  <section class="section-card">
    <h2>口座一覧取得</h2>
    <button @click="getAccounts">GET /api/v1/accounts</button>
    <pre v-if="listStatus !== null">HTTP {{ listStatus }}\n{{ listRes }}</pre>
  </section>

  <section class="section-card">
    <h2>入金</h2>
    <label>accountId<input v-model="depId" /></label>
    <label>amount<input v-model.number="depAmount" type="number" /></label>
    <label>Idempotency-Key<input v-model="depKey" /></label>
    <button @click="deposit">POST /api/v1/accounts/{id}/deposit</button>
    <pre v-if="depStatus !== null">HTTP {{ depStatus }}\n{{ depRes }}</pre>
  </section>
</template>

特徴:

  • ref()リアクティブ変数を宣言、v-model で双方向バインディング
  • inject<Ref<string>>("token") で親から共有トークンを取得
  • v-if でレスポンス表示を条件分岐、{{ }} で変数を埋め込み

React 版

// App.tsx の AccountsPage 関数(抜粋)
function AccountsPage({ token }: PageProps) {
  const [listResponse, setListResponse] = useState<ApiResult | null>(null);
  const [depositResponse, setDepositResponse] = useState<ApiResult | null>(null);

  const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
  const [depositAmount, setDepositAmount] = useState("10000");
  const [depositKey, setDepositKey] = useState("dep-key-001");
  const [depositCurrency, setDepositCurrency] = useState("JPY");
  const [depositReference, setDepositReference] = useState("TEST-DEP-001");

  return (
    <div className="page-grid">
      <Section title="口座一覧取得">
        <button
          onClick={async () =>
            setListResponse(await requestApi({ method: "GET", path: "/api/v1/accounts", token }))
          }
        >
          GET /api/v1/accounts
        </button>
        <ResponsePanel response={listResponse} />
      </Section>

      <Section title="入金">
        <label>accountId
          <input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
        </label>
        <label>amount
          <input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
        </label>
        <label>Idempotency-Key
          <input value={depositKey} onChange={e => setDepositKey(e.target.value)} />
        </label>
        <button
          onClick={async () =>
            setDepositResponse(await requestApi({
              method: "POST",
              path: `/api/v1/accounts/${depositAccountId}/deposit`,
              token,
              idempotencyKey: depositKey,
              body: {
                amount: Number(depositAmount),
                currency: depositCurrency,
                reference: depositReference,
              },
            }))
          }
        >
          POST /api/v1/accounts/{"{id}"}/deposit
        </button>
        <ResponsePanel response={depositResponse} />
      </Section>
    </div>
  );
}

特徴:

  • 各入力フィールド・各レスポンスごとに useState で状態を宣言(Vue の ref より宣言量が多い
  • onChange={e => setX(e.target.value)} のイベントハンドラを毎回書く必要(Vue の v-model のような糖衣構文がない)
  • JSX 内に直接ボタンの async ハンドラを書ける

Thymeleaf 版

3層構造: テンプレート + コントローラ + サービス + フォームオブジェクト。

<!-- accounts.html (口座一覧と入金部分の抜粋) -->
<section class="section-card">
  <h2>口座一覧取得(サーバーサイドForm)</h2>
  <form id="accounts-form" th:action="@{/api/v1/accounts}" th:object="${accountsForm}" method="post">
    <input type="hidden" th:field="*{authorization}" />
    <button type="submit" class="action-button">POST /api/v1/accounts</button>
  </form>
  <div th:if="${accountsForm != null and accountsForm.apiResponse != null}" class="response-box">
    <p><strong>Status:</strong> <span th:text="${accountsForm.apiResponse.statusCode}">0</span></p>
    <pre th:text="${accountsForm.apiResponse.body}"></pre>
  </div>
</section>

<section class="section-card">
  <h2>入金</h2>
  <form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
    <input type="hidden" th:field="*{authorization}" />
    <label>accountId<input th:field="*{depositAccountId}" /></label>
    <label>amount<input th:field="*{depositAmount}" type="number" /></label>
    <label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
    <button type="submit" class="action-button">POST /api/v1/accounts/deposit</button>
  </form>
</section>
// AccountsController.java
@Controller
@RequiredArgsConstructor
public class AccountsController {
    private final AccountsService accountsService;

    @PostMapping("/api/v1/accounts")
    public String listAccounts(
            @ModelAttribute("accountsForm") AccountsForm form,
            Model model) {
        applyToken(form.getAuthorization());
        form.setApiResponse(accountsService.listAccounts());
        model.addAttribute("accountsForm", form);
        return "accounts";  // accounts.html を再レンダリング
    }

    @PostMapping("/api/v1/accounts/deposit")
    public String deposit(
            @ModelAttribute("accountsForm") AccountsForm form,
            Model model) {
        applyToken(form.getAuthorization());
        Map<String, Object> body = Map.of(
            "amount", form.getDepositAmount(),
            "currency", form.getDepositCurrency(),
            "reference", form.getDepositReference()
        );
        form.setApiResponse(accountsService.deposit(
            form.getDepositAccountId(), body, form.getDepositIdempotencyKey()));
        model.addAttribute("accountsForm", form);
        return "accounts";
    }
}
// AccountsForm.java (抜粋)
@Data
public class AccountsForm {
    private String authorization;
    private String depositAccountId;
    private Integer depositAmount;
    private String depositCurrency;
    private String depositReference;
    private String depositIdempotencyKey;
    private ExternalApiResponse apiResponse;
    // ... (他フィールド)
}

特徴:

  • 各操作で HTTP POST → サーバー処理 → ページ再描画 という従来の Web フロー
  • th:field="*{depositAmount}" で Form Object のフィールドと input を双方向バインド(Spring が自動で値を受け渡し)
  • 結果表示もサーバーサイドで HTML 化してレンダリング → クライアント側 JS は最小

コード行数の比較(口座ページ 全体)

コード行数言語 / ファイル
Vanilla HTML約 130 行HTML + JS(1ファイル)
Vue約 130 行TypeScript + Template(1ファイル)
React約 350 行TypeScript JSX(1関数)
Thymeleaf約 250 行HTML + Java(5ファイルに分散)

React が長い理由: 各入力フィールドの useState 宣言と onChange ハンドラ、各ボタンの async コールバックを 個別に書く必要 があるため。Vue の v-model のような糖衣構文がない分、コード量が増える傾向。


6. API クライアントの違い

すべて同じバックエンド API(/api/v1/accounts 等)を叩きますが、クライアント実装は微妙に異なります。

Vanilla HTML / Vue / React — JavaScript の fetch

// Vue 版 client.ts
export interface ApiResult {
  status: number;
  body: unknown;
}

export async function requestApi(opts: {
  method: string;
  path: string;
  token: string;
  idempotencyKey?: string;
  body?: unknown;
}): Promise<ApiResult> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${opts.token}`,
  };
  if (opts.idempotencyKey) {
    headers["Idempotency-Key"] = opts.idempotencyKey;
  }

  try {
    const res = await fetch(opts.path, {
      method: opts.method,
      headers,
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
    });
    const text = await res.text();
    const responseBody = text ? JSON.parse(text) : null;
    return { status: res.status, body: responseBody };
  } catch (e) {
    return { status: 0, body: String(e) };
  }
}

Vanilla / Vue / React で ほぼ同一。違いは型注釈の有無くらい(Vanilla は .js、Vue / React は .ts)。

Thymeleaf — Spring Boot の RestClient

// AccountsService.java
@Service
@RequiredArgsConstructor
public class AccountsService {
    private final RestClient restClient;
    private String token;

    public ExternalApiResponse listAccounts() {
        ResponseEntity<String> response = restClient
            .get()
            .uri("/api/v1/accounts")
            .header("Authorization", "Bearer " + token)
            .retrieve()
            .toEntity(String.class);

        return new ExternalApiResponse(
            response.getStatusCode().value(),
            true,
            response.getBody()
        );
    }
}

Java の RestClient(Spring 6.1+)でビルダー記法。fetch と比べて型が厳密で IDE 補完が効きやすい。

共通点と相違点

観点Vanilla/Vue/ReactThymeleaf
言語JavaScript / TypeScriptJava
HTTP クライアントfetch(標準)RestClient(Spring 標準)
エラー処理try/catch + status codetry/catch + HttpClientErrorException
型安全性TypeScript で型注釈Java で型安全 (デフォルト)
ネットワーク発生場所ブラウザ → APIサーバー → API

最後の「ネットワーク発生場所」が アーキテクチャ上一番重要な違い です。

  • SPA系(Vanilla/Vue/React)はブラウザから API を直接叩く → CORS 設定が必要 / API 認証情報がクライアントに渡る
  • Thymeleaf はサーバーが API を叩く → CORS 不要 / 認証情報がサーバー内に閉じる

7. 状態管理・データバインディングの違い

「画面上の入力値・取得したレスポンス・トークン」をどう保持するかが各スタックの個性が出る部分。

Vanilla HTML — DOM が状態の源

// 入力値: いつでも DOM から取り出す
const accountId = document.getElementById("balance-account-id").value;

// レスポンス表示: innerHTML で文字列を直接書き込む
document.getElementById("balance-response").innerHTML = formatResponse(result);

// トークン: localStorage に保存
localStorage.setItem("banklink_token", token);

「状態」は概念がない。常に DOM か localStorage の値を読みに行く。シンプルだがアプリが大きくなると同期がしんどい。

Vue — ref でリアクティブ宣言

const balanceId = ref("ACC-0001");    // 文字列の値を持つリアクティブ変数
const balanceRes = ref("");           // テンプレートで {{ balanceRes }} と書くと自動更新

// テンプレート側で <input v-model="balanceId" /> と書けば
// ユーザー入力が balanceId.value に自動反映される(双方向バインディング)

「リアクティブ変数を宣言 → テンプレートが自動追従」 のモデル。書き手が状態同期を意識しなくていい。

React — useState で状態フックを宣言

const [balanceId, setBalanceId] = useState("ACC-0001");
const [balanceRes, setBalanceRes] = useState<ApiResult | null>(null);

// テンプレート側: 値とハンドラを別々に渡す
<input value={balanceId} onChange={e => setBalanceId(e.target.value)} />

「変数と setter のペアを宣言 → setter 経由で更新 → 再レンダリング」 のモデル。Vue の v-model のような糖衣構文がないので、入力フィールドごとに onChange を書く必要がありコード量が増える

Thymeleaf — Form Object でサーバー側に状態を集約

@Data
public class AccountsForm {
    private String authorization;
    private String balanceAccountId;
    private ExternalApiResponse apiResponse;
    // ... 他フィールド
}
<input th:field="*{balanceAccountId}" />

サーバー側の Form Object が「状態の正本」。POST のたびに input の値が Form Object に詰められ、Controller が処理 → 結果を Form Object に詰めて再描画。「クライアント側に状態を持たない」古典的な Web モデル。

場面ごとの向き不向き

アプリの性質向くスタック
小規模・1画面・状態がほぼないVanilla HTML
リアクティブ UI 多用、フォーム多いVue
コンポーネント再利用が多い、エコシステム重視React
状態をサーバー側に持ちたい(業務システム的)Thymeleaf

8. フォーム処理の違い

入金フォーム(accountId / amount / currency / reference / Idempotency-Key の5フィールド)の実装方法。

Vanilla HTML

// 各 input に id を付ける
<input id="deposit-account-id" value="ACC-0001" />

// クリック時にまとめて値を読み取る
const body = {
  accountId: document.getElementById("deposit-account-id").value,
  amount: Number(document.getElementById("deposit-amount").value),
  // ...
};

ヘルパ関数 value() を書けばこの繰り返しは減らせるが、「フォーム」という概念は HTML/JS 側にしかない

Vue — v-model で5行で済む

<label>accountId<input v-model="depId" /></label>
<label>amount<input v-model.number="depAmount" type="number" /></label>
<label>currency<input v-model="depCurrency" /></label>
<label>reference<input v-model="depRef" /></label>
<label>Idempotency-Key<input v-model="depKey" /></label>

v-model.number で数値型に自動変換。書く量が最も少ない

React — フィールドごとに useState + onChange

const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
const [depositAmount, setDepositAmount] = useState("10000");
const [depositCurrency, setDepositCurrency] = useState("JPY");
// ... 各フィールド分

<input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
<input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
// ...

5フィールド = useState 5回 + onChange 5回。react-hook-form のような外部ライブラリで省略できるが、本記事では「素の React」での比較。

Thymeleaf — th:field で Form Object と自動結合

<form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
  <input type="hidden" th:field="*{authorization}" />
  <label>accountId<input th:field="*{depositAccountId}" /></label>
  <label>amount<input th:field="*{depositAmount}" type="number" /></label>
  <label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
  <button type="submit">送信</button>
</form>

th:field 1つで「name属性・id属性・value属性」を自動設定し、サーバー側の Form Object とフィールドが結びつく。Vue の v-model に近い書き心地。


9. エラー・ローディング表示の違い

API 失敗時の表示パターン。

Vanilla HTML

function renderResponse(targetId, response) {
  const target = document.getElementById(targetId);
  const badgeClass = response.status >= 200 && response.status < 300 ? "ok" : "err";
  target.innerHTML = `
    <div class="response-panel">
      <span class="badge ${badgeClass}">HTTP ${response.status}</span>
      <pre>${escapeHtml(JSON.stringify(response.body, null, 2))}</pre>
    </div>
  `;
}

手動でエスケープが必要escapeHtml 関数を自前で用意するか、textContent を使う。

Vue / React — 自動エスケープ + 条件レンダリング

<!-- Vue -->
<div v-if="status !== null" class="response-panel">
  <span :class="status < 400 ? 'badge-ok' : 'badge-err'">HTTP {{ status }}</span>
  <pre>{{ response }}</pre>
</div>
// React
{response && (
  <div className="response-panel">
    <span className={response.status < 400 ? "badge-ok" : "badge-err"}>HTTP {response.status}</span>
    <pre>{JSON.stringify(response.body, null, 2)}</pre>
  </div>
)}

{{ }}{} で値を埋め込むと自動エスケープされる。XSS 対策を意識する必要がない。

Thymeleaf — th:text で自動エスケープ

<div th:if="${accountsForm.apiResponse != null}" class="response-box">
  <p>Status: <span th:text="${accountsForm.apiResponse.statusCode}"></span></p>
  <pre th:text="${accountsForm.apiResponse.body}"></pre>
</div>

th:text も自動エスケープ。th:utext を使うと unescape(XSS リスクあり)。


10. 起動・デプロイ構成の違い

Vanilla HTML — nginx で静的配信

# nginx-external.conf
server {
  listen 8080;
  root /app/banklink-external-web-vanilla-html;
  index index.html;

  location /api/ {
    proxy_pass http://banklink-api:8080;  # API へリバプロ
  }
}
FROM nginx:alpine
COPY banklink-web-vanilla-html /app/
COPY nginx-external.conf /etc/nginx/conf.d/default.conf

最小構成。HTML/JS/CSS をそのまま配信。

Vue / React — Vite ビルド → nginx 配信

# build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build   # dist/ を生成

# serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-external.conf /etc/nginx/conf.d/default.conf

ビルド成果物を nginx に配置。Vanilla との違いはビルドステージが追加されるだけ。

Thymeleaf — Spring Boot 実行可能 jar

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

JVM 必須。コンテナサイズも数十 MB + JVM 分(200 MB前後)。

イメージサイズ比較

コンテナイメージサイズ起動時間
Vanilla HTML (nginx)〜25 MB< 1秒
Vue (nginx 配信)〜30 MB< 1秒
React (nginx 配信)〜30 MB< 1秒
Thymeleaf (Spring Boot + JVM)〜200 MB5〜15秒

軽量化を優先するなら nginx 配信系(前者3つ)が有利。Spring Boot は重い分、サーバーサイドロジック・API プロキシ・認証統合などを 同一プロセスで扱える強みがある。


11. 横並び比較表

観点Vanilla HTMLVue 3ReactThymeleaf
言語JSTSTS (TSX)Java
ビルド不要ViteViteMaven
学習コスト中〜高中(Java 既習なら低)
コード行数(口座ページ)約130行約130行約350行約250行 (分散)
状態管理モデルDOMリアクティブ refuseStateForm Object (server)
フォーム結合手動v-modelonChange 個別th:field
XSS自動エスケープ手動自動自動自動
API呼び出し場所ブラウザブラウザブラウザサーバー
認証情報の持ち場所localStoragelocalStoragelocalStorageサーバー Session
CORS 必要はいはいはいいいえ
依存パッケージ数0〜10〜10〜20 (Maven)
コンテナイメージ〜25 MB〜30 MB〜30 MB〜200 MB
起動時間即時即時即時5〜15秒 (JVM)
動的UI弱い強い強い弱い (リロード前提)
エコシステムなし中規模巨大Spring エコシステム

12. どう選ぶか — 4スタックの強み弱みと判断軸

Vanilla HTML

強み:

  • ゼロ依存・ゼロビルド・ゼロ学習コスト(HTML/JS 基礎のみ)
  • 配信コスト最小・コンテナイメージ最小
  • 「30分で動くデモを作る」用途に最強

弱み:

  • アプリが大きくなると状態管理が破綻する(DOM 直接操作の限界)
  • TypeScript 型安全性なし
  • リアクティブ UI が苦手

選ぶ場面:

  • API 動作確認ツール、社内検証用フォーム、簡易ダッシュボード
  • 「フレームワーク要らない、画面だけほしい」
  • 後で SPA に置き換える前提のプロトタイプ

Vue

強み:

  • v-model などの糖衣構文でコード量が React より少ない
  • テンプレート構文が HTML に近く、初心者にも読みやすい
  • 単一ファイルコンポーネント (.vue) でロジック/テンプレート/スタイルが1ファイルに収まる

弱み:

  • React に比べてエコシステムが小さい
  • 採用案件・採用人材の数で React に劣る

選ぶ場面:

  • 業務系 SPA でフォーム・入力 UI が多い
  • 「フレームワーク選定で迷ったら Vue から試す」
  • Web エンジニア人材市場が日本国内中心の案件

React

強み:

  • 巨大なエコシステム(UI ライブラリ・状態管理・テスト・モバイル)
  • TypeScript との相性が良い(型定義が充実)
  • 採用市場で最も求人が多い

弱み:

  • 同じ機能を書くのに Vue より行数が増える傾向
  • useState, useEffect, useMemo, useCallback の使い分けが学習コスト
  • 関数コンポーネント再レンダリング理解が必須

選ぶ場面:

  • 大規模 SPA、Next.js / React Native との接続を見据える
  • エンジニア採用を重視する組織
  • UI ライブラリ(MUI / Mantine / shadcn など)を流用したい

Thymeleaf

強み:

  • クライアント側 JS を最小に抑えられる(業務システム的)
  • 認証情報・API トークンがサーバー内に閉じる(セキュリティ要件が厳しい場面で有利)
  • Spring Security / Spring Boot のエコシステムをフル活用
  • Java エンジニアが既存スキルで Web UI を作れる

弱み:

  • ページ遷移ごとにサーバーラウンドトリップが必要(SPA に比べてもっさり)
  • リアクティブな UI に向かない
  • JVM のコンテナイメージが重い

選ぶ場面:

  • 業務システム・管理画面(社内向け)
  • セキュリティ要件で API キーをクライアントに露出させたくない
  • Java エンジニアが多い組織、Spring Boot を既に採用済み
  • リッチな UI より「フォーム送信して結果表示」程度で十分

判断フロー

Q1. UI のリアクティブ性は必要か?
   ├─ No (フォーム送信して結果表示で十分)
   │   ├─ サーバー側集約したい → Thymeleaf
   │   └─ 軽量に作りたい → Vanilla HTML
   └─ Yes (リアクティブ UI が必要)
       ├─ エコシステムや採用重視 → React
       └─ コード量を抑えたい / 学習コスト軽 → Vue

13. まとめ・学び

  • 4スタックは「正解 vs 不正解」ではなく、プロジェクト要件によって有利不利が変わる。同じ仕様を並べると相違が浮かび上がる。
  • コード行数だけ見ると Vanilla / Vue が少なく、React が多い。ただし React は採用市場とエコシステムで補って余りある。
  • Thymeleaf は時代遅れではない — 業務システムのセキュリティ・運用要件には今でも合うことが多い。
  • 「フレームワークの選定は要件から逆算する」 が結論。流行で選ぶと数年後に苦労する。
  • 4実装を並行運用してみて分かったのは、ビルドツール(Vite vs Maven vs なし)の違いがプロジェクト立ち上げ初日の体験を大きく分けるということ。プロトタイプ段階では Vanilla / Vite 系が圧倒的に速い。

選定の際にこの比較が判断材料の1つになれば幸いです。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。