Tier 3 Β· SpecializedπŸš€ StandaloneRecipe #12

Standalone β€” Vite, Remix & Custom APIs

Use hookform-action-standalone with any async function β€” fetch, axios, tRPC, or a custom client β€” in apps that don't use Next.js Server Actions.

Standalone Guide β†’

Why it matters

Not every React app uses Next.js or Server Actions. Vite SPAs, Remix apps, Astro islands, and React Native projects all need the same ergonomics β€” typed validation, automatic error mapping, optimistic UI β€” but with a plain async function as the submit handler.

The standalone adapter exposes exactly the same hook API. The only difference is: instead of passing a Server Action as the first argument, you pass an options object with a submit function. Everything else β€” schema, optimisticData, persistKey, onSuccess, errorMapper β€” works identically.

Full Example β€” Login Form (Vite SPA)

api.ts β€” Typed fetch wrapper
// A reusable fetch wrapper that returns a typed result
// (replace with axios, ky, or your preferred client)

export interface LoginResult {
  success?: boolean
  token?: string
  errors?: {
    email?: string[]
    password?: string[]
  }
  message?: string
}

export async function loginApi(data: {
  email: string
  password: string
}): Promise<LoginResult> {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // Add auth headers here if needed: Authorization: `Bearer ${token}`
    },
    body: JSON.stringify(data),
  })

  // Treat non-2xx as a structured error result, not a thrown error
  if (!res.ok) {
    const body = await res.json().catch(() => ({}))
    return {
      errors: body.errors ?? { email: ['Login failed. Please try again.'] },
      message: body.message,
    }
  }

  return res.json()
}
login-form.tsx β€” Standalone hook usage
import { useActionForm } from 'hookform-action-standalone'
import { z } from 'zod'
import { loginApi, type LoginResult } from './api'

// Client-side validation schema (same as in Next.js)
const loginSchema = z.object({
  email:    z.string().email('Enter a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm<{ email: string; password: string }, LoginResult>({
    // Key difference from Next.js: pass an options object with 'submit'
    submit: loginApi,

    defaultValues: { email: '', password: '' },
    schema: loginSchema,
    validationMode: 'onChange',

    onSuccess: (result) => {
      if (result.token) {
        localStorage.setItem('token', result.token)
        window.location.href = '/dashboard'
      }
    },
    onError: (result) => {
      if (result instanceof Error) {
        // toast.error('Network error. Please check your connection.')
      }
    },
  })

  if (isSubmitSuccessful) {
    return <p className="text-green-400">Signed in! Redirecting…</p>
  }

  return (
    <form onSubmit={handleSubmit()}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p className="text-red-400">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p className="text-red-400">{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Signing in…' : 'Sign In'}
      </button>
    </form>
  )
}
Bonus β€” axios variant
import axios from 'axios'

// The submit function can throw β€” the hook catches it and fires onError
export async function loginApi(data: { email: string; password: string }) {
  try {
    const { data: result } = await axios.post('/api/auth/login', data)
    return result
  } catch (err) {
    if (axios.isAxiosError(err) && err.response) {
      // Return structured errors instead of throwing
      return err.response.data
    }
    throw err  // Re-throw for unexpected errors (network timeout, etc.)
  }
}

Next.js vs Standalone β€” API Diff

Featurehookform-action (Next.js)hookform-action-standalone
Importhookform-actionhookform-action-standalone
Hook signatureuseActionForm(action, options?)useActionForm({ submit, ...options })
formActionβœ… Available❌ Not available
schema / withZodβœ… Auto-detected from actionβœ… Pass schema option
persistKeyβœ…βœ…
optimisticDataβœ…βœ…
errorMapperβœ…βœ…

Key Concepts

submit: async (data) => TResult

Any async function that receives the validated form data and returns a result. It can call fetch, axios, a tRPC procedure, a GraphQL mutation, or any other async operation. If it throws, the hook catches it and fires onError.

Structured errors vs thrown errors

For HTTP 4xx errors, return a structured result (with errors) instead of throwing. This allows the errorMapper to set field errors correctly. Only throw for unexpected errors (5xx, network failures) β€” those trigger onError with an Error instance.

withZod is not used server-side in standalone

withZod is a wrapper for Next.js Server Actions. In standalone mode, pass the schema via the schema option for client-side validation only. Server-side validation must be implemented inside your submit function or API handler.

⚠️ Pitfalls

  • ⚠

    Throwing on HTTP 4xx errors

    If your fetch wrapper throws on 4xx, the hook receives an Error β€” not a structured result. The errorMapper won't fire for thrown errors. Return a structured object instead so field errors can be set automatically.

  • ⚠

    No formAction in standalone

    The standalone adapter does not expose formAction β€” there is no support for the <form action={...}> pattern without JavaScript. Progressive enhancement requires the Next.js adapter.

  • ⚠

    Inline submit function

    Defining submit as an inline arrow function inside the component creates a new reference on every render. Extract it to module scope or wrap with useCallback to avoid re-initialising the hook.

Related