Forms en React 19

Este patrón permite:

  1. Validar los datos client side con Zod y React Hook Forms
  2. Una vez realizada la validación, invocar un server function
  3. Validar de nuevo esos mismos datos pero esta vez server side

Validar en cliente

Para esto usaremos useActionState, React Hook Forms y Zod.

//app.tsx
"use client"
const formSchema = z.object({
	email: z.string().trim().email({
		message: "Invalid email",
	}),
	name: z.string().trim().min(3, {
		message: "Name must be at least 3 characters",
	}),
});
 
export default function App() {
const [state, formAction, isPending] = useActionState(createAdmin, null);
const form = useForm<z.infer<typeof formSchema>>({
 
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
name: "",
},
 
});

Primero usamos useActionState para crear un estado que comunique cliente/servidor y le pasamos como primer parámetro el server action que queremos utilizar.

Después invocamos useForm y le indicamos que infiera el type en base al schema de Zod que hemos declarado en formSchema. Le pasamos el resolver de Zod para integrar su validación con React Hook Form y también los valores por defecto de cada campo.

La parte importante es no invocar directamente la Server Action desde action si queremos que React Hook Form valide antes:

<form
className="flex flex-col gap-2"
onSubmit={form.handleSubmit((values) => {
	startTransition(() => {
		formAction(values);
	});
})}
>

Al crear el formulario, NO le pasamos la server action dentro de la prop action como sería lo habitual según la documentación de NextJS.

En su lugar, creamos un onSubmit en el que vamos a invocar la función handleSubmit que nos devuelve useForm. handleSubmit gestiona la validación y la recolección de datos.

A handleSubmit le pasamos un callback que recibe los valores del formulario una vez realizada la validación.

Dentro de este callback lo que hacemos es llamar al server action que normalmente llamaríamos desde la prop action. Lo hacemos así porque queremos que primero que realice la validación y únicamente cuando esta se ha completado, entonces llamaos al server action.

Y startTransition es una nueva función de React 19 que permite realizar actualizaciones de estado de baja prioridad. Lo usamos porque si tratamos de llamar al server action directamente, React nos tira un error diciendo que estamos ejecutando una función asíncrona fuera de la prop action.

Server Action y Validación en Servidor

Y por último la server action como tal

export async function createAdmin(
prevState: FormActionResponse,
data: { email: string; name: string },
): Promise<FormActionResponse> {
 
const session = await auth();
if (!session || !isSuperAdmin(session.user)) {
	return { type: "error", message: "Unauthorized" };
}
 
const parsed = createAdminSchema.safeParse(data);
 
if (!parsed.success) {
	return {
		type: "error",
		issues: parsed.error?.issues.map((issue) => issue.message),
		message: "Invalid form data",
	};
}
//logica
return { type: "success", message: "Admin Created" };
}

Aquí necesitamos pasarle los dos parámetros que se le envían desde useActionState.

prevState, que es el estado que se recibe desde cliente.
Y data, que normalmente sea un FormData si invocasemos la función desde un action de un form, pero que la hacerlo de esta manera, recibimos directamente un objecto con los campos del formulario.

Dentro de la función nos aseguramos de que la autenticación del usuario sea la adecuada para realizar la función que queremos. Después validamos de nuevo los datos, esta vez server side, y a partir de ahí metemos la lógica que sea.

Integrando todo esto en un Custom Hook

La idea es reutilizar un mismo formulario para CREAR y EDITAR un usuario, ya que ambos formularios tienen los mismos campos. Para ello he creado el siguiente hook.

