Thymeleafで入力エラー後にFormの値が消える原因を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアがFormとBindingResultを確認するイラスト。

実装・エラー解決

Thymeleafで入力エラー後に値が消える|Formとselectを保持する方法

AIによる要約

Thymeleafで入力エラー後に値や選択状態が消えたら、送信値・Form・再表示HTMLを順に確認します。この記事では氏名と部署を入力し、メールだけを不正にしたPOSTを実行します。部署コードがFormに残っていても、選択肢の一覧をModelへ戻し忘れるとselectを再現できません。失敗時と修正後のHTMLを比較し、th:field・BindingResult・エラー時のreturnも確認します。

新人SE
新人SE
入力チェックエラーを表示したら、利用者が入力した値まで空になりました。どこを見ればいいですか?
ポンコツSE
ポンコツSE
Formの受け取り方だけでなく、エラー時のreturn、th:field、選択肢の再設定を順番に確認しましょう。

登録画面で入力エラーが起きたとき、利用者が入力した内容は残したまま、該当項目の近くにエラーメッセージを表示するのが一般的です。ところが、Controllerから画面へ戻した途端に値が消えたり、プルダウンだけ空になったりすることがあります。

社員登録で「氏名は残るのに、選んだ部署だけ初期状態へ戻る」場合は、Formを作り直す前に選択肢を確認します。まず氏名=山田太郎・部署=DEV・メール=invalidをPOSTした失敗HTMLと1行の修正を確認します。その後に追試用のコードと、別の原因を調べる順番を載せます。

この記事のポイント

  • 入力欄はth:objectとth:fieldでFormへ結び付ける
  • BindingResultは検証対象Formの直後へ置く
  • 入力エラー時は同じテンプレートをreturnし、安易にredirectしない
  • プルダウンなどの表示用データは、エラー時にも再設定する

再現と修正: 氏名は残るのに部署の選択が消える

ここではFormの部署コードが残っているのに、画面の部署が初期状態へ戻るケースを再現します。th:fieldが保持する選択値と、th:eachが描画する候補一覧は別のデータです。

失敗例: エラー分岐から部署一覧の設定を外す

後述する最小サンプルのEmployeeController#createで、エラー分岐だけを次の状態にします。初期GETの部署一覧設定はそのまま残します。検証用テストでは、この分岐を持つ別ハンドラへ同じPOSTを送りました。

if (bindingResult.hasErrors()) {
    // departments をModelへ戻し忘れた状態
    return "employees/form";
}

起動中のサンプルへ次を送ります。type="email"によるブラウザ側の入力チェックを通さず、サーバー側で不正メールを受け取ったときの動作を調べるため、直接POSTします。

curl -i http://localhost:8080/employees \
  --data-urlencode 'name=山田太郎' \
  --data-urlencode 'departmentCode=DEV' \
  --data-urlencode 'email=invalid'

応答は200で入力画面を再表示します。Controllerが受け取ったFormのdepartmentCodeはDEV、氏名もメールも残っていました。しかし実際のHTMLは次の状態です。以下はレスポンスから入力欄とselectを抜粋しています。

<input id="name" type="text" name="name" value="山田太郎">
<select id="departmentCode" name="departmentCode">
<option value="">選択してください</option>
</select>
<input id="email" type="email" name="email" value="invalid">
<p id="email-error">メールアドレスの形式を確認してください</p>

value="DEV"のoption自体がありません。選択肢が「選択してください」1件だけなので、FormにDEVが入っていても「開発」を選択表示できません。この段階でFormを空にしたり、selectedを手書きしたりする修正は不要です。

最小修正: 同じFormを残して候補一覧を1行戻す

if (bindingResult.hasErrors()) {
    model.addAttribute("departments", departments());
    return "employees/form";
}

この1行を戻して再起動し、同じPOSTを再送します。DBなどから部署を取得する実装でも、初期表示と同じ候補一覧をエラー再表示へ渡す、という修正点は同じです。

成功確認: 値・選択状態・候補・エラーをHTMLで見る

<input id="name" type="text" name="name" value="山田太郎">
<select id="departmentCode" name="departmentCode">
<option value="">選択してください</option>
<option value="DEV" selected="selected">開発</option>
<option value="SALES">営業</option>
</select>
<input id="email" type="email" name="email" value="invalid">
<p id="email-error">メールアドレスの形式を確認してください</p>

氏名と不正メールのvalueが残り、DEVのoptionだけにselected="selected"が付いています。SALESも含めて選択肢が3件あり、メール欄にはエラー文が出ています。HTTP 200やFormの中身だけで完了にせず、このHTMLまで確認します。

