DEV Community

Kamran Khan
Kamran Khan

Posted on

My React forms expect exactly one error shape: the 422 contract between Laravel and React

I used to handle API errors in React the way most people do: one big try/catch, one generic toast, done. It worked, and every form looked like it was built by someone who had given up.

The fix wasn't a library. It was a contract: Laravel always returns validation failures the same way, so React can always render them the same way.

On the backend, FormRequests do the work. When validation fails, Laravel responds with HTTP 422 and a JSON body shaped like this:

{
  "message": "The email field is required. (and 1 more error)",
  "errors": {
    "email": ["The email field is required."],
    "password": ["The password must be at least 8 characters."]
  }
}
Enter fullscreen mode Exit fullscreen mode

That shape never changes, on any endpoint. That's the whole contract.

On the frontend, my registration form keeps one state object:

const [errors, setErrors] = useState({});
Enter fullscreen mode Exit fullscreen mode

And the catch block is a single if/else:

catch (err) {
  if (err.response?.status === 422 && err.response.data?.errors) {
    setErrors(err.response.data.errors);
  } else {
    setErrors({ general: err.response?.data?.message || 'Registration failed. Please try again.' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Each input renders its own error:

{errors.email && <p className="mt-1 text-xs text-rose-600">{errors.email[0]}</p>}
Enter fullscreen mode Exit fullscreen mode

The input border turns rose via a tiny inputClass(field) helper. And the odd-shaped failures — network down, server 500, a route that doesn't exist — collapse into errors.general, the single banner at the top of the form.

Two deliberate choices worth calling out:

  1. I only take the first error per field (errors.email[0]). Showing three stacked messages under one input looks broken. Laravel orders them by rule priority, and the first one is almost always the one that matters.

  2. Non-422 failures never pretend to be validation errors. A 500 doesn't become "the email is invalid" — it becomes a banner. This is the thing most form code gets wrong: it maps every failure onto the same red box and lies to the user about what happened.

The admin user-edit form in my kit uses the exact same pattern — setFormErrors(err.response.data.errors) on 422, general otherwise. One contract, copied across every form, zero decisions left to make per page.

If you're starting a new Laravel + React project and want to skip wiring all of this yourself — the 422 contract, the AuthContext, the token interceptor — I sell a starter kit that has it done: https://kamranofficial.gumroad.com/l/mhekoig. It's $19, one-time, and it's the same code I copy into client projects.

Top comments (0)