同じ業務 Web UI を Vanilla HTML / Vue / React / Thymeleaf で実装して比較した — 4スタックの違いと選定指針
この記事でわかること
- 同一仕様の業務 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 | ビルド不要(モジュールスクリプト) |
| Vue | 3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x |
| React | 18.x + Vite 5.x + TypeScript 5.x + React Router 6.x |
| Thymeleaf | Spring 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通り」という比較ベースを作りました。

口座画面(上記)には「口座一覧取得・残高取得・入金・出金・取引履歴」の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 build で dist/ に静的ファイル生成 → 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と内部UIの見た目の差別化
「機能は同じ、見た目は外部UI / 内部UI で意図的に変える」という方針を採っており、内部UI(業務端末寄り)はこのような外観です。

本記事の比較は 外部UI を題材にしていますが、各スタックとも external/internal の2セットを実装しており、CSS だけ入れ替えれば両方に対応できる構造になっています。
4. プロジェクト構成・ビルドの違い
Vanilla HTML — ビルド設定なし
package.json も tsconfig.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 HTML | 0秒 | 0秒 | 即時 |
| Vue | 30〜60秒 (npm install) | 5〜15秒 | nginx 起動分 |
| React | 30〜60秒 | 5〜15秒 | nginx 起動分 |
| Thymeleaf | 30〜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/React | Thymeleaf |
|---|---|---|
| 言語 | JavaScript / TypeScript | Java |
| HTTP クライアント | fetch(標準) | RestClient(Spring 標準) |
| エラー処理 | try/catch + status code | try/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 MB | 5〜15秒 |
軽量化を優先するなら nginx 配信系(前者3つ)が有利。Spring Boot は重い分、サーバーサイドロジック・API プロキシ・認証統合などを 同一プロセスで扱える強みがある。
11. 横並び比較表
| 観点 | Vanilla HTML | Vue 3 | React | Thymeleaf |
|---|---|---|---|---|
| 言語 | JS | TS | TS (TSX) | Java |
| ビルド | 不要 | Vite | Vite | Maven |
| 学習コスト | 低 | 中 | 中〜高 | 中(Java 既習なら低) |
| コード行数(口座ページ) | 約130行 | 約130行 | 約350行 | 約250行 (分散) |
| 状態管理モデル | DOM | リアクティブ ref | useState | Form Object (server) |
| フォーム結合 | 手動 | v-model | onChange 個別 | th:field |
| XSS自動エスケープ | 手動 | 自動 | 自動 | 自動 |
| API呼び出し場所 | ブラウザ | ブラウザ | ブラウザ | サーバー |
| 認証情報の持ち場所 | localStorage | localStorage | localStorage | サーバー 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つになれば幸いです。