確認対象修正前修正後
Formの部署コードDEVDEV
氏名・メールの入力値山田太郎 / invalid山田太郎 / invalid
optionの値空文字のみ空文字 / DEV / SALES
selectedの付いたoptionなしDEVだけ
メールのエラー文表示あり表示あり

この検証は実際にThymeleafで描画したレスポンスをJsoupで解析しています。失敗再現・修正後・初期GET・正常POST・BindingResultの順序違い2パターンの計6テストが通りました。正常なPOSTは完了URLへredirectし、不正なPOSTは入力画面のままになることも確認しています。

初期表示とエラー分岐で同じ候補取得が重複する場合は、画面で常に必要なデータかを確認したうえで@ModelAttribute付きメソッドへまとめられます。まずは上記の1行で不具合を直し、共通化はその後に検討すると修正範囲が明確です。値は残るのにエラー文だけ出ない場合は、バリデーションメッセージが表示されないときの確認手順へ進んでください。

追試できる最小サンプル

以下はJava 17.0.8、Spring Boot 3.5.16、Spring Framework 6.2.19、Thymeleaf 3.1.5.RELEASE、Hibernate Validator 8.0.1.Finalで検証した構成です。氏名・部署・メールだけを扱い、部署は固定値で用意します。DBへの登録とSpring Securityはこの再表示サンプルに含めません。メールの@Emailは形式チェック用で、必須入力にはしていません。

Mavenプロジェクトを作り、以下のパスへそれぞれ別ファイルとして保存します。Javaの4ファイルはすべて同じinfo.syachiku.formkeepパッケージです。次の「原因1」に載せるテンプレートも作成してください。

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
  <groupId>info.syachiku</groupId><artifactId>form-keep-demo</artifactId><version>1.0.0</version>
  <properties>
    <java.version>17</java.version>
    <jboss-logging.version>3.5.3.Final</jboss-logging.version>
    <classmate.version>1.6.0</classmate.version>
  </properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
    <dependency><groupId>org.hibernate.validator</groupId><artifactId>hibernate-validator</artifactId><version>8.0.1.Final</version></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.jsoup</groupId><artifactId>jsoup</artifactId><version>1.17.2</version><scope>test</scope></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

上のspring-boot-starter-testとjsoupはHTMLを確認する自動テスト用です。バージョンは今回の検証値を固定しており、既存プロジェクトに取り込む場合はそのプロジェクトの依存管理に合わせます。

src/main/java/info/syachiku/formkeep/FormKeepApplication.java

package info.syachiku.formkeep;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FormKeepApplication {
    public static void main(String[] args) {
        SpringApplication.run(FormKeepApplication.class, args);
    }
}

src/main/java/info/syachiku/formkeep/EmployeeForm.java

