Connecting React Hook Form to Spring Boot REST API: Clean DTO Validation & Global Exception Handling
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, pro
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.