はじめに (対象読者・この記事でわかること)

この記事は、Java・Spring Boot を使って Web アプリケーション開発を行っているエンジニア、特に Thymeleaf をテンプレートエンジンとして採用している方を対象としています。
ページ全体をリロードせずに、画面の一部だけを動的に更新したい、というニーズに応える具体的な実装方法を学びたい方に最適です。
本記事を読むことで、以下ができるようになります。

  • Spring Boot + Thymeleaf プロジェクトに Ajax を組み込み、サーバー側からフラグメント(部分テンプレート)を返す方法
  • jQuery(または vanilla JS)で取得した HTML を DOM に差し込む手順
  • 実装時に陥りがちなエラーや、デバッグのコツ

このテーマは、ユーザー体験を向上させるために頻繁に求められる「部分更新」の実装を、フレームワークの標準機能だけでシンプルに実現する点に焦点を当てています。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。
Java の基本的な文法とオブジェクト指向概念
Spring Boot の基本的なプロジェクト構成(@Controller, @Service など)
HTML/CSS の基礎知識
任意だが、簡単な JavaScript(特に fetch API や jQuery の AJAX)に慣れていると実装が楽です

Spring Boot と Thymeleaf で部分画面更新を行う背景

Web アプリケーションでは、ユーザー操作に応じてページ全体を再描画するよりも、必要な箇所だけを差し替える方がレスポンスが速く、ユーザー体験が向上します。
Spring Boot と Thymeleaf の組み合わせは、サーバーサイドで HTML を生成する点で強力ですが、デフォルトでは「ページ全体」しか返しません。そこで、フラグメント(部分テンプレート)と Ajax を組み合わせることで、サーバー側で必要な HTML 片だけを返し、クライアント側で差し替えることが可能になります。

ここで重要になる概念は次の3点です。

  1. Thymeleaf フラグメント
    th:fragment で定義したテンプレートの一部を、別テンプレートから呼び出す仕組みです。これにより、共通ヘッダーやテーブルの行、モーダルウィンドウなどを再利用できます。

  2. Ajax リクエスト
    JavaScript(今回は jQuery)で非同期リクエストを送り、サーバー側のコントローラがフラグメントだけを返すようにします。レスポンスは HTML 文字列になるため、クライアント側でそのまま DOM に挿入できます。

  3. Spring MVC の @ResponseBodyModelAndView
    フラグメントだけ返すエンドポイントは、@ResponseBody で文字列として HTML を返すか、ModelAndView でビュー名だけを指定し、th:replace でフラグメントに差し替える形にします。

この仕組みを正しく組み合わせると、たとえば「一覧テーブルのページング」や「検索結果のリアルタイム更新」など、一般的な UI パターンをシンプルに実装できます。

実装手順:Spring Boot + Thymeleaf で画面の一部だけを非同期に更新する

以下では、商品一覧ページに「検索ボックス」で絞り込みを行い、結果テーブルだけを更新するサンプルを通じて、実装の全体像を解説します。プロジェクトは Maven、Spring Boot 3.2、Thymeleaf 3.1、jQuery 3.7 を使用します。

ステップ 1:プロジェクトの雛形作成

Bash
$ spring init \ --dependencies=web,thymeleaf,lombok \ --package-name=com.example.demo \ demo-thymeleaf-ajax $ cd demo-thymeleaf-ajax $ ./mvnw clean package

src/main/resources/templates 配下に layout.html(ベースレイアウト)と product-list.html(一覧ページ)を作成します。

layout.html(共通レイアウト)

Html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:replace="~{::title}">Demo</title> <script src="https://code.jquery.com/jquery-3.7.0.min.js"></script> </head> <body> <header th:replace="fragments/header :: header"></header> <main th:replace="~{::section}"></main> <footer th:replace="fragments/footer :: footer"></footer> </body> </html>

fragments/header.html(ヘッダーのフラグメント)

Html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" th:fragment="header"> <header> <h1>商品一覧デモ</h1> </header> </html>

fragments/footer.html(フッターのフラグメント)

Html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" th:fragment="footer"> <footer> <p>&copy; 2025 Demo Company</p> </footer> </html>

ステップ 2:コントローラとサービスの実装

src/main/java/com/example/demo/controller/ProductController.java を作成します。

