Thymeleafフラグメントを使った非同期更新のパターンは、サーバーサイドレンダリングの利点を活かしつつ、クライアント側でのJavaScriptの複雑さを軽減できる。以下に、2つの具体例を示す。
目次
- 動的なセレクトボックス(プルダウン)の実装
- バリデーションエラーを含んだフォームの非同期更新
例1:動的なセレクトボックス(プルダウン)
シナリオ: 1つ目のプルダウンで「商品カテゴリ」を選択すると、そのカテゴリに属する「商品」が2つ目のプルダウンに非同期で表示される。
A. モデル (Model)
カテゴリと商品のシンプルなクラスを用意する。
// Category.java と Product.java (中身は単純なPOGO)
// 実際にはJPAのエンティティなどを想定
record Category(Long id, String name) {}
record Product(Long id, String name, Long categoryId) {}
B. コントローラー (Controller)
初期表示用のメソッドと、カテゴリIDを受け取って商品リストの <option>
タグ群(フラグメント)を返すメソッドを用意する。
ProductSelectController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
import java.util.stream.Collectors;
@Controller
public class ProductSelectController {
// サンプルのためのダミーデータ
private final List<Category> categories = List.of(
new Category(1L, "家電"),
new Category(2L, "書籍")
);
private final List<Product> products = List.of(
new Product(101L, "テレビ", 1L),
new Product(102L, "冷蔵庫", 1L),
new Product(201L, "Spring入門", 2L),
new Product(202L, "Thymeleaf実践", 2L)
);
// 初期表示用のページ
@GetMapping("/products/select")
public String showProductSelect(Model model) {
model.addAttribute("categories", categories);
return "product-select";
}
// ★★★ 非同期リクエストで呼ばれるメソッド ★★★
@GetMapping("/api/products/options")
public String getProductOptions(@RequestParam Long categoryId, Model model) {
// カテゴリIDに一致する商品リストを取得
List<Product> filteredProducts = products.stream()
.filter(p -> p.categoryId().equals(categoryId))
.collect(Collectors.toList());
model.addAttribute("products", filteredProducts);
// ★ product-select.html内の "productOptionsFragment" という部品を返す
return "product-select :: productOptionsFragment";
}
}
C. ビュー (Thymeleaf - HTML & JavaScript)
この例では、フラグメントを同じHTMLファイル内に定義する。
templates/product-select.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<title>動的セレクトボックス</title>
</head>
<body>
<h1>商品選択</h1>
<div>
<label for="category">カテゴリ:</label>
<select id="category" name="category">
<option value="">-- 選択してください --</option>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.name}"></option>
</select>
</div>
<div style="margin-top: 10px;">
<label for="product">商品:</label>
<select id="product" name="product">
<th:block th:fragment="productOptionsFragment">
<option value="">-- カテゴリを選択 --</option>
<option th:if="${products}" th:each="prod : ${products}" th:value="${prod.id}" th:text="${prod.name}"></option>
</th:block>
</select>
</div>
<script>
// カテゴリのプルダウンが変更されたら発火
document.getElementById('category').addEventListener('change', function() {
const categoryId = this.value;
const productSelect = document.getElementById('product');
if (!categoryId) {
// カテゴリが未選択に戻されたら、商品プルダウンを初期状態に戻す
productSelect.innerHTML = '<option value="">-- カテゴリを選択 --</option>';
return;
}
// ★★★ APIにリクエストを送信 ★★★
fetch(`/api/products/options?categoryId=${categoryId}`)
.then(response => response.text())
.then(html => {
// ★★★ 返ってきたHTML(<option>タグ群)で、商品プルダウンの中身を丸ごと入れ替える ★★★
productSelect.innerHTML = html;
})
.catch(error => console.error('Error:', error));
});
</script>
</body>
</html>
D. このパターンの利点
-
<option>
タグを生成するロジック(th:each
など)をJavaScriptで書く必要がなく、Thymeleafの得意な領域で完結できる。 - JavaScriptはサーバーから返ってきたHTMLを
innerHTML
にセットするだけで、非常にシンプルになる。
例2:バリデーションエラーを含んだフォームの非同期更新
シナリオ: フォームを送信し、入力エラーがあればページ全体をリロードせずにエラーメッセージを表示する。成功すれば成功メッセージに差し替える。
A. モデル (Model)
@Valid
でチェックするためのバリデーションアノテーションを付与する。
ContactForm.java
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
public class ContactForm {
@NotEmpty(message = "名前は必須です")
private String name;
@NotEmpty(message = "Emailは必須です")
@Email(message = "有効なEmail形式で入力してください")
private String email;
// Getters and Setters
}
B. コントローラー (Controller)
フォーム送信を受け取るメソッドで、バリデーション結果 (BindingResult
) を見て、返すフラグメントを切り替える。
ContactController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.validation.Valid;
@Controller
public class ContactController {
// 初期表示
@GetMapping("/contact")
public String showContactForm(Model model) {
model.addAttribute("contactForm", new ContactForm());
return "contact-form";
}
// ★★★ Ajaxによるフォーム送信を受け取るメソッド ★★★
@PostMapping("/contact/submit")
public String submitContactForm(
@Valid @ModelAttribute ContactForm contactForm,
BindingResult bindingResult,
Model model) {
// ★★★ バリデーションエラーがある場合 ★★★
if (bindingResult.hasErrors()) {
// エラー情報を含んだフォームを、フラグメントとして返す
return "contact-form :: contactFormFragment";
}
// --- 成功時の処理(DB保存など)---
System.out.println("成功: " + contactForm.getName());
// ★★★ 成功した場合 ★★★
// 成功メッセージのフラグメントを返す
model.addAttribute("name", contactForm.getName());
return "contact-form :: successFragment";
}
}
C. ビュー (Thymeleaf - HTML & JavaScript)
フォーム部分と成功メッセージ部分をフラグメントとして定義する。
templates/contact-form.html
<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<title>Ajaxフォーム送信</title>
<style>.error { color: red; }</style>
</head>
<body>
<h1>お問い合わせ</h1>
<div id="formContainer">
<form th:fragment="contactFormFragment" id="contactForm" th:object="${contactForm}" method="post">
<div>
<label>名前:</label>
<input type="text" th:field="*{name}">
<span class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
</div>
<div style="margin-top: 10px;">
<label>Email:</label>
<input type="text" th:field="*{email}">
<span class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
</div>
<button type="submit" style="margin-top: 15px;">送信</button>
</form>
</div>
<div th:fragment="successFragment">
<h3 style="color: green;" th:text="${name} + 'さん、お問い合わせありがとうございます!'"></h3>
</div>
<script>
// formContainerは動的に入れ替わる可能性があるため、documentに対してイベントを設定
document.addEventListener('submit', function(event) {
// #contactForm の送信イベントを捕捉
if (event.target.id === 'contactForm') {
// デフォルトのフォーム送信をキャンセル
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
fetch('/contact/submit', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(html => {
// ★★★ 返ってきたHTML(フォームor成功メッセージ)でコンテナを入れ替える ★★★
document.getElementById('formContainer').innerHTML = html;
})
.catch(error => console.error('Error:', error));
}
});
</script>
</body>
</html>
D. このパターンの利点
-
バリデーションロジックがサーバー側に集約できる。Thymeleafの強力なエラー表示機能(
th:if
,th:errors
)をそのまま利用でき、JavaScriptでエラーオブジェクトを解釈して表示する手間が一切不要。 - 成功時と失敗時で返すHTMLをコントローラーで切り替えるだけで、クライアント側は返ってきたものを表示するだけ、というシンプルな責務分担となる。