Tier 3 · SpecializedRecipe #11

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.)

Example 1 — Laravel / Rails (arrays of strings)
'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"
}
error-mapper.ts — Shared mapper for this API
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
}
registration-form.tsx — Using the custom mapper
'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 | null

A 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 component

The 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 actionResult

Errors 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 defaultErrorMapper

You 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 of null

    The hook checks if the returned object is truthy and has keys. An empty object {} is truthy, but all its field lookups return undefined. Return null or undefined explicitly when there are no errors to map.

  • Inline arrow function as errorMapper

    errorMapper: (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

    errorMapper is called after every action invocation, including successful ones. Always add a guard (e.g. check for the presence of an error key) and return null for success responses.

Related