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.
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)
// 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()
}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>
)
}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
| Feature | hookform-action (Next.js) | hookform-action-standalone |
|---|---|---|
| Import | hookform-action | hookform-action-standalone |
| Hook signature | useActionForm(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) => TResultAny 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 errorsFor 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 standalonewithZod 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. TheerrorMapperwon't fire for thrown errors. Return a structured object instead so field errors can be set automatically. - β
No
formActionin standaloneThe 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
submitfunctionDefining
submitas an inline arrow function inside the component creates a new reference on every render. Extract it to module scope or wrap withuseCallbackto avoid re-initialising the hook.