Java
package com.example.demo.controller; import com.example.demo.service.ProductService; import com.example.demo.model.Product; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import java.util.List; @Controller @RequiredArgsConstructor public class ProductController { private final ProductService productService; // 初期表示:全商品一覧 @GetMapping("/products") public String list(@RequestParam(value = "q", required = false) String query, Model model) { List<Product> products = (query == null || query.isBlank()) ? productService.findAll() : productService.search(query); model.addAttribute("products", products); model.addAttribute("query", query); return "product-list"; } // Ajax 用エンドポイント:フラグメントだけ返す @GetMapping(value = "/products/fragment", produces = "text/html") public String listFragment(@RequestParam(value = "q", required = false) String query, Model model) { List<Product> products = (query == null || query.isBlank()) ? productService.findAll() : productService.search(query); model.addAttribute("products", products); model.addAttribute("query", query); // → product-list-fragment.html がフラグメントとして返る return "product-list-fragment :: list"; } }

続いて、ダミーデータを返す ProductService を作ります。

Java
package com.example.demo.service; import com.example.demo.model.Product; import org.springframework.stereotype.Service; import java.util.*; import java.util.stream.*; @Service public class ProductService { private final List<Product> allProducts = List.of( new Product(1L, "Apple iPhone 15", 999), new Product(2L, "Samsung Galaxy S24", 899), new Product(3L, "Google Pixel 8", 799), new Product(4L, "Sony Xperia 5", 749), new Product(5L, "Nokia X30", 499) ); public List<Product> findAll() { return allProducts; } public List<Product> search(String keyword) { return allProducts.stream() .filter(p -> p.getName().toLowerCase().contains(keyword.toLowerCase())) .collect(Collectors.toList()); } }

Product エンティティはシンプルな POJO。

Java
package com.example.demo.model; import lombok.AllArgsConstructor; import lombok.Data; @Data @AllArgsConstructor public class Product { private Long id; private String name; private int price; }

ステップ 3:テンプレートの作成

product-list.html(全体ページ)

Html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" th:replace="layout :: layout"> <head> <title>商品一覧</title> </head> <body> <section> <div> <input type="text" id="searchBox" placeholder="商品名で検索" th:value="${query}" /> <button id="searchBtn">検索</button> </div> <!-- 結果テーブルはフラグメントで差し替え --> <div id="productTable" th:replace="product-list-fragment :: list(${products}, ${query})"> <!-- 初回ロード時はサーバ側で埋め込まれる --> </div> </section> <script th:inline="javascript"> /*<![CDATA[*/ $(function() { $('#searchBtn').on('click', function() { const q = $('#searchBox').val(); $.ajax({ url: /*[[@{/products/fragment}]]*/ '/products/fragment', data: { q: q }, success: function(fragment) { $('#productTable').html(fragment); }, error: function(xhr) { alert('検索に失敗しました: ' + xhr.status); } }); }); // Enter キーでも検索できるように $('#searchBox').on('keypress', function(e) { if (e.which === 13) { $('#searchBtn').click(); } }); }); /*]]>*/ </script> </body> </html>

product-list-fragment.html(テーブル部分のみのフラグメント)

Html
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" th:fragment="list (products, query)"> <table border="1" width="100%"> <thead> <tr><th>ID</th><th>商品名</th><th>価格 (¥)</th></tr> </thead> <tbody> <tr th:each="p : ${products}"> <td th:text="${p.id}"></td> <td th:text="${p.name}"></td> <td th:text="${p.price}"></td> </tr> <tr th:if="${#lists.isEmpty(products)}"> <td colspan="3" style="text-align:center;">該当する商品がありません</td> </tr> </tbody> </table> </html>

ポイント:

  • th:replace="product-list-fragment :: list(${products}, ${query})" で、最初のフルページ表示時にもフラグメントが埋め込まれます。
  • Ajax の success コールバックで受け取った HTML を #productTable に上書きするだけで、ページ全体のリロードが不要になります。
  • th:inline="javascript" により、Thymeleaf が URL 変数を自動展開します(@{/products/fragment})。

ステップ 4:動作確認とデバッグ

  1. アプリケーション起動
    bash $ ./mvnw spring-boot:run
  2. ブラウザで http://localhost:8080/products を開くと、全商品がテーブルに表示されます。
  3. 検索ボックスに「Apple」と入力し「検索」ボタンをクリックすると、サーバーが /products/fragment?q=Apple に対して部分テンプレートを返し、テーブルが「Apple iPhone 15」のみになることを確認してください。

ハマった点やエラー解決

発生した問題 原因 解決策
404 エラーでフラグメントが取得できない Ajax の URL がコンテキストパスを考慮していなかった th:inline="javascript" 内で @{/products/fragment} を使用し、Spring が生成する正しいパスに置き換える
クリック時にテーブルが空になる th:replace でフラグメントに渡すパラメータ名が一致していなかった コントローラの listFragmentmodel.addAttribute("products", ...) を設定し、テンプレート側の list (products, query) と名前を合わせた
JavaScript のエラー「$ is not defined」 jQuery がロードされていなかった <script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>layout.html<head> に追加

さらに高度な実装例

  • Spring WebFlux + Thymeleaf Reactive
    非同期ストリームでリアクティブにデータを配信し、Server-Sent Events と組み合わせると、リアルタイム更新が可能です。
  • Alpine.js で軽量化
    jQuery を除外し、Alpine.js の x-data / x-on:click で同等の機能を実装すると、バンドルサイズが小さくなります。
  • キャッシュ制御
    Cache-Control ヘッダーや ETag を設定し、頻繁にリクエストが走らないように最適化できます。

まとめ

本記事では、Spring Boot と Thymeleaf を組み合わせ、Ajax とフラグメント を活用した「画面の一部だけを非同期に更新」する手順を、実装サンプルとともに解説しました。

  • フラグメント(th:fragmentで再利用可能なテンプレート部品を定義し、サーバー側で必要な部分だけを返す
  • Ajax(jQuery)で非同期にフラグメントを取得し、クライアント側の DOM に差し替えるだけでページ全体のリロードを防止
  • 実装時の典型的なエラー(URL パス、属性名の不一致、jQuery 未読込)とその対処法を紹介

これにより、ユーザー体験が向上すると同時に、サーバー・クライアント双方の負荷を抑えることができます。次は WebSocketServer‑Sent Events を組み合わせたリアルタイム更新や、Alpine.js での軽量化戦略についても取り上げる予定です。

参考資料