Custom Error Mapper
When your API returns errors in a format different from the default { errors: { field: string[] } }, use a custom errorMapper to translate them into React Hook Form field errors automatically.
Why it matters
APIs designed before this library existed — Laravel backends, Rails APIs, external REST services, tRPC procedures — rarely return errors in the { errors: { field: string[] } } format that defaultErrorMapper expects. Without a custom mapper, the hook cannot automatically set field errors and you end up writing manual setSubmitError calls all over your form components.
The errorMapper option is a pure function defined once per form (or shared across forms hitting the same API). It receives the raw action result and returns a FieldErrorRecord that the hook applies to the correct fields automatically.
Full Example — Laravel-style errors
The external API returns:
{
"message": "The given data was invalid.",
"errors": {
"email": ["The email has already been taken.", "Must be a valid email."],
"password": ["The password must be at least 8 characters."]
}
}(Laravel-style — the errors values are arrays of strings, which happens to match the default format. The next example shows a stricter mismatch.)
'use client'
import { useActionForm } from 'hookform-action-standalone'
import type { FieldErrorRecord } from 'hookform-action-core'
import { submitRegistrationForm } from './api'
// This API shape matches the default exactly — no custom mapper needed!
// { errors: { field: string[] } }
export function RegistrationForm() {
const { register, handleSubmit, formState: { errors, isPending } } =
useActionForm({
submit: submitRegistrationForm,
defaultValues: { email: '', password: '', name: '' },
// defaultErrorMapper handles this format automatically
})
// ...
}A non-standard API (object-per-error format):
{
"fieldErrors": {
"email": [{ "message": "Already taken", "code": "DUPLICATE" }],
"name": [{ "message": "Too short", "code": "MIN_LENGTH" }]
},
"globalError": "Validation failed"
}import type { FieldErrorRecord } from 'hookform-action-core'
interface ApiError {
fieldErrors?: Record<string, Array<{ message: string; code: string }>>
globalError?: string
}
// Define outside components for stability (no re-renders)
export function apiErrorMapper(result: unknown): FieldErrorRecord | null {
if (!result || typeof result !== 'object') return null
const r = result as ApiError
if (!r.fieldErrors) return null
const mapped: FieldErrorRecord = {}
for (const [field, errs] of Object.entries(r.fieldErrors)) {
// Extract just the message strings
mapped[field] = errs.map((e) => e.message)
}
return Object.keys(mapped).length > 0 ? mapped : null
}'use client'
import { useActionForm } from 'hookform-action-standalone'
import { apiErrorMapper } from './error-mapper'
import { submitRegistrationForm } from './api'
interface ApiResult {
fieldErrors?: Record<string, Array<{ message: string; code: string }>>
globalError?: string
userId?: string
}
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isPending, isSubmitSuccessful, actionResult },
} = useActionForm<{ email: string; name: string; password: string }, ApiResult>({
submit: submitRegistrationForm,
defaultValues: { email: '', name: '', password: '' },
// Plug in the custom mapper — runs after every action call
errorMapper: apiErrorMapper,
onError: (result) => {
// Still fires even with custom mapper, good for toasts
if (result instanceof Error) {
console.error('Network error:', result.message)
} else if (result.globalError) {
// toast.error(result.globalError)
}
},
})
return (
<form onSubmit={handleSubmit()}>
<div>
<label>Email</label>
<input type="email" {...register('email')} />
{/* Errors from apiErrorMapper appear here automatically */}
{errors.email && <p className="text-red-400">{errors.email.message}</p>}
</div>
<div>
<label>Name</label>
<input {...register('name')} />
{errors.name && <p className="text-red-400">{errors.name.message}</p>}
</div>
<div>
<label>Password</label>
<input type="password" {...register('password')} />
{errors.password && <p className="text-red-400">{errors.password.message}</p>}
</div>
{/* Show global error from actionResult */}
{actionResult?.globalError && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-sm text-red-400">
{actionResult.globalError}
</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Registering…' : 'Create Account'}
</button>
</form>
)
}Key Concepts
errorMapper: (result) => FieldErrorRecord | nullA pure function that receives the raw action result (typed as TResult) and returns a FieldErrorRecord — a Record<string, string[] | undefined>. Return null or undefined when there are no errors. The hook calls setError on each field automatically.
Define mapper outside the componentThe errorMapper is compared by reference between renders. An inline arrow function creates a new reference every time, causing the hook to re-run unnecessarily. Define it at module scope or wrap with useCallback.
Global errors via actionResultErrors that cannot be mapped to a specific field (e.g. "service unavailable") should be read from formState.actionResult and rendered separately — not through the field error system.
Composing with defaultErrorMapperYou can import and call defaultErrorMapper inside your custom mapper as a fallback for fields that already match the default format, and only override the fields that don't.
⚠️ Pitfalls
- ⚠
Returning an empty object
{}instead ofnullThe hook checks if the returned object is truthy and has keys. An empty object
{}is truthy, but all its field lookups returnundefined. Returnnullorundefinedexplicitly when there are no errors to map. - ⚠
Inline arrow function as
errorMappererrorMapper: (r) => ...creates a new function on every render. Since the hook uses it as a dependency, this can cause performance issues. Always define the mapper at module scope. - ⚠
Mapper receives the result even on success
errorMapperis called after every action invocation, including successful ones. Always add a guard (e.g. check for the presence of an error key) and returnnullfor success responses.