Saltar a contenido

Formularios

Un formulario es el punto donde la aplicación deja de solo mostrar datos y empieza a recibirlos. En Spring MVC con Thymeleaf, ese intercambio siempre sigue el mismo recorrido de cinco pasos:

Paso Quién actúa Qué ocurre
1 Controlador (GET) Prepara un objeto vacío y lo publica en el Model
2 Plantilla Dibuja el formulario enlazado a ese objeto con th:object y th:field
3 Controlador (POST) Recibe los datos con @ModelAttribute y los valida con @Valid
4 Plantilla Si hay errores, vuelve a mostrarse con los valores y los mensajes
5 Controlador Si todo está bien, guarda y redirige

Cada paso se detalla a continuación.


1. El GET prepara el objeto

Antes de dibujar el formulario, el controlador debe poner en el Model el objeto que le servirá de respaldo:

@GetMapping("/productos/nuevo")
public String nuevo(Model model) {
    model.addAttribute("producto", new Producto());
    return "productos/formulario";
}

El objeto llega vacío: sirve para que la plantilla sepa qué campos existen y para conservar los valores cuando el formulario deba volver a mostrarse.

Error muy frecuente

Si el controlador no publica el atributo producto, la plantilla falla con un error del tipo "Neither BindingResult nor plain target object for bean name 'producto' available". El th:object="${producto}" necesita que ese objeto exista en el modelo.


2. th:object y th:field

<form th:action="@{/productos}" th:object="${producto}" method="post">

  <label>Nombre</label>
  <input th:field="*{nombre}">

  <label>Precio</label>
  <input th:field="*{precio}" type="number" step="0.01">

  <button type="submit">Guardar</button>
</form>

Tres atributos hacen todo el trabajo:

  • th:action="@{/productos}" — construye la URL de destino respetando el contexto de la aplicación.
  • th:object="${producto}" — selecciona el objeto del modelo al que pertenece el formulario. A partir de aquí, dentro del <form>, las expresiones *{...} se evalúan respecto a ese objeto: *{nombre} equivale a ${producto.nombre}.
  • th:field="*{nombre}" — enlaza el input a la propiedad. Genera automáticamente tres cosas:
<!-- th:field="*{nombre}" produce: -->
<input id="nombre" name="nombre" value="">

Es decir: el id, el name (que es lo que viaja en la petición y debe coincidir con la propiedad de la clase) y el value (que repuebla el campo con el valor actual del objeto).

Por qué th:field y no name a mano

Escribir name="nombre" a mano funciona, pero obliga a repetir el nombre en tres lugares y no repuebla el valor cuando el formulario vuelve con errores. th:field mantiene los tres sincronizados a partir de una sola fuente: la propiedad de la clase.

Otros tipos de campo

th:field también funciona en select, checkbox y radio, marcando por sí solo la opción que corresponde al valor actual:

<select th:field="*{categoria}">
  <option value="">-- Seleccione --</option>
  <option th:each="c : ${categorias}"
          th:value="${c.id}"
          th:text="${c.nombre}">Categoría</option>
</select>

<input type="checkbox" th:field="*{activo}"> Activo

3. El POST, el enlace de datos y la validación

Del lado del controlador, @ModelAttribute hace lo contrario de th:field: toma los parámetros que llegaron en la petición y los asigna a las propiedades de un objeto Java, convirtiendo tipos por el camino (el texto "120.50" se vuelve un BigDecimal).

@PostMapping("/productos")
public String guardar(
        @Valid @ModelAttribute("producto") Producto producto,
        BindingResult result) {

    if (result.hasErrors()) {
        return "productos/formulario";
    }

    repository.save(producto);
    return "redirect:/productos";
}

Tres piezas trabajan juntas:

Elemento Responsabilidad
@ModelAttribute("producto") Enlaza los campos recibidos al objeto y lo vuelve a publicar en el modelo con esa clave
@Valid Dispara las restricciones declaradas en la clase
BindingResult Recoge los errores encontrados, en lugar de lanzar una excepción

El orden importa

BindingResult debe ir inmediatamente después del objeto validado. Si se coloca otro parámetro en medio, Spring no lo asocia al objeto y la petición falla con una excepción en vez de mostrar los errores en el formulario.

Declarar las restricciones

Las reglas se declaran como anotaciones sobre los atributos de la clase (requieren la dependencia Validation, spring-boot-starter-validation):

public class Producto {

    private Long id;

    @NotBlank(message = "El nombre es obligatorio")
    @Size(max = 100, message = "El nombre no puede superar 100 caracteres")
    private String nombre;

    @NotNull(message = "El precio es obligatorio")
    @DecimalMin(value = "0.0", inclusive = false, message = "El precio debe ser mayor que cero")
    private BigDecimal precio;

