Frontend nextjs

useActionState en Next.js: el formulario que funciona sin JS

Un formulario con validación por campo, estado de carga y manejo de errores, sin useState ni onSubmit manual. Y sigue funcionando si el JS aún no cargó.

8 min
React Next.js Server Actions Formularios

Si vienes de construir SPAs con useState + onChange por cada campo + onSubmit con preventDefault() manual, useActionState va a sorprenderte por lo poco que tienes que escribir — y más todavía cuando entiendas que el formulario resultante sigue funcionando incluso si el JavaScript del cliente aún no terminó de cargar.

El hook, en su forma mínima

'use client'
import { useActionState } from 'react'
import { submitCheckout } from '@/actions/checkout.actions'

const initialState = { status: 'idle' as const }

export function CheckoutForm() {
  const [state, formAction, isPending] = useActionState(submitCheckout, initialState)

  return (
    <form action={formAction}>
      {state.status === 'error' && <p>{state.message}</p>}
      <input name="email" type="email" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Procesando...' : 'Confirmar'}
      </button>
    </form>
  )
}

Tres cosas que devuelve el hook, y para qué sirve cada una:

  • state: lo último que retornó tu Server Action — útil para mostrar errores de validación, mensajes de éxito, o cualquier resultado estructurado.
  • formAction: una versión especial de tu Server Action que React sabe conectar directo al action= del <form>.
  • isPending: true mientras la acción está en curso, sin que tengas que manejar tu propio useTransition en paralelo.

El detalle que sorprende: no hay onSubmit, y funciona igual

<form action={formAction}>

Esto no es sintaxis de React reinterpretando un atributo HTML — es el atributo action nativo de un formulario HTML, que el navegador entiende desde siempre (aunque tradicionalmente apuntaba a una URL, no a una función). React 19 y Next.js extendieron ese comportamiento nativo para aceptar una función.

La consecuencia práctica: si por cualquier motivo el JavaScript de tu página tarda en cargar (una conexión lenta, un bundle grande), el formulario igual envía la petición — el navegador hace un submit tradicional al servidor. Pierdes el isPending y la re-renderización sin recarga de página, pero no pierdes la funcionalidad core. Eso es progressive enhancement real, no una promesa de marketing.

Validación por campo, con errores estructurados desde el servidor

La pieza que completa el patrón es devolver errores por campo desde la propia Server Action, típicamente con Zod:

// src/lib/validations/checkout.ts
import { z } from 'zod'

export const checkoutSchema = z.object({
  fullName: z.string().min(3, 'Ingresa tu nombre completo'),
  email: z.string().email('Ingresa un email válido'),
})

export type CheckoutFormState = {
  status: 'idle' | 'error' | 'success'
  errors?: Partial<Record<keyof z.infer<typeof checkoutSchema>, string>>
  message?: string
}
// src/actions/checkout.actions.ts
'use server'
import { checkoutSchema, type CheckoutFormState } from '@/lib/validations/checkout'

export async function submitCheckout(
  _prevState: CheckoutFormState,
  formData: FormData
): Promise<CheckoutFormState> {
  const parsed = checkoutSchema.safeParse({
    fullName: formData.get('fullName'),
    email: formData.get('email'),
  })

  if (!parsed.success) {
    const errors: CheckoutFormState['errors'] = {}
    for (const issue of parsed.error.issues) {
      errors[issue.path[0] as keyof typeof errors] = issue.message
    }
    return { status: 'error', errors, message: 'Revisa los datos del formulario' }
  }

  // ...crear la orden...
  return { status: 'success' }
}

Nota la firma exacta: (prevState, formData) => newState. useActionState pasa el estado anterior como primer argumento automáticamente — no lo llamas tú, React lo hace por ti en cada submit. El segundo argumento, formData, se arma solo con los name de cada input del formulario, sin que tengas que capturar onChange de nada.

Conectando los errores por campo en la UI

<input name="fullName" />
{state.errors?.fullName && <span>{state.errors.fullName}</span>}

<input name="email" type="email" />
{state.errors?.email && <span>{state.errors.email}</span>}

Cada input es no controlado (sin value/onChange) — a diferencia del patrón clásico de React con useState por campo. No lo necesitas: quien procesa el formulario es el servidor, no un handler local. Esto reduce bastante el código comparado con el patrón controlado tradicional, sin perder ninguna capacidad de validación.

Por qué esto sorprende a quien viene de SPA puro

En una SPA tradicional, la idea de que “el formulario funciona sin JavaScript” suena casi contradictoria — toda la aplicación es JavaScript. Pero Next.js con Server Actions invierte el orden de prioridades: el HTML real, servido por el servidor, es la base funcional; React se encarga de mejorar esa base (estados de carga, sin recarga de página, feedback inmediato), no de ser la única forma en que el formulario puede funcionar. Esa es la esencia real de “progressive enhancement”, no solo un término de marketing de hace 15 años que volvió a ser relevante.

Conclusión

useActionState no es solo “una forma más corta de manejar formularios” — es una pieza que conecta HTML nativo, validación estructurada del servidor, y React, sin sacrificar el caso donde el JavaScript todavía no está listo. Si tu formulario actual depende 100% de onSubmit + preventDefault(), vale la pena evaluar si migrar a este patrón te simplifica código y de paso te da resiliencia gratis.

¿Necesitas modernizar los formularios de tu aplicación React/Next.js? Agenda una asesoría técnica gratuita de 15 minutos.

¿Tienes un proyecto en mente?

Convierte tu idea en un producto real

Desarrollo web, aplicaciones a medida y consultoría tecnológica para empresas y startups. Cuéntame tu proyecto y te respondo en menos de 24 horas.

Solicitar presupuesto Ver LinkedIn