In modern full-stack development, client-side validation gives users instant feedback, but it must never be your only line of defense. Client checks can easily be bypassed using tools like cURL or Postman.
A secure, production-grade application requires robust server-side validation paired with clean, structured error feedback to the client.
In this tutorial, we will connect a React Hook Form frontend to a Spring Boot REST API using Jakarta Bean Validation (@Valid), centralize error handling with @RestControllerAdvice, and dynamically map backend field errors into React Hook Form using setError().
1. Architecture Overview
text [ React Hook Form ] [ Spring Boot Backend ] | | | --- POST /api/users (Payload) -------------> | (Validates DTO via @Valid) | | | [ Validation Fails ] | | | MethodArgumentNotValidException | | | @RestControllerAdvice | | | <--- 400 Bad Request { field: message } ---- | | | (Calls setError() to display messages) 2. Spring Boot 3 Backend Setup Dependencies (pom.xml) Ensure you have the Spring Boot Validation starter: XML <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> The Request DTO (UserRegistrationDto.java) Define validation rules using standard Jakarta constraints: Java package com.example.demo.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public class UserRegistrationDto { @NotBlank(message = "Username is required") @Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters") private String username; @NotBlank(message = "Email address is required") @Email(message = "Please provide a valid email address") private String email; @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least 8 characters long") private String password; 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 String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } Centralized Error Handler (GlobalExceptionHandler.java) Intercept MethodArgumentNotValidException and transform the validation errors into a clean key-value map (fieldName: errorMessage): Java package com.example.demo.exception; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> handleValidationExceptions( MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getAllErrors().forEach((error) -> { String fieldName = ((FieldError) error).getField(); String errorMessage = error.getDefaultMessage(); errors.put(fieldName, errorMessage); }); return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); } } The Controller (UserController.java) Use @Valid to enforce validation before controller logic runs: Java package com.example.demo.controller; import com.example.demo.dto.UserRegistrationDto; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/api/users") @CrossOrigin(origins = "http://localhost:3000") public class UserController { @PostMapping public ResponseEntity<?> registerUser(@Valid @RequestBody UserRegistrationDto userDto) { return ResponseEntity.ok(Map.of("message", "User registered successfully!")); } } 3. React Frontend Integration Install Axios and React Hook Form: Bash npm install axios react-hook-form Registration Form Component (RegistrationForm.jsx) When an HTTP 400 response is returned, iterate through the server error object and assign each error message to its corresponding field with setError: JavaScript import React from 'react'; import { useForm } from 'react-hook-form'; import axios from 'axios'; export default function RegistrationForm() { const { register, handleSubmit, setError, formState: { errors, isSubmitting }, reset, } = useForm(); const onSubmit = async (data) => { try { const response = await axios.post('http://localhost:8080/api/users', data); alert(response.data.message); reset(); } catch (err) { if (err.response && err.response.status === 400) { const serverErrors = err.response.data; // Map backend errors dynamically into React Hook Form Object.keys(serverErrors).forEach((field) => { setError(field, { type: 'server', message: serverErrors[field], }); }); } else { alert('An unexpected error occurred. Please try again.'); } } }; return ( <div style={{ maxWidth: '400px', margin: '40px auto', fontFamily: 'sans-serif' }}> <h2>Create an Account</h2> <form onSubmit={handleSubmit(onSubmit)} noValidate> {/* Username */} <div style={{ marginBottom: '16px' }}> <label style={{ display: 'block', marginBottom: '4px' }}>Username</label> <input type="text" {...register('username')} style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }} /> {errors.username && ( <span style={{ color: 'red', fontSize: '12px' }}>{errors.username.message}</span> )} </div> {/* Email */} <div style={{ marginBottom: '16px' }}> <label style={{ display: 'block', marginBottom: '4px' }}>Email</label> <input type="email" {...register('email')} style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }} /> {errors.email && ( <span style={{ color: 'red', fontSize: '12px' }}>{errors.email.message}</span> )} </div> {/* Password */} <div style={{ marginBottom: '16px' }}> <label style={{ display: 'block', marginBottom: '4px' }}>Password</label> <input type="password" {...register('password')} style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }} /> {errors.password && ( <span style={{ color: 'red', fontSize: '12px' }}>{errors.password.message}</span> )} </div> <button type="submit" disabled={isSubmitting} style={{ padding: '10px 16px', backgroundColor: '#0070f3', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', }} > {isSubmitting ? 'Submitting...' : 'Register'} </button> </form> </div> ); } Key Takeaways DTO-Level Validation: Applying validation annotations directly to DTOs decouples business rules from database entity models. Standardized Payloads: Returning a { fieldName: errorMessage } map keeps the API contract clean and straightforward to consume. Seamless UI Integration: React Hook Form’s setError() updates the form state directly without manual re-render workarounds or custom state arrays. 