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 elinputa la propiedad. Genera automáticamente tres cosas:
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 elth:ifcon#fields.hasErrors(...)es opcional: se usa cuando además se quiere aplicar una clase o un ícono.th:fieldrepuebla elinputcon 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:
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:
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 errores →
return "productos/formulario";(misma vista, sin redirect) - Sin errores →
return "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";
}
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 |