Thymeleafフラグメントを使った非同期更新のパターン

Thymeleafフラグメントを使った非同期更新のパターンは、サーバーサイドレンダリングの利点を活かしつつ、クライアント側でのJavaScriptの複雑さを軽減できる。以下に、2つの具体例を示す。

## 目次
1.  **動的なセレクトボックス（プルダウン）の実装**
2.  **バリデーションエラーを含んだフォームの非同期更新**

---

### 例1：動的なセレクトボックス（プルダウン）

**シナリオ:** 1つ目のプルダウンで「商品カテゴリ」を選択すると、そのカテゴリに属する「商品」が2つ目のプルダウンに非同期で表示される。

#### A. モデル (Model)
カテゴリと商品のシンプルなクラスを用意する。

```java
// 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`**
```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`**
```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`**
```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`**
```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`**
```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をコントローラーで切り替えるだけで、クライアント側は返ってきたものを表示するだけ、というシンプルな責務分担となる。