0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

目次

  1. 動的なセレクトボックス(プルダウン)の実装
  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をコントローラーで切り替えるだけで、クライアント側は返ってきたものを表示するだけ、というシンプルな責務分担となる。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?