actfields
v0.1.0
Published
Field-level helpers for React 19 Actions and useActionState.
Downloads
188
Maintainers
Readme
actfields
Field-level helpers for React 19 Actions and useActionState.
Why
React 19's useActionState gives you form-level state after a server action completes. What it doesn't give you is per-field ergonomics: which field has an error, what value should be shown after a failed submit, which ARIA attributes need to be set.
react-hook-form solves this but brings a controlled-input model, a schema abstraction layer, and 24 kB min+gz. If you're just wiring a form to a React 19 Action, that's overkill.
actfields is the missing middle: a ~1 kB wrapper that turns your action's return value into ready-to-spread field props.
Install
npm install actfields
# react >= 19 required as a peer depQuickstart with Zod
app/actions.ts (server component or 'use server' file)
'use server'
import { z } from 'zod'
import { actionResult } from 'actfields'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export async function loginAction(prev, formData) {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return actionResult(
Object.fromEntries(formData),
parsed.error.flatten().fieldErrors,
)
}
await authenticate(parsed.data)
return actionResult(parsed.data)
}app/LoginForm.tsx (client component)
'use client'
import { useActionFields } from 'actfields'
import { loginAction } from './actions'
export function LoginForm() {
const { state, formAction, pending, fields } = useActionFields(loginAction, {
values: {},
errors: {},
})
return (
<form action={formAction}>
<input type="email" {...fields.email} />
{fields.email.error && (
<p id={fields.email['aria-describedby']}>{fields.email.error}</p>
)}
<input type="password" {...fields.password} />
{fields.password.error && (
<p id={fields.password['aria-describedby']}>{fields.password.error}</p>
)}
{state.formError && <p role="alert">{state.formError}</p>}
<button disabled={pending}>{pending ? 'Signing in…' : 'Sign in'}</button>
</form>
)
}API Reference
useActionFields(action, initial)
React hook. Client-side only.
function useActionFields<T>(
action: (prev: ActionState<T>, formData: FormData) => Promise<ActionState<T>>,
initial: ActionState<T>,
): {
state: ActionState<T>
formAction: (formData: FormData) => void
pending: boolean
fields: { [K in keyof T]: FieldHelpers }
}state— the currentActionState, same shape as what your action returns.formAction— pass directly to<form action={formAction}>.pending—truewhile the action is in flight.fields— a proxy object. Accessfields.myFieldto get helpers for that field:name— the field name string (for uncontrolled inputs).defaultValue— the value fromstate.values(preserves input after failed submit).error— first error string, orundefined.aria-invalid—truewhen an error is present, absent otherwise.aria-describedby—"${name}-error"when an error is present, absent otherwise.
actionResult(values, errors?)
Server-side (or client-side) helper. Shapes the return value so types align.
function actionResult<T>(
values: Partial<T>,
errors?: FieldErrors<T>,
): ActionState<T>Types
type FieldErrors<T> = Partial<Record<keyof T, string | string[]>>
type ActionState<T> = {
values: Partial<T>
errors: FieldErrors<T>
formError?: string
}
type FieldHelpers = {
name: string
defaultValue: string | number | undefined
error?: string
'aria-invalid'?: true
'aria-describedby'?: string
}Recipes
Access the full error array
fields.email.error returns the first string. For all errors, read state.errors.email directly:
{Array.isArray(state.errors.email) &&
state.errors.email.map((e) => <p key={e}>{e}</p>)}Form-level error
Return formError from your action:
// in your action
return { values: {}, errors: {}, formError: 'Invalid credentials' }Then render it:
{state.formError && <p role="alert">{state.formError}</p>}Async validation (e.g. check username availability)
Run the check inside your action before returning actionResult. The lib doesn't care — it just renders what the action returns.
Optimistic UI
Use React 19's useOptimistic alongside useActionFields. They compose naturally since formAction is just a function.
Works with Next.js?
Yes. Pass Server Actions directly to useActionFields. The action parameter type matches Next.js's Server Action signature.
Works without a framework?
Yes. Point action at any async function that takes (prevState, formData) and returns ActionState<T>. Works in Vite, CRA, or bare React.
License
MIT
