Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

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.
πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.