Thymeleaf は、Spring Bootでよく使われる HTMLテンプレートエンジンです。
テンプレートエンジンとは、HTMLの中に「埋め込み用の目印(式)」を書いておき、サーバー側でその部分を置き換えて
完成したHTMLを返す仕組みのことです。
ThymeleafのテンプレートHTMLは、通常次の場所に置きます。
src/main/resources/templates/
└─ users.html
そして Controller から return "users"; と返すと、
Spring Boot は templates/users.html を探して表示します。
ControllerでHTMLを返す場合は、通常 @RestController ではなく
@Controller を使います。
(@RestController は主にJSONを返す用途です)
@Controller
public class UserPageController {
@GetMapping("/users")
public String users(Model model) {
// Viewに渡すデータをModelに入れる(キー名 "title")
model.addAttribute("title", "ユーザー一覧");
// templates/users.html を表示する
return "users";
}
}
ポイント:
return "users"; は「users.html」というファイル名そのものではなく、
テンプレート名(拡張子なし)を返しています。
Thymeleafでは、HTMLに th:text などの属性を書いて、Modelの値を埋め込みます。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${title}">タイトル</title>
</head>
<body>
<h1 th:text="${title}">ユーザー一覧</h1>
</body>
</html>
${title} は「Modelに入れた title の値」を意味します。
Controllerで model.addAttribute("title", "ユーザー一覧"); としたので、
ここには「ユーザー一覧」が表示されます。
<span th:text="${message}">ここが置き換わる</span>
たとえば Model に users というリストを渡している場合、
次のように繰り返し表示できます。
<ul>
<li th:each="u : ${users}">
<span th:text="${u.name}">名前</span>
</li>
</ul>
<a th:href="@{/users}">ユーザー一覧へ</a>
src/main/resources/templates/ に置く@Controller を使い、return "テンプレート名"; で返すModel に入れる(addAttribute)${...} で Model の値を表示する
ここまで理解できれば、Spring BootでHTMLを返す基本は完成です。
次のステップは「フォーム送信(POST)」「入力チェック」「エラーメッセージ表示」です。
Spring Boot + Thymeleafでは、まず GETでフォームを表示し、次に POSTで送信するのが基本です。
フォーム入力は Entity(DBの1行)ではなく、まず DTO(入力用の箱)に受け取るのが安全です。
// 入力用DTO(フォームの箱)
// ※後のバリデーション用アノテーションは次章で説明します
public class UserForm {
private String name;
private Integer age;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
}
@Controller
@RequestMapping("/users")
public class UserPageController {
private final UserService userService;
public UserPageController(UserService userService) {
this.userService = userService;
}
// ① GET:フォーム表示
@GetMapping("/new")
public String showForm(Model model) {
model.addAttribute("userForm", new UserForm());
return "user-form"; // templates/user-form.html
}
// ② POST:送信処理(まずはバリデーション無し版)
@PostMapping("/new")
public String submitForm(@ModelAttribute("userForm") UserForm form) {
userService.register(form.getName(), form.getAge());
// PRGパターン:POST後はリダイレクト(再送信防止)
return "redirect:/users";
}
}
ポイント:
@ModelAttribute により、フォームの入力が UserForm に自動で詰められます(バインド)。
@GetMapping のメソッド引数に Model は必須ではありません。
Model は HTML(View)にデータを渡したいときだけ 使用します。
Spring Boot(正確には Spring MVC)では、Controller メソッドの引数を Spring が自動で用意して渡す仕組みがあります。
この仕組みを 引数の自動解決(HandlerMethodArgumentResolver) と呼びます。
そのため、Controller メソッドでは
「自分で new して作ったオブジェクト」ではなく、
Spring に用意してほしいものを引数に書く
という考え方になります。
@GetMapping("/about")
public String about() {
// データを渡さず、HTMLを表示するだけ
return "about";
}
このように、固定ページなど View に渡すデータが無い場合は Model は不要です。
@GetMapping("/users")
public String users(Model model) {
// Viewに渡すデータを Model に入れる
model.addAttribute("title", "ユーザー一覧");
return "users";
}
Model は
Controller から View(Thymeleaf)へデータを渡すための箱
です。
上記の場合、HTML側では
${title} として
「ユーザー一覧」が表示されます。
Model 以外にも、
Spring は状況に応じてさまざまな引数を自動で渡してくれます。
| 引数 | 意味 |
|---|---|
Model |
View(HTML)に渡すデータ箱 |
@RequestParam |
URLのクエリパラメータ |
@PathVariable |
URLの一部の値 |
HttpServletRequest |
HTTPリクエスト全体 |
Principal |
ログイン中のユーザー情報 |
// ❌ Spring はどう用意すればいいか分からない
@GetMapping("/users")
public String users(String title) {
return "users";
}
Spring は 「その型のオブジェクトをどうやって作ればいいのか」 分からない引数は渡せません。
Model model を見ると、
「どこで new しているの?」と思いがちですが、
自分で new する必要はありません。
Spring が裏で作り、Controller に渡しています。
Model は必須ではない
一言で言うと:
Controller メソッドの引数は、
Spring に対する「これをください」という宣言
です。
テンプレート側では th:object と th:field を使うと楽です。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ユーザー登録</title>
</head>
<body>
<h1>ユーザー登録</h1>
<form th:action="@{/users/new}" th:object="${userForm}" method="post">
<div>
<label>名前</label>
<input type="text" th:field="*{name}" />
</div>
<div>
<label>年齢</label>
<input type="number" th:field="*{age}" />
</div>
<button type="submit">登録</button>
</form>
</body>
</html>
th:object="${userForm}" はフォームの「入力対象オブジェクト」を指定します。
th:field="*{name}" は、そのオブジェクトの name プロパティに結びつきます。
Spring Bootでは通常、次を入れるとバリデーションが使えます(Maven/GradleいずれでもOK)。
// Maven例(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
import jakarta.validation.constraints.*;
public class UserForm {
@NotBlank(message = "名前は必須です")
@Size(max = 20, message = "名前は20文字以内で入力してください")
private String name;
@NotNull(message = "年齢は必須です")
@Min(value = 0, message = "年齢は0以上で入力してください")
@Max(value = 120, message = "年齢は120以下で入力してください")
private Integer age;
// getter/setter 省略
}
BindingResultは必ず、@Validの直後の引数に置きます。
@PostMapping("/new")
public String submitForm(@Valid @ModelAttribute("userForm") UserForm form,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
// エラーがあれば、同じ画面に戻す(入力値は保持される)
return "user-form";
}
userService.register(form.getName(), form.getAge());
return "redirect:/users";
}
<form th:action="@{/users/new}" th:object="${userForm}" method="post">
<div>
<label>名前</label>
<input type="text" th:field="*{name}" />
<!-- nameにエラーがあるときだけ表示 -->
<div th:if="${#fields.hasErrors('name')}" th:errors="*{name}">エラー</div>
</div>
<div>
<label>年齢</label>
<input type="number" th:field="*{age}" />
<div th:if="${#fields.hasErrors('age')}" th:errors="*{age}">エラー</div>
</div>
<button type="submit">登録</button>
</form>
#fields.hasErrors('name') は「nameにエラーがあるか?」を判定します。
th:errors="*{name}" はそのエラーメッセージを表示します。
まず、テンプレート部品用フォルダを作ると管理しやすいです。
src/main/resources/templates/
├─ fragments/
│ ├─ header.html
│ └─ footer.html
└─ user-form.html
<!-- fragments/header.html -->
<div xmlns:th="http://www.thymeleaf.org"
th:fragment="header(title)">
<header>
<h1 th:text="${title}">タイトル</h1>
<nav>
<a th:href="@{/users}">一覧</a> |
<a th:href="@{/users/new}">新規登録</a>
</nav>
<hr>
</header>
</div>
<!-- fragments/footer.html -->
<div xmlns:th="http://www.thymeleaf.org"
th:fragment="footer">
<hr>
<footer>
<small>© My App</small>
</footer>
</div>
例として、user-form.html にヘッダー/フッターを差し込みます。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ユーザー登録</title>
</head>
<body>
<!-- header(title) に "ユーザー登録" を渡す -->
<div th:replace="~{fragments/header :: header('ユーザー登録')}"></div>
<main>
<form th:action="@{/users/new}" th:object="${userForm}" method="post">
<div>
<label>名前</label>
<input type="text" th:field="*{name}" />
<div th:if="${#fields.hasErrors('name')}" th:errors="*{name}">エラー</div>
</div>
<div>
<label>年齢</label>
<input type="number" th:field="*{age}" />
<div th:if="${#fields.hasErrors('age')}" th:errors="*{age}">エラー</div>
</div>
<button type="submit">登録</button>
</form>
</main>
<div th:replace="~{fragments/footer :: footer}"></div>
</body>
</html>
次のステップとして、実務では Service層での登録処理(重複チェックやDB保存)や @Transactional(トランザクション)を学ぶと理解がつながります。
Thymeleafにはいろいろな式がありますが、初心者がまず覚えるべきは次の3つです。
${title})*{name})@{/users/new})
ユーザー一覧
ユーザー一覧
id
userName
email
1
taka_o
taka@example.com
@Controller
@RequestMapping("/users")
public class UserPageController {
private final UserService userService;
public UserPageController(UserService userService) {
this.userService = userService;
}
// ユーザー一覧画面を表示
@GetMapping
public String showUsers(Model model) {
// 本来はDBから取得(Service経由)
List users = userService.findAll();
// View(HTML)に渡す
model.addAttribute("users", users);
// templates/users.html を表示
return "users";
}
}
@Controller
@RequestMapping("/users")
public class UserPageController {
private final UserService userService;
public UserPageController(UserService userService) {
this.userService = userService;
}
// ユーザー一覧画面を表示
@GetMapping
public String showUsers(Model model) {
// 本来はDBから取得(Service経由)
List users = userService.findAll();
// View(HTML)に渡す
model.addAttribute("users", users);
// templates/users.html を表示
return "users";
}
}
Thymeleaf では、HTMLタグに th:〜 という属性を付けることで、
Controller から渡されたデータを HTML に埋め込みます。
用途: タグの中身の文字を置き換える
<td th:text="${user.email}">taka@example.com</td>
説明:
${user.email} の値で、タグの中身が完全に置き換わります。
taka@example.com は HTML を直接開いたとき用のダミー表示です。
用途: リストをループして表示する
<tr th:each="user : ${users}">
<td th:text="${user.userName}">taka_o</td>
</tr>
説明:
${users} は Controller から渡されたリストです。
1件ずつ user という変数に入れて繰り返します。
用途: 条件が true のときだけ表示する
<div th:if="${user.email != null}">
<span th:text="${user.email}">mail</span>
</div>
説明:
条件が false の場合、この要素は HTML にすら出力されません。
用途: th:if の逆
<p th:unless="${users.size() > 0}">
データがありません
</p>
用途: aタグのリンク先を動的に作る
<a th:href="@{/users/{id}(id=${user.id})}">
詳細
</a>
説明:
URL の {id} 部分に user.id が入ります。
実行時:/users/3 のようになります。
用途: form の送信先URLを指定
<form th:action="@{/users/new}" method="post">
用途: フォーム入力の対象オブジェクトを指定
<form th:object="${userForm}">
説明:
このフォーム全体が userForm に結び付きます。
用途: input と Java のプロパティを自動連携
<input type="text" th:field="*{userName}" />
説明:
*{} は th:object で指定したオブジェクトを指します。
name属性・value属性も自動生成されます。
用途: 入力エラーを表示
<div th:errors="*{email}">エラー</div>
説明:
@Valid で検出されたエラーメッセージが表示されます。
用途: エラー時などにクラスを追加
<input th:field="*{email}"
th:classappend="${#fields.hasErrors('email')} ? 'error' : ''" />
用途: ヘッダー・フッターなどの共通化
<div th:replace="~{fragments/header :: header('タイトル')}"></div>
説明:
別ファイルで定義したテンプレート部品をここに差し込みます。
${user.name} // プロパティ参照
${users.size()} // 件数
${#lists.isEmpty(users)} // 空チェック
${#fields.hasErrors('name')} // 入力エラー判定
ここまで理解できれば、
Spring Boot + Thymeleaf の画面実装は一通り書けるレベルです。
Spring Boot で Thymeleaf を使うには、 pom.xml に専用の starter を1つ追加するだけです。
以下を pom.xml の <dependencies> 内に追加します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
これで、
spring-boot-starter-thymeleaf は、
Thymeleaf を使うために必要な
複数のライブラリと設定をまとめたパッケージです。
そのため、次のような作業は不要です。
Spring Boot の「自動設定(Auto Configuration)」が すべて裏でやってくれます。
上記 dependency を追加すると、Spring Boot は次を自動で行います。
templates/ フォルダを View の探索対象にするreturn "users"; → users.html を探すth:text や th:each を解釈するQ. Thymeleaf を使わない場合は?
この dependency を入れなければ、
Spring Boot は HTML テンプレートとして処理しません。
@Controller で return "users"; と書くと
エラーになります。
Q. application.properties に設定は必要?
基本的には 不要です。
デフォルト設定で次の場所が使われます。
src/main/resources/templates/*.html
学習用としては不要ですが、 以下のような設定も存在します。
# テンプレートのキャッシュを無効化(開発中に便利)
spring.thymeleaf.cache=false
一言で言うと:
Thymeleaf は
「入れた瞬間から使えるように設計されている」
テンプレートエンジンです。
<input type="text" th:field="*{name}" /> の値は、
Controller の引数で受け取る「フォーム用クラス(DTO)」の name プロパティに自動で入ります。
Thymeleaf のフォーム入力は、
HTML → Controller に直接 String を渡すのではありません。
いったん フォーム用のJavaクラス(DTO) にまとめて受け取ります。
この仕組みを データバインディング(Binding) と呼びます。
// フォーム入力を受け取るDTO
public class UserForm {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
ポイント:
th:field="*{name}" は、
このクラスの name プロパティと 1対1で対応します。
<form th:action="@{/users/new}"
th:object="${userForm}"
method="post">
<input type="text" th:field="*{name}" />
<button type="submit">送信</button>
</form>
ここでの対応関係は次のとおりです。
| HTML | 意味 |
|---|---|
th:object="${userForm}" |
Controllerに渡すオブジェクト名 |
th:field="*{name}" |
そのオブジェクトの name プロパティ |
@Controller
@RequestMapping("/users")
public class UserPageController {
@PostMapping("/new")
public String submit(
@ModelAttribute("userForm") UserForm userForm
) {
// input に入力された値がここに入る
String name = userForm.getName();
System.out.println(name);
return "result";
}
}
Spring が自動で、
フォーム入力値を UserForm に詰めてから
Controller に渡します。
はい、省略できます。
@PostMapping("/new")
public String submit(UserForm userForm) {
String name = userForm.getName();
return "result";
}
Spring が引数の型を見て判断します。
ただし初心者のうちは、
何が起きているか分かりやすいので明示するのがおすすめです。
❌ String で直接受け取ろうとする
@PostMapping("/new")
public String submit(String name) { // NG
return "result";
}
Spring は 「どの入力項目の name なのか」 を判断できないため、正しく動きません。
❌ プロパティ名が一致していない
// HTML
<input th:field="*{username}" />
// Java
private String name; // NG
th:field の名前と Java のプロパティ名は必ず一致させます。
th:field はフォーム用クラスのプロパティに対応するString で受け取らない
一言で言うと:
th:field は
Controller の引数オブジェクトへの「直通ルート」
です。
「行をクリックすると input に表示」は、サーバー(Controller)だけではできません。
クリックはブラウザ内のイベントなので、JavaScriptで input に値を入れるのが一般的です。
(もしくは、クリックで /edit?id=... に遷移してサーバー側で再表示、という方法もあります)
@Controller
@RequestMapping("/users")
public class UserEditPageController {
private final UserService userService;
public UserEditPageController(UserService userService) {
this.userService = userService;
}
// 画面表示:一覧とフォーム(初期は空)
@GetMapping
public String page(Model model) {
model.addAttribute("users", userService.findAll());
// フォーム用DTO(空)
model.addAttribute("userForm", new UserForm());
return "users-edit"; // templates/users-edit.html
}
// 更新:フォーム送信を受け取って保存
@PostMapping("/update")
public String update(@ModelAttribute("userForm") UserForm form) {
userService.update(form); // id を含めて更新する想定
return "redirect:/users"; // 画面再表示(再送信防止)
}
}
ポイント:
users(一覧)と userForm(入力フォーム)を両方渡すUserForm を受け取って更新し、redirect で戻すpublic class UserForm {
private Long id;
private String userName;
private String email;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Users Edit</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
tr.clickable { cursor: pointer; }
tr.clickable:hover { background: #fafbff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.card { background: #fff; padding: 16px; border-radius: 12px; }
body { background: #f6f7fb; padding: 20px; font-family: system-ui, sans-serif; }
label { display:block; margin-top: 10px; font-size: 12px; color:#555; }
input { width: 100%; padding: 8px; }
button { margin-top: 12px; padding: 10px 14px; }
</style>
</head>
<body>
<div class="grid">
<div class="card">
<h2>一覧(行クリックで右のフォームに表示)</h2>
<table>
<thead>
<tr><th>id</th><th>userName</th><th>email</th></tr>
</thead>
<tbody>
<tr class="clickable"
th:each="u : ${users}"
th:attr="data-id=${u.id}, data-username=${u.userName}, data-email=${u.email}">
<td th:text="${u.id}">1</td>
<td th:text="${u.userName}">taka_o</td>
<td th:text="${u.email}">taka@example.com</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>編集フォーム</h2>
<form th:action="@{/users/update}" th:object="${userForm}" method="post">
<!-- どのユーザーを更新するか:hiddenでidを送る -->
<input type="hidden" id="id" th:field="*{id}" />
<label>userName</label>
<input type="text" id="userName" th:field="*{userName}" />
<label>email</label>
<input type="text" id="email" th:field="*{email}" />
<button type="submit">保存(更新)</button>
</form>
<p style="font-size:12px;color:#666;margin-top:10px">
※行をクリックするとフォームに値が入ります(ブラウザ内のJavaScriptで反映)。
</p>
</div>
</div>
<script>
// 行クリック → data-* を input に入れる
document.querySelectorAll("tr.clickable").forEach(tr => {
tr.addEventListener("click", () => {
document.getElementById("id").value = tr.dataset.id;
document.getElementById("userName").value = tr.dataset.username;
document.getElementById("email").value = tr.dataset.email;
});
});
</script>
</body>
</html>
ここがポイント:
行に data-id / data-username / data-email を仕込んで、クリック時に input に入れています。
こうすると「1画面で一覧+編集」が成立します。
「JSがまだ難しい」場合は、クリックをリンクにして
/users/edit/{id} に移動し、サーバー側でフォームに値を入れて表示する方法がよく使われます。
@GetMapping("/edit/{id}")
public String edit(@PathVariable Long id, Model model) {
model.addAttribute("users", userService.findAll()); // 左に一覧を出すなら
model.addAttribute("userForm", userService.findFormById(id)); // 取得してフォームに詰める
return "users-edit";
}
パターンBの特徴:
save() で UPDATE)deleteById())→再表示
※「行クリックでフォームに反映」はブラウザ側のイベントなので、JavaScript を最小で使います。
JSなし派なら「クリックで /users/edit/{id} に遷移」版にできます。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
@Entity
public class User {
@Id
@GeneratedValue
private Long id;
private String userName;
private String email;
// getter / setter(省略しない場合は必ず用意)
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Entity を直接フォームに使わず、まず Form(DTO)で受け取るのが安全です。
import jakarta.validation.constraints.*;
public class UserForm {
// 更新対象を特定するために必要(hiddenで送る)
@NotNull(message = "id は必須です(行を選択してください)")
private Long id;
@NotBlank(message = "userName は必須です")
@Size(max = 20, message = "userName は20文字以内で入力してください")
private String userName;
@NotBlank(message = "email は必須です")
@Email(message = "email の形式が正しくありません")
@Size(max = 100, message = "email は100文字以内で入力してください")
private String email;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUserName() { return userName; }
public void setUserName(String userName) { this.userName = userName; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
public interface UserRepository extends JpaRepository<User, Long> {
// 今回は findAll / findById / save / deleteById を使うので追加メソッドは不要
}
更新は JPA の save() で実現します。
重要:id が入っている Entity を save すると UPDATE になります(DBにそのidが存在する前提)。
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<User> findAll() {
return userRepository.findAll();
}
// FormをEntityに反映して更新(save)
public void update(UserForm form) {
// 既存を取得(存在しない場合は例外)
User user = userRepository.findById(form.getId())
.orElseThrow(() -> new IllegalArgumentException("指定idのユーザーが存在しません: " + form.getId()));
// 更新したい項目だけ反映
user.setUserName(form.getUserName());
user.setEmail(form.getEmail());
// save:idがあるので UPDATE 相当
userRepository.save(user);
}
public void delete(Long id) {
userRepository.deleteById(id);
}
}
BindingResult は必ず @Valid の直後に置きます。
エラーがあれば同じ画面を返し、一覧も再度 Model に入れ直します(画面に一覧が必要なので)。
@Controller
@RequestMapping("/users")
public class UserEditPageController {
private final UserService userService;
public UserEditPageController(UserService userService) {
this.userService = userService;
}
// 画面表示:一覧 + 空フォーム
@GetMapping
public String page(Model model) {
model.addAttribute("users", userService.findAll());
model.addAttribute("userForm", new UserForm()); // 初期は空
return "users-edit";
}
// 更新:@Valid で入力チェック → OKなら更新
@PostMapping("/update")
public String update(@Valid @ModelAttribute("userForm") UserForm form,
BindingResult result,
Model model) {
if (result.hasErrors()) {
// エラー時も一覧は必要
model.addAttribute("users", userService.findAll());
return "users-edit";
}
userService.update(form);
return "redirect:/users";
}
// 削除:idを受け取って削除
@PostMapping("/delete")
public String delete(@RequestParam("id") Long id) {
userService.delete(id);
return "redirect:/users";
}
}
左の行をクリックすると、右フォームの hidden(id) と input(userName/email) に値が入ります。
更新ボタンはフォーム送信(POST /users/update)、削除ボタンは別フォーム(POST /users/delete)です。
Users Edit
一覧(行クリックで右に反映)
id
userName
email
操作
1
taka_o
taka@example.com
※行をクリックすると右のフォームに値が入ります(JSで反映)。
編集フォーム(@Valid でチェック)
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Users Edit</title>
<style>
body { background:#f6f7fb; padding:20px; font-family:system-ui, sans-serif; }
.grid { display:grid; grid-template-columns: 1.2fr 1fr; gap:16px; max-width: 980px; margin:0 auto; }
.card { background:#fff; padding:16px; border-radius:12px; box-shadow: 0 6px 16px rgba(0,0,0,.06); }
table { border-collapse: collapse; width: 100%; }
th, td { border-bottom: 1px solid #eee; padding: 10px; text-align: left; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background:#fafbff; }
label { display:block; margin-top:10px; font-size:12px; color:#555; }
input { width:100%; padding:8px; box-sizing:border-box; }
.row { display:flex; gap:10px; margin-top:12px; align-items:center; }
button { padding:10px 14px; cursor:pointer; }
.error { color:#d92d20; font-size:12px; margin-top:6px; }
.hint { font-size:12px; color:#667085; margin-top:8px; }
.danger { border:1px solid #f04438; background:#fff5f5; }
</style>
</head>
<body>
<div class="grid">
<div class="card">
<h2>一覧(行クリックで右に反映)</h2>
<table>
<thead>
<tr><th>id</th><th>userName</th><th>email</th><th>操作</th></tr>
</thead>
<tbody>
<tr class="clickable"
th:each="u : ${users}"
th:attr="data-id=${u.id}, data-username=${u.userName}, data-email=${u.email}">
<td th:text="${u.id}">1</td>
<td th:text="${u.userName}">taka_o</td>
<td th:text="${u.email}">taka@example.com</td>
<td>
<!-- 削除は別フォーム(行ごとに送る) -->
<form th:action="@{/users/delete}" method="post" style="margin:0">
<input type="hidden" name="id" th:value="${u.id}" />
<button type="submit" class="danger">削除</button>
</form>
</td>
</tr>
</tbody>
</table>
<p class="hint">※行をクリックすると右のフォームに値が入ります(JSで反映)。</p>
</div>
<div class="card">
<h2>編集フォーム(@Valid でチェック)</h2>
<form th:action="@{/users/update}" th:object="${userForm}" method="post">
<!-- 更新対象のid(クリックで入る) -->
<input type="hidden" id="id" th:field="*{id}" />
<!-- id のエラー表示(行を選ばずに保存した場合など) -->
<div th:if="${#fields.hasErrors('id')}" th:errors="*{id}" class="error">id error</div>
<label>userName</label>
<input type="text" id="userName" th:field="*{userName}" />
<div th:if="${#fields.hasErrors('userName')}" th:errors="*{userName}" class="error">userName error</div>
<label>email</label>
<input type="text" id="email" th:field="*{email}" />
<div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error">email error</div>
<div class="row">
<button type="submit">保存(更新)</button>
<span class="hint">※保存前に必ず行をクリックして選択してください</span>
</div>
</form>
</div>
</div>
<script>
// 行クリック → data-* をフォームに反映
document.querySelectorAll("tr.clickable").forEach(tr => {
tr.addEventListener("click", () => {
document.getElementById("id").value = tr.dataset.id;
document.getElementById("userName").value = tr.dataset.username;
document.getElementById("email").value = tr.dataset.email;
});
});
</script>
</body>
</html>
Q. 更新はなぜ save() でできるの?
JPA では、id を持つ Entity を save すると UPDATE 相当になります(そのidがDBに存在する前提)。
今回は安全のために findById で既存 Entity を取ってから、値を変更して save しています。
Q. エラー時に一覧が消えるのはなぜ?
エラー時に return "users-edit" で同じ画面に戻す場合、
一覧データ(users)も再度 model に入れないと、テンプレート側で表示できません。
そのため、エラー時は model.addAttribute("users", ...) を必ず入れています。
Q. 削除はなぜ別フォーム?
HTML の基本では、ボタン送信はそのフォームの action に送られます。
更新フォームと削除ボタンは送信先が違うため、削除用フォームを別にしています。
history.back() はブラウザ履歴に戻るだけなので、状況によっては
ログイン画面まで戻れてしまいます。
「Aから来たらAへ、Bから来たらBへ」と戻り先を確実に制御したい場合は、
戻り先を明示的に管理するのが一般的です。
例えば履歴が次の順なら、
/login → /menuA → /detail
詳細画面で history.back() を押すと、
履歴どおりに戻るだけなので、
/detail → /menuA → /login
となり、さらに戻すとログイン画面にも戻れてしまいます。
これはバグではなく ブラウザの仕様です。
「どこに戻るか」を returnTo のようなパラメータで持ち回ります。
こうすると、ログイン画面に戻らないように 戻り先を固定できます。
一覧A → 詳細(戻り先を付ける)
<a th:href="@{/detail/{id}(id=${u.id}, returnTo='/menuA')}">詳細</a>
一覧B → 詳細
<a th:href="@{/detail/{id}(id=${u.id}, returnTo='/menuB')}">詳細</a>
Controller(returnTo を Model に渡す)
@GetMapping("/detail/{id}")
public String detail(@PathVariable Long id,
@RequestParam(required=false) String returnTo,
Model model) {
model.addAttribute("item", service.findById(id));
model.addAttribute("returnTo", returnTo != null ? returnTo : "/menuA"); // デフォルト
return "detail";
}
詳細画面(戻るリンク)
<a th:href="${returnTo}">戻る</a>
メリット:
どうしても戻るを「履歴優先」にしたい場合は、 「ログイン画面に戻りそうなら安全なページへ飛ばす」 というフォールバックを付けます。
<button type="button" onclick="goBack()">戻る</button>
<script>
function goBack() {
// 前ページがログインっぽいなら安全な戻り先へ
if (document.referrer && !document.referrer.includes('/login')) {
history.back();
} else {
location.href = '/menuA'; // 安全なデフォルト
}
}
</script>
注意: document.referrer は状況により空になったり、
セキュリティ設定で消えることがあります(完全ではありません)。
リクエストヘッダの Referer を使う方法もありますが、
消えることがあるため「確実な戻り先」にしたい用途には弱いです。
一言で:
history.back() は便利ですが「戻りすぎ」を制御できません。
SSRで安全に戻るなら、戻り先を明示的に持ち回すのが王道です。
JpaRepository を継承していれば、ページング用の findAll(Pageable) が最初から使えます。
public interface UserRepository extends JpaRepository<User, Long> {
// 追加メソッド不要(findAll(Pageable) が使える)
}
URL例:/users?page=0&size=10
※pageは0始まり(0=1ページ目)に注意。
@Controller
@RequestMapping("/users")
public class UserPagingController {
private final UserRepository userRepository;
public UserPagingController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping
public String list(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "10") int size,
Model model
) {
// 例:新しい順にしたいなら Sort を付ける
Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
Page<User> userPage = userRepository.findAll(pageable);
// Thymeleaf に渡す
model.addAttribute("userPage", userPage);
model.addAttribute("users", userPage.getContent()); // 表示用リスト
model.addAttribute("page", page);
model.addAttribute("size", size);
return "users-paging"; // templates/users-paging.html
}
}
ポイント:
PageRequest.of(page, size) でページ指定userPage.getContent() が「そのページのデータ本体」userPage には総件数・総ページ数なども入っている
ファイル配置:src/main/resources/templates/users-paging.html
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Users(Paging)</title>
<style>
body { background:#f6f7fb; padding:20px; font-family:system-ui, sans-serif; }
.card { max-width: 960px; margin:0 auto; background:#fff; padding:16px; border-radius:12px; }
table { border-collapse: collapse; width:100%; }
th, td { border-bottom: 1px solid #eee; padding:10px; text-align:left; }
.pager { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:12px; }
.pager a, .pager span {
display:inline-block; padding:8px 10px; border:1px solid #e5e7eb; border-radius:10px;
text-decoration:none; color:#111;
}
.pager .disabled { color:#999; background:#f3f4f6; border-color:#eee; }
.pager .active { font-weight:700; }
.meta { font-size:12px; color:#667085; margin-top:10px; }
</style>
</head>
<body>
<div class="card">
<h2>ユーザー一覧(ページング)</h2>
<table>
<thead>
<tr>
<th>id</th>
<th>userName</th>
<th>email</th>
</tr>
</thead>
<tbody>
<tr th:each="u : ${users}">
<td th:text="${u.id}">1</td>
<td th:text="${u.userName}">taka_o</td>
<td th:text="${u.email}">taka@example.com</td>
</tr>
</tbody>
</table>
<div class="meta">
全<span th:text="${userPage.totalElements}">0</span>件 /
<span th:text="${userPage.totalPages}">0</span>ページ中
<span th:text="${userPage.number + 1}">1</span>ページ目
(1ページ<span th:text="${userPage.size}">10</span>件)
</div>
<!-- ページャ -->
<div class="pager">
<!-- 前へ(hasPrevious が false なら押せない表示) -->
<span class="disabled" th:if="${!userPage.hasPrevious()}">前へ</span>
<a th:if="${userPage.hasPrevious()}"
th:href="@{/users(page=${userPage.number - 1}, size=${userPage.size})}">前へ</a>
<!-- ページ番号(0..totalPages-1 をループ) -->
<a th:each="i : ${#numbers.sequence(0, userPage.totalPages - 1)}"
th:href="@{/users(page=${i}, size=${userPage.size})}"
th:text="${i + 1}"
th:classappend="${i == userPage.number} ? ' active' : ''">1</a>
<!-- 次へ -->
<span class="disabled" th:if="${!userPage.hasNext()}">次へ</span>
<a th:if="${userPage.hasNext()}"
th:href="@{/users(page=${userPage.number + 1}, size=${userPage.size})}">次へ</a>
</div>
</div>
</body>
</html>
Spring Data の PageRequest は 0 が1ページ目です。
画面表示だけ userPage.number + 1 にして「人間向け」にします。
データが0件だと totalPages = 0 になり、
#numbers.sequence(0, -1) となって困ることがあります。
実務では次のようにガードします(任意)。
<div class="pager" th:if="${userPage.totalPages > 0}">
...
</div>
PageRequest.of(page, size) でページ指定findAll(Pageable) がそのまま使えるuserPage.hasPrevious()/hasNext() で「前へ/次へ」制御#numbers.sequence(0, totalPages-1) でリンク生成