    // constructores, getters y setters
}
Anotación Valida que…
@NotNull El valor no sea nulo
@NotBlank El texto no sea nulo ni esté vacío ni sea solo espacios
@Size(min, max) La longitud esté en un rango
@Min / @Max / @DecimalMin El número esté dentro de un límite
@Email El texto tenga formato de correo
@Pattern(regexp) El texto cumpla una expresión regular

4. Mostrar los errores con th:errors

Cuando result.hasErrors() es verdadero, el controlador retorna la misma vista (nunca un redirect, porque en una redirección se perderían tanto los errores como los datos digitados). La plantilla los muestra así:

<label>Nombre</label>
<input th:field="*{nombre}">
<small th:if="${#fields.hasErrors('nombre')}"
       th:errors="*{nombre}">Error del nombre</small>

<label>Precio</label>
<input th:field="*{precio}" type="number" step="0.01">
<small th:errors="*{precio}">Error del precio</small>
  • th:errors="*{nombre}" imprime los mensajes asociados a ese campo. Si el campo no tiene errores, el elemento no se renderiza, así que el th:if con #fields.hasErrors(...) es opcional: se usa cuando además se quiere aplicar una clase o un ícono.
  • th:field repuebla el input con el valor que el usuario había escrito, de modo que no tenga que volver a digitar todo el formulario.

Para resaltar visualmente el campo con error:

<input th:field="*{nombre}"
       th:classappend="${#fields.hasErrors('nombre')} ? 'campo-error'">

Y para listar todos los errores en un solo bloque al comienzo del formulario:

<div th:if="${#fields.hasAnyErrors()}">
  <ul>
    <li th:each="e : ${#fields.allErrors()}" th:text="${e}">Error</li>
  </ul>
</div>

5. Éxito y Post/Redirect/Get

Si no hubo errores, el método guarda y devuelve una redirección:

repository.save(producto);
return "redirect:/productos";

Esto implementa el patrón Post/Redirect/Get (PRG):

Paso Qué pasa
POST El navegador envía el formulario
PROCESAR El servidor enlaza, valida y guarda
REDIRECT El servidor responde 302 apuntando a /productos
GET El navegador solicita el listado, ahora con la URL correcta

Sin PRG, la última petición del navegador seguiría siendo el POST: al recargar la página (F5) el formulario se reenviaría y el producto quedaría duplicado. Con PRG, la última petición es un GET inofensivo.

En resumen:

  • Con erroresreturn "productos/formulario"; (misma vista, sin redirect)
  • Sin erroresreturn "redirect:/productos";

Mensajes de confirmación tras la redirección

Como la redirección genera una petición nueva, el Model se pierde. Para pasar un mensaje al listado se usa RedirectAttributes:

@PostMapping("/productos")
public String guardar(@Valid @ModelAttribute("producto") Producto producto,
                      BindingResult result,
                      RedirectAttributes flash) {

    if (result.hasErrors()) {
        return "productos/formulario";
    }

    repository.save(producto);
    flash.addFlashAttribute("mensaje", "Producto guardado correctamente");
    return "redirect:/productos";
}
<p th:if="${mensaje}" th:text="${mensaje}" class="alerta-exito">Mensaje</p>

Un flash attribute sobrevive exactamente a una redirección y luego desaparece: si el usuario recarga el listado, el mensaje ya no aparece.


Formulario único para crear y editar

No hace falta una plantilla por operación. Como th:field repuebla los valores, la misma vista sirve para ambos casos: la diferencia está en el objeto que el controlador publica.

@GetMapping("/productos/nuevo")
public String nuevo(Model model) {
    model.addAttribute("producto", new Producto());   // objeto vacío → crear
    return "productos/formulario";
}

@GetMapping("/productos/{id}/editar")
public String editar(@PathVariable Long id, Model model) {
    model.addAttribute("producto", repository.findById(id).orElseThrow()); // con datos → editar
    return "productos/formulario";
}

Para que la edición actualice el registro en vez de crear uno nuevo, el id debe viajar de vuelta al servidor en un campo oculto:

<form th:action="@{/productos}" th:object="${producto}" method="post">
  <input type="hidden" th:field="*{id}">
  ...
</form>

Cuando save() recibe un objeto con id, JPA hace un UPDATE; cuando lo recibe sin id, hace un INSERT. Ese comportamiento se explica en CRUD con JPA.


Errores comunes

Síntoma Causa habitual
Neither BindingResult nor plain target object… El GET no publicó el objeto en el Model
El formulario se envía pero los campos llegan nulos El name no coincide con la propiedad, o falta el setter
Los errores nunca se muestran Falta @Valid, o se hizo redirect en lugar de retornar la vista
Al recargar se duplica el registro Falta el redirect: (no se aplicó PRG)
Al editar se crea un registro nuevo Falta el <input type="hidden" th:field="*{id}">
Las anotaciones de validación no hacen nada Falta la dependencia spring-boot-starter-validation