package info.syachiku.formkeep;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public class EmployeeForm {
    @NotBlank(message = "氏名を入力してください")
    private String name;

    @NotBlank(message = "部署を選択してください")
    private String departmentCode;

    @Email(message = "メールアドレスの形式を確認してください")
    private String email;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDepartmentCode() { return departmentCode; }
    public void setDepartmentCode(String departmentCode) { this.departmentCode = departmentCode; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

src/main/java/info/syachiku/formkeep/DepartmentView.java

package info.syachiku.formkeep;

public record DepartmentView(String code, String name) {
}

src/main/java/info/syachiku/formkeep/EmployeeController.java

package info.syachiku.formkeep;

import java.util.List;
import jakarta.validation.Valid;
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 org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/employees")
public class EmployeeController {
    private List<DepartmentView> departments() {
        return List.of(new DepartmentView("DEV", "開発"),
                       new DepartmentView("SALES", "営業"));
    }

    @GetMapping("/new")
    public String showForm(Model model) {
        model.addAttribute("employeeForm", new EmployeeForm());
        model.addAttribute("departments", departments());
        return "employees/form";
    }

    @PostMapping
    public String create(
            @Valid @ModelAttribute("employeeForm") EmployeeForm form,
            BindingResult bindingResult, Model model) {
        if (bindingResult.hasErrors()) {
            model.addAttribute("departments", departments());
            return "employees/form";
        }
        // 再表示の検証用サンプルのため、DBへの登録は省略しています。
        return "redirect:/employees/complete";
    }

    @GetMapping("/complete")
    @ResponseBody
    public String complete() {
        return "入力チェックを通過しました(DBへの登録は行っていません)";
    }
}

起動はプロジェクト直下で次を実行し、http://localhost:8080/employees/newを開きます。Controllerはエラー時に受け取ったFormと検証結果を残し、部署一覧を補って同じテンプレートへ戻します。

mvn spring-boot:run

原因1: th:objectとth:fieldがForm名に合っていない

ControllerがemployeeFormという名前でFormを渡しているなら、HTML側も同じ名前を参照します。

src/main/resources/templates/employees/form.html

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>社員登録サンプル</title></head>
<body>
<form th:action="@{/employees}" th:object="${employeeForm}" method="post">
    <label for="name">氏名</label>
    <input id="name" type="text" th:field="*{name}">
    <p th:errors="*{name}"></p>

    <label for="departmentCode">部署</label>
    <select id="departmentCode" th:field="*{departmentCode}">
        <option value="">選択してください</option>
        <option th:each="department : ${departments}"
                th:value="${department.code}" th:text="${department.name}"></option>
    </select>
    <p th:errors="*{departmentCode}"></p>

    <label for="email">メールアドレス</label>
    <input id="email" type="email" th:field="*{email}">
    <p id="email-error" th:errors="*{email}"></p>
    <button type="submit">登録</button>
</form>
</body>
</html>

th:field="*{name}"は、th:objectで指定したFormのnameプロパティと入力欄を結び付けます。手書きのname属性とth:valueを混在させると、エラー時にどの値が優先されるのか分かりにくくなります。

<!-- 避けたい例: Form名と参照名が一致していない -->
<form th:object="${form}">
    <input name="employeeName" th:value="${employeeForm.name}">
</form>

Controllerの@ModelAttribute名、th:object、th:fieldの3点を一続きで確認します。

原因2: BindingResultの位置が間違っている

BindingResultは、検証対象のForm引数の直後に置きます。今回の構成でForm, Model, BindingResultの順にすると、不正メールのPOSTはハンドラ本体へ入らずMethodArgumentNotValidExceptionによる400になりました。一方、正常なメールでは200で本体へ到達しました。「順番が違うと全リクエストが必ず失敗する」とは判断せず、不正入力で確かめます。

// 正しい並び
public String create(
        @Valid @ModelAttribute("employeeForm") EmployeeForm form,
        BindingResult bindingResult,
        Model model) {
    // ...
}

// 間にModelが入り、BindingResultがFormの直後ではない
public String create(
        @Valid @ModelAttribute("employeeForm") EmployeeForm form,
        Model model,
        BindingResult bindingResult) {
    // ...
}

複数のFormを受け取る場合は、それぞれの直後に対応するBindingResultを置きます。引数が増えすぎたControllerは読み違いも起きやすいため、入力の責務自体を見直すことも必要です。

原因3: エラー時にredirectしている

redirectはブラウザへ別のリクエストを送らせます。そのため、POST時のFormやBindingResultは通常のModelのままでは次のGETへ引き継がれません。

if (bindingResult.hasErrors()) {
    // 入力値とエラー情報が次のGETへそのまま残らない
    return "redirect:/employees/new";
}

入力エラー時は同じテンプレートをreturn "employees/form"で再表示し、登録成功後は二重送信を防ぐためにredirectする、という使い分けが基本です。登録成功後にPOSTからredirectを返し、GETへ移る流れがPost/Redirect/Get(PRG)です。

場面戻り方理由
入力エラー同じテンプレートをreturnFormとBindingResultを使って再表示する
登録成功完了画面などへredirect再読み込みによる二重送信を防ぐ

RedirectAttributesのFlash Attributeで値を渡す方法もありますが、通常の入力エラー表示を複雑にしてまでredirectする必要があるかを先に考えます。特別な画面遷移要件がなければ、エラー時は直接returnする方が分かりやすい実装です。

原因4: 新しいFormで上書きしている

エラー時に空のFormをModelへ入れ直す処理があれば見直します。入力済みオブジェクトを差し替えると、Formと検証結果の対応を崩す原因になります。再表示では受け取ったFormをそのまま使います。

if (bindingResult.hasErrors()) {
    // 入力済みFormを空のFormで上書きしてしまう
    model.addAttribute("employeeForm", new EmployeeForm());
    return "employees/form";
}

@ModelAttributeで受け取ったFormはすでにModelへ追加されるため、通常は再登録不要です。初期表示のGETと、入力エラー時のPOSTを同じ感覚で書かないようにします。

チェックボックスとラジオボタンもth:fieldで結ぶ

以下は応用の断片です。今回の3項目サンプルとは別に、Formへagreed(boolean)とemploymentType(String)のプロパティ・getter/setterを追加し、employmentTypesの候補一覧をModelへ渡す画面を想定しています。

<label>
    <input type="checkbox" th:field="*{agreed}">
    利用規約に同意する
</label>

<label th:each="type : ${employmentTypes}">
    <input type="radio"
           th:field="*{employmentType}"
           th:value="${type.code}">
    <span th:text="${type.name}"></span>
</label>

チェック状態を独自のth:checkedで組み立てるより、Formのプロパティへ結び付ける方が再表示時の状態を保ちやすくなります。複数選択ならForm側の型がList<String>など、画面仕様に合っているかも確認します。

原因を切り分ける確認順

  1. ブラウザのリクエストで、入力値がPOSTされているか確認する
  2. Controller受け取り直後のFormに値が入っているか確認する
  3. BindingResult#getFieldValueとエラー内容をデバッガーで確認する
  4. エラー時がredirectではなくテンプレートreturnになっているか確認する
  5. th:object名とth:fieldのプロパティ名を確認する
  6. プルダウンやラジオボタンの候補一覧がModelにあるか確認する

「Thymeleafがおかしい」と決め付けず、ブラウザ、Controller、Model、テンプレートの境界を順番に追うと、値が消えた場所を特定できます。

現場レビューでよくある指摘

// レビューコメント例
入力エラー時にredirectしているため、BindingResultが失われます。
同じテンプレートをreturnし、成功時だけredirectしてください。

// レビューコメント例
部署一覧は初期表示時だけModelへ設定されています。
エラー再表示時にも同じ一覧を設定してください。

// レビューコメント例
th:valueを個別に指定していますが、この画面はFormバインドが前提です。
th:objectとth:fieldへ統一し、再表示時の値を確認してください。

提出前のセルフチェック

  • @ModelAttribute名とth:object名が一致しているか
  • 入力項目をth:fieldでFormへ結び付けているか
  • BindingResultが検証対象Formの直後にあるか
  • 入力エラー時に同じテンプレートをreturnしているか
  • 選択肢や表示補助データを再設定しているか
  • 不正なPOSTのHTMLに入力値・選択値・候補一覧・エラー文が揃っているか
  • 成功時はredirectし、二重送信を防いでいるか

Spring MVCの画面処理を整理する参考書

Form、Model、Controller、テンプレートのつながりを一度通して学ぶと、画面固有の不具合も切り分けやすくなります。Spring Bootの入門書を手元に置き、実装中の章へ戻れるようにしておくと便利です。

書籍「作って学ぶ Spring Boot入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

作って学ぶ Spring Boot入門

多田 真敏 (著)

Spring Bootを、動くWebアプリを作りながら覚える。

Controller、画面、DB連携などを一つのアプリとして実装できます。Java基礎からSpringの現場へ進む橋渡しに向く一冊です。

  • Spring Bootを手を動かして学びたい
  • Webアプリ開発の流れをつかみたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事BindingResultの使い方と注意点|@Validの直後に置く理由 @Validとの正しい引数順、エラー判定、画面への戻し方を詳しく確認できます。 記事を読む 関連記事Spring Bootの@ModelAttributeとは?Formに値が入る仕組みと使い方 リクエスト値がFormへ入り、Modelとして画面へ渡る流れを整理します。 記事を読む 関連記事Spring BootのFormとDTOの違い|Serviceへ渡すクラスを分ける判断 画面入力用Formと、層間でデータを渡すDTOを混同しない基準を解説します。 記事を読む

仕様の確認に使った公式資料

selectのth:fieldとoptionのth:valueの対応はThymeleaf公式チュートリアルのDropdown/List selectorsを確認できます。Formの直後にBindingResultを置いてエラーを処理する形は、Spring Framework公式の@ModelAttributeに記載されています。

まとめ

Thymeleafで入力エラー後に値が消えるときは、FormだけでなくBindingResultと画面表示用データを含めて確認します。@ModelAttribute名、th:object、th:fieldを揃え、BindingResultをFormの直後へ置くのが基本です。

入力エラー時は同じテンプレートをreturnし、登録成功時にredirectします。プルダウンだけ消える場合は選択肢一覧の再設定も確認してください。データがブラウザ、Controller、Model、テンプレートのどこまで届いているかを順番に追えば、原因を切り分けられます。

再表示を直した後に、入力制約・Serviceへの受け渡し・テストも見直すなら、Spring Bootのフォーム入力・バリデーション実践ガイドで全体の確認順を整理できます。

関連記事Spring Bootのフォーム入力・バリデーション実践ガイド|実装からテストまで フォームを作って終わりにしない。入力の約束からエラー時の再表示、Serviceの業務判断、MockMvcでの確認まで、既存記事を読む順と確認する問いをまとめました。 記事を読む

-実装・エラー解決
-,