import axios from 'axios';
export type NormalizedErrors = {
fields: Record<string, string>;
formLevel: string | null;
};
type FieldErrorBody = {
field_errors?: Record<string, string | string[]>;
errors?: Array<{ path: string; message: string }>;
detail?: string;
};
function firstMessage(value: string | string[]): string {
return Array.isArray(value) ? value[0] : value;
}
export function normalizeApiError(err: unknown): NormalizedErrors {
if (!axios.isAxiosError(err)) {
return { fields: {}, formLevel: 'Something went wrong. Please try again.' };
}
if (!err.response) {
return { fields: {}, formLevel: 'Network error — check your connection.' };
}
if (err.response.status !== 422) {
return { fields: {}, formLevel: err.response.statusText || 'Request failed.' };
}
const body = (err.response.data ?? {}) as FieldErrorBody;
const fields: Record<string, string> = {};
if (body.field_errors) {
for (const [path, value] of Object.entries(body.field_errors)) {
fields[path] = firstMessage(value);
}
}
if (Array.isArray(body.errors)) {
for (const { path, message } of body.errors) {
if (!(path in fields)) fields[path] = message;
}
}
return { fields, formLevel: body.detail ?? null };
}
import { useCallback, useState } from 'react';
import { UseFormReturn, Path, FieldValues } from 'react-hook-form';
import { normalizeApiError } from './errors';
export function useServerErrors<T extends FieldValues>(form: UseFormReturn<T>) {
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = useCallback(
(submit: (values: T) => Promise<void>) =>
form.handleSubmit(async (values) => {
setFormError(null);
try {
await submit(values);
} catch (err) {
const { fields, formLevel } = normalizeApiError(err);
const known = new Set(Object.keys(form.getValues()));
const unknown: string[] = [];
let focused = false;
for (const [path, message] of Object.entries(fields)) {
if (!known.has(path.split('.')[0])) {
unknown.push(message);
continue;
}
form.setError(path as Path<T>, { type: 'server', message }, { shouldFocus: !focused });
focused = true;
}
setFormError(formLevel ?? unknown[0] ?? null);
}
}),
[form]
);
const clearServerError = useCallback(
(name: Path<T>) => {
if (form.formState.errors[name]?.type === 'server') form.clearErrors(name);
},
[form]
);
return { formError, handleSubmit, clearServerError };
}
import { useForm } from 'react-hook-form';
import { useServerErrors } from './useServerErrors';
import { api } from './api';
type SignupValues = {
email: string;
password: string;
};
export function SignupForm() {
const form = useForm<SignupValues>({ defaultValues: { email: '', password: '' } });
const { register, formState } = form;
const { formError, handleSubmit, clearServerError } = useServerErrors(form);
const onSubmit = handleSubmit(async (values) => {
await api.post('/signup', values);
});
return (
<form onSubmit={onSubmit} noValidate>
{formError && <p role="alert" className="form-error">{formError}</p>}
<label>
Email
<input
type="email"
{...register('email', { onChange: () => clearServerError('email') })}
/>
{formState.errors.email && <span className="field-error">{formState.errors.email.message}</span>}
</label>
<label>
Password
<input
type="password"
{...register('password', { onChange: () => clearServerError('password') })}
/>
{formState.errors.password && <span className="field-error">{formState.errors.password.message}</span>}
</label>
<button type="submit" disabled={formState.isSubmitting}>
Create account
</button>
</form>
);
}
This snippet shows how a frontend normalizes backend validation errors and surfaces them on the exact fields that caused them, rather than dumping a generic banner. The core problem is that servers describe errors in their own shape — nested paths, arrays of messages, a top-level detail string — while react-hook-form wants a flat map of field name to message plus an optional form-level error. Bridging those two worlds cleanly is what makes a form feel trustworthy.
In errors.ts a discriminated union of NormalizedErrors separates per-field messages from a formLevel fallback. The normalizeApiError function inspects an unknown thrown value using axios.isAxiosError, so it works whether the failure is an HTTP 422 with a structured body, a network error, or an unexpected exception. It supports two common server contracts: a field_errors object keyed by dotted paths and an array of { path, message } entries, collapsing both into Record<string, string>. Dotted paths like address.zip are preserved verbatim because react-hook-form addresses nested fields with that same dot notation, so the mapping stays lossless.
The useServerErrors hook wraps a submit callback and exposes a formError string for anything that cannot attach to a field. On failure it calls normalizeApiError, then uses setError from the form context to place each message on its field with { type: 'server', shouldFocus }, focusing the first offending input. A key detail is that these server errors are transient: clearErrors is wired so that when a user edits a field the stale server message disappears, avoiding the trap where a corrected value still shows the old error. Unknown fields that do not exist in the form fall through to formError instead of being silently dropped.
In SignupForm component the hook is composed with useForm, and each register call is paired with an error message read from formState.errors. Because server and client errors share the same errors object, the JSX renders them identically. The trade-off is a small contract coupling: the frontend must agree with the backend on field naming, which is why normalization is centralized in one place and can be adapted without touching components. This pattern is worth reaching for whenever forms submit to an API that can reject individual fields.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
Share this code
Here's the card — post it anywhere.