//use-admin-form.tsx
//El hook recibe dos valores:
// onCloseSubmit, función para cerrar el dialog
// id, en caso de que estemos editando un usuario ya existente
export default function useAdminForm(closeOnSubmit: () => void, id?: string) {
// Si le pasamos una ID, sabemos que queremos editar un usuario ya existente, por lo que la serverAction a ejecutar es Admin. En caso contrario lo que queremos es crear uno nuevo.
const serverAction = id ? editAdmin : createAdmin;
const [state, formAction, isPending] = useActionState(serverAction, null);
//Si recibimos una id, hacemos fetch de los datos del usuario para rellenar el formulario. En caso contrario, devolvemos null. Necesitamos variable isLoading para saber cuándo ha terminado de hacer el query.
const { data: adminData, isLoading } = id
? api.admin.getAdmin.useQuery(id)
: { data: null, isLoading: false };
 
//Inicializamos el formulario con los campos vacíos, ya que useQuery es async y devuelve el resultado DESPUES de inicializar el form
const form = useForm<z.infer<typeof adminSchema>>({
	resolver: zodResolver(adminSchema),
	defaultValues: { id: "", email: "", name: "" },
});
 
// Aquí creamos una función que nos permita invalidar el query de useQuery. De esta forma, cuando actualicemos los datos en BD podemos invalidar los datos viejos para que tRPC realice de nuevo un fetch y obtenga los datos más recientes.
const invalidateQuery = useCallback(
    (id: string) => {
      utils.admin.getAdmin.invalidate(id).catch((err) => {
        console.error(err);
      });
    },
    [utils],
  );
 
//Aqui comprobamos si el query ha devuelto algo y de ser así, reiniciamos el formulario con los datos recibidos
useEffect(() => {
if (adminData) {
	form.reset({
		id: adminData.id,
		email: adminData.email,
		name: adminData.name ?? "",
	});
}
}, [form, adminData]);
 
  
//Aquí escuchamos el state que recibimos de servidor y si ha sido success, mostramos un toast, cerramos el dialog y reseteamos el form.
useEffect(() => {
	if (state?.type === "success") {
		toast({
			description: state.message,
		});
		closeOnSubmit();
		form.reset();
	}
}, [state, form, closeOnSubmit]);
 
  
//Aquí devolvemos la función que ejecuta la server action
const handleFormSubmit = (values: z.infer<typeof adminSchema>) => {
startTransition(() => {
	formAction(values);
});
};
 
 
return { form, formAction, handleFormSubmit, state, isLoading, isPending };
 
}
//admin-form.tsx
type Props = {
  closeOnSubmit: () => void;
  id?: string;
};
export default function AdminForm({ closeOnSubmit, id }: Props) {
  const submitText = id ? "Update" : "Create";
  // Al hook le pasamos:
  // onCloseSubmit, funcion para cerrar dialog
  // id del usuario a editar, en caso de que estemos editando
  
  //El hook nos devuelve:
  
  // - Objeto form de react-hook-forms
  // - El estado que se envía/recibe de servidor.
  // -  El handleFormSubmit, que es la función que se ejecuta después de validar.
  // - isLoading, para saber si los queries necesarios para rellenar el formulario están cargando
  // isPending, para saber si el submit está en proceso y disablear inputs 
  const { form, state, handleFormSubmit, isLoading, isPending } = useAdminForm(
    closeOnSubmit,
    id,
  );
 
  return (
    <>
      {isLoading ? (
        <Spinner />
      ) : (
        <Form {...form}>
          {state?.type === "error" && (
            <Alert type={state.type} message={state.message} />
          )}
          <form
            className="flex flex-col gap-2"
            onSubmit={form.handleSubmit(handleFormSubmit)}
          >
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email Address</FormLabel>
                  <FormControl>
                    <Input placeholder="Email" {...field} />
                  </FormControl>
                  <FormDescription>User Email</FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input placeholder="Name" {...field} />
                  </FormControl>
                  <FormDescription>User Name</FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit" className="w-full" disabled={isPending}>
              {isPending ? <Spinner className="text-white" /> : `${submitText}`}
            </Button>
          </form>
        </Form>
      )}
    </>
  );
}

15-11-2024
Después de darle muchas vueltas creo que finalmente he dado con una de las mejores soluciones para combinar validación cliente y servidor con react hook forms y server actions.

El objetivo es simplificar el flujo y dejar que React Hook Form controle la validación cliente antes de ejecutar la mutación.

Lo primero es descartar el uso de useActionState y usar useTransition