Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Server Actions on onSubmit (Next 13) #10391

Closed
callmedukey opened this issue May 11, 2023 · 57 comments
Closed

Support Server Actions on onSubmit (Next 13) #10391

callmedukey opened this issue May 11, 2023 · 57 comments
Labels
feature request request a feature to be added

Comments

@callmedukey
Copy link

Next Js 13 Server actions are in alpha stages but any chance we can get this going?

For example instead of passing onSubmit with the handleSubmit, perhaps something to pass to formActions instead?

@callmedukey callmedukey added feature request request a feature to be added waiting-up-vote Waiting for votes from the community. labels May 11, 2023
@Moshyfawn
Copy link
Member

Moshyfawn commented May 12, 2023

My understanding is, you can already utilize the useTransition + server action pattern for client-side validation. Other-wise, it's out of RHF responsibility and you can follow the withValidate pattern.

@bluebill1049
Copy link
Member

bluebill1049 commented May 14, 2023

I will give it try later with our new Form component, see if we can make it easier to integrate. https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions#custom-invocation-using-starttransition

@bluebill1049 bluebill1049 added this to In progress in React Hook Form May 14, 2023
@bluebill1049
Copy link
Member

Just had a quick play, I don't think it's possible, server action is based on the server component which hook form requires client side of state from react.

@bluebill1049 bluebill1049 removed the waiting-up-vote Waiting for votes from the community. label May 17, 2023
@bluebill1049 bluebill1049 moved this from In progress to Pause in React Hook Form May 17, 2023
@ThimoDEV
Copy link

Would it be possible to add some sort of react hook form RSC code so we get the validation and a subset of react hook form on the server? Or do I understand it completely wrong?

@JohnVicke
Copy link

Maybe this is a possible solution?

We disable the progressive enhancement, however the action runs on the server and we get input validation in the client and can run server code in action.ts

// app/test/form.tsx

"use client";

import { useTransition } from "react";
import { useForm } from "react-hook-form";

import { action, type FormData } from "./action";

export function Form() {
  const [isPending, startTransition] = useTransition();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit = handleSubmit(data => {
    startTransition(() => {
      action(data);
    });
  });

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" {...register("name", { required: true })} />
      {errors.name && <span>This field is required</span>}
      <label htmlFor="description">Description</label>
      <input type="text" id="description" {...register("description", { required: true })} />
      {errors.description && <span>This field is required</span>}
      <button type="submit" disabled={isPending}>
        Submit
      </button>
    </form>
  );
}
// app/test/action.ts

"use server";

export type FormData = {
  name: string;
  description: string;
};

export async function action(data: FormData) {
  console.log(data);
}
// app/test/page.tsx

import { Form } from "./form";

export default function Page() {
  return <Form />;
}

@bluebill1049
Copy link
Member

bluebill1049 commented May 20, 2023

I guess the new Form component simplify a bit

return (
  <Form onSubmit={({ data }) => {
    startTransition(() => {
      action(data);
    });
  }}>
    <input {...register('input')} />
  </Form>
);

without server action

return (
  <Form action="/api" method="post">
    <input {...register('input')} />
  </Form>
);

@koichik
Copy link

koichik commented Jun 2, 2023

@bluebill1049
I think that RHF can work with Server Actions by just changing the handling of event.preventDefault.

  • do submit: NOT call event.preventDefault
  • cancel submit: call event.preventDefault

Here is a poor POC:

So it would be great if RHF could support this behavior with useServerActionForm or useForm({serverAction: true}), etc.
I have not yet imagined a deep integration including validation errors on the server side, but it would be a good starting point, I think.

@bluebill1049
Copy link
Member

cc @kotarella1110 above

@Moshyfawn
Copy link
Member

I don't think you need to remove preventDefault().

You can pass server actions directly to your client something like

function CreateItemForm() {
  const { handleSubmit } = useForm()

  async function addItem(data) {
    'use server'
    return await db.add('items', data)
  }

  const onSubmit = (data) => addItem(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      ...
    </form>
  )
}

@kotarella1110
Copy link
Member

To support progressive enhancement, it might be a good suggestion to avoid calling event.preventDefault.

@bluebill1049
Copy link
Member

To support progressive enhancement, it might be a good suggestion to avoid calling event.preventDefault.

hmmm, not sure if I am following this one. If js is disabled then do we even need to worry about event.preventDefault?

@Moshyfawn
Copy link
Member

To support progressive enhancement, it might be a good suggestion to avoid calling event.preventDefault.

I'm not sure if the concept of progressive enhancement is currently worth pursuing for a form management library, TBH. It's a more nuanced topic, but from my POV, the most apparent concern regarding RSC and Server Actions is the lack of a comprehensive solution for error handling and tracking additional form states. You still want to have a client form component that can maintain form state like submission status and validity. Additionally, it should be capable of displaying form validation on the page, which, I believe, isn't a thing with the new server stuff ATM.

@Moshyfawn
Copy link
Member

^ a quick note, I'm not trying to sway the conversation away from exploring progressive enhancement; just sharing my perspective on the current state of things with the new server stuff

@kotarella1110
Copy link
Member

kotarella1110 commented Jun 2, 2023

hmmm, not sure if I am following this one. If js is disabled then do we even need to worry about event.preventDefault?

if JS is disabled, there is no need to worry about it. However, if JS is enabled, calling e.preventDefault will prevent the server action from being executed.

function Form() {
  return (
    <form
      // When JS is enabled, server actions will not be executed.
      action={serverAction}
      onSubmit={(event) => {
        event.preventDefault();
      }
    }>
      <input type="text" name="text" defaultValue="" />
      <button>Submit</button>
    </form>
  );
}

@bluebill1049
Copy link
Member

Got it @kotarella1110 personally I do agree with what @Moshyfawn stated above, this library focuses on providing great user experience while leveraging a lot of client-side validation and state management. Progressive enhancement is a great bounce tho.

@kotarella1110
Copy link
Member

kotarella1110 commented Jun 2, 2023

the most apparent concern regarding RSC and Server Actions is the lack of a comprehensive solution for error handling and tracking additional form states.

related links:

@bluebill1049
Copy link
Member

Error handling part almost felt the current Form design is more generic and not vendor locking to any framework

// Send post request with json form data
<Form action="/api" encType="application/json" headers={{ accessToken: 'test' }}> 
  {errors.root?.server.type === 500 && 'Error message'}
  {errors.root?.server.type === 400 && 'Error message'}
</Form>

@Moshyfawn
Copy link
Member

related links:

Ooo, interesting! A hook to track action states.. Granted, it's still hypothetical, it ties into a couple of ideas I had about the API. Lemme sit on that 🤔

@codinginflow
Copy link

codinginflow commented Jun 10, 2023

Maybe this is a possible solution?

We disable the progressive enhancement, however the action runs on the server and we get input validation in the client and can run server code in action.ts

// app/test/form.tsx

"use client";

import {  } from "react";
import { useForm } from "react-hook-form";

import { action, type FormData } from "./action";

export function Form() {
  const [, ] = ();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit = handleSubmit(data => {
    (() => {
      action(data);
    });
  });

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" {...register("name", { required: true })} />
      {errors.name && <span>This field is required</span>}
      <label htmlFor="description">Description</label>
      <input type="text" id="description" {...register("description", { required: true })} />
      {errors.description && <span>This field is required</span>}
      <button type="submit" disabled={}>
        Submit
      </button>
    </form>
  );
}
// app/test/action.ts

"use server";

export type FormData = {
  name: string;
  description: string;
};

export async function action(data: FormData) {
  console.log(data);
}
// app/test/page.tsx

import { Form } from "./form";

export default function Page() {
  return <Form />;
}

What's the point of useTransition here? I know the docs say you should use it but I tried the code without it, and I noticed no difference. On the contrary, not using a transition here allows me to catch any errors and handle them in the UI.

@Moshyfawn
Copy link
Member

What's the point of useTransition here? I know the docs say you should use it but I tried the code without it, and I noticed no difference. On the contrary, not using a transition here allows me to catch any errors and handle them in the UI.

useTransition enables non-blocking Server Mutations. You can omit passing your function to startTransition if it's a simple Server Action

@codinginflow
Copy link

What's the point of useTransition here? I know the docs say you should use it but I tried the code without it, and I noticed no difference. On the contrary, not using a transition here allows me to catch any errors and handle them in the UI.

useTransition enables non-blocking Server Mutations. You can omit passing your function to startTransition if it's a simple Server Action

I read this section of the docs like 10 times already and even tried it out myself. Not wrapping the server action into a startTransition changed nothing. That's why I'm wondering.

@ChrisVilches
Copy link

ChrisVilches commented Jun 15, 2023

@codinginflow (cc @Moshyfawn)

I'm also a bit confused by the docs. I think if you do a mutation (e.g. add an element to a list) and then execute revalidatePath, Next.js will render the new data in the background (i.e. the page function will be executed) and the data will be streamed when it's done, however the time it takes to obtain that data can only be awaited using the startTransition (since any await you use on the action will only wait for the action execution, but not the data to be streamed back to the client, which doesn't happen in the same flow as the action).

I tried putting a sleep in the function that renders the new data (to make the waiting time noticeable), however the weird thing is that only sometimes it waits for the new data. Sometimes the isPending becomes false immediately and the data appears on screen several seconds after (when the sleep ends). Sometimes isPending is true for the entire duration of the sleep. I don't know if this is a Next.js bug or not. (Note that this test has nothing to do with RHF, it could be a vanilla form as well)

(Note: I'm not sure about anything I wrote... I wish docs were more detailed)

@codinginflow
Copy link

the time it takes to obtain that data can only be awaited using the startTransition

Yea, I noticed the same. This doesn't work in like 5-10% of cases for me tho. It's probably a bug.
I also talked to someone else who had a similar setup and isPending switched to false before the page was revalidated. I cloned his project and observed the same behavior but couldn't figure out why.

In earlier versions of Next, the loading.tsx would show while the page revalidated. I have the suspicion that useTransition might also be necessary to avoid showing that loading page in the future.

@fevernova90
Copy link

I'm using the form.trigger() as a temporary fix. Not sure if there's any drawback to this.

  <form
        action={async () => {
          const valid = await form.trigger();
          if (valid) createApplePass({ data: form.getValues() });
        }}
        className="space-y-8"
      >
  </form>

@kotarella1110
Copy link
Member

I've created an experimental PR to explore the best way to support Server Actions within React Hook Form. Your thoughts and ideas are invaluable, so please provide feedback!

#11061

@tferullo
Copy link

tferullo commented Nov 1, 2023

Just for anyone stumbling across this, I was able to get it to work (requires js)

      <form
        action={async (formData: FormData) => {
          const valid = await form.trigger();
          if (!valid) return;
          return serverAction(formData);
        }}
      >

@DeveloperHIT
Copy link

Here is the way I've been using server actions in react-hook-forms:

export async function register(newUser: unknown) {
  // Server-side validation
  const newUserValidation = UserSchema.safeParse(newUser);
  if (!newUserValidation.success) {
    // ...handle error messages
  }

  // ...add new user to database
}
export function NewUserForm() {
  const clientAction = async(formData: FormData) => {
    // Create new user object from form data with .get()
    const newUser = {
      email: formData.get("email"),
      password: formData.get("password")
    };

    // Add client-side validation here
    const result = UserSchema.safeParse(newUser);
    if (!result.success) {
      let errorMessage = "";
      result.error.issues.forEach((issue) => {
        errorMessage += issue.path[0] + " " + issue.message + ". ";
      });
      console.log(errorMessage);
      return;
    }

    // Handle server action and server-side errors
    const response = await register(result.data);
    if(response?.error) {
      console.log(response.error);
      return;
    }
  }

  return (
    <form action={clientAction}>...
  )
}

This way you get client and server-side validation and can handle error messages for both in the client and you avoid having to use trigger or useTranslation, etc.

@yuyakinjo
Copy link

I was able to do what I wanted with this.

const NO_OPERATION = () => {};

<form
  {...(formState.isValid ? { action: serverAction } : { onSubmit: handleSubmit(NO_OPERATION) })}
>

With this, If the form becomes valid, you could use serverAction.
After the first submit, it will resolve the required error when the value is updated.
I'm using shadcn/ui Form component with react-hook-form.
thanks.

@ehsanbahramidev
Copy link

It works correctly (async server actions in on submit of react-hook-form):

'use client'

import { useForm, SubmitHandler } from "react-hook-form"
import { yourServerAction } from "dir-to-your-server-action"

type Inputs = {
    name: string
}

export default function ExampleForm() {
    const {
        register,
        handleSubmit,
        formState: { errors }
    } = useForm<Inputs>()
    const onSubmit: SubmitHandler<Inputs> = async (data) => {
        await yourServerAction(data)
    }
    return (
        <form onSubmit={handleSubmit(onSubmit)} method="POST">
            <input
                id="name"
                {...register('name', { required: true })}
                type="text"
             />

            <button type="submit">Submit</button>
        </form>
    )
}

@itsanishjain
Copy link

the

Yes that's working, but then we lost Progressive Enhancement. Have you found a way to do shadcnUI form with client-side validation and actions

@1Ghasthunter1
Copy link

1Ghasthunter1 commented Jan 1, 2024

Just for anyone stumbling across this, I was able to get it to work (requires js)

      <form
        action={async (formData: FormData) => {
          const valid = await form.trigger();
          if (!valid) return;
          return serverAction(formData);
        }}
      >

One caveat to note: If you're using useFormStatus() to see if anything is pending, you'll temporarily see a true blip as the first part of the action function still causes useFormStatus() to update. Honestly not sure where to go from here; maybe will just debounce the loading although that's a band aid over a gunshot wound.

@ezanglo
Copy link

ezanglo commented Jan 12, 2024

i created this custom hook (based on the answers above), i don't know if there are any implications on this, but this lets me do the same thing as i would do in the onSubmit. This will also make it so the type of the formData is your schema, instead of just FormData

import { FieldValues, SubmitHandler, UseFormProps, UseFormReturn, useForm } from "react-hook-form";

export function useFormAction<TFieldValues extends FieldValues = FieldValues, TContext = any, TTransformedValues extends FieldValues | undefined = undefined>(props?: UseFormProps<TFieldValues, TContext>){
  const form = useForm<TFieldValues, TContext, TTransformedValues>(props)

  const handleAction = async (onAction: SubmitHandler<TFieldValues>) => {
    const valid = await form.trigger();
    if (valid) {
      return onAction(form.getValues());
    }
  };

  return {
    ...form,
    handleAction
  }
}

To use, just replace your useForm with this useFormAction, ideally it should work, please let me know if there are any issues with this

"use client";

import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"

const formSchema = z.object({
  email: z.string().email(),
})

export type EmailCheckFormSchema = z.infer<typeof formSchema>

export function Example() {
  const form = useFormAction<EmailCheckFormSchema>({
      resolver: zodResolver(formSchema),
      defaultValues: {
        email: "",
      },
    })

  return (
    <form action={() => form.handleAction(emailCheck)}>

    </form>
  )
}
"use server";

export async function emailCheck(formData: EmailCheckFormSchema)
{
 //do something
}

@JanJakes
Copy link

JanJakes commented Feb 6, 2024

I think there is a way to keep progressive enhancement working, and it's quite simple.

The idea is to use both action and onSubmit in a way that action would work without JS and onSubmit with JS:

'use client';

import { useFormState } from 'react-dom';
import { useForm } from 'react-hook-form';
import { authenticate } from '../server-actions';

export function AuthForm() {
  const [state, action] = useFormState(authenticate, undefined);
  const form = useForm({
    ...,
    errors: state.errors, // use errors from the server when submitted without JS
  });

  return (
    <form
      action={action} // from React's "useFormState()"
      onSubmit={(event) => {
        const formData = new FormData(
          event.target as HTMLFormElement,
          (event.nativeEvent as SubmitEvent).submitter,
        );

        form.handleSubmit(async () => {
          // execute the server action and sync errors to the form
          const { errors } = await authenticate(state, formData);
          if (errors) {
            form.setError(...);
          }
        })(event);
      }}
    >
      ...
    </form>
  );
}

It's not perfect, as React's useFormStatus will not work with the onSubmit variant, but that's not much of an issue as formState from react-hook-form will report all changes.

@gendaineko2222
Copy link

Hi @ezanglo
I'm trying to use useFormAction with isSubmitting but it doesn't seem to be working as expected.

Here's my code:

'use client';

import { Button } from '@/components/ui/button'
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"

const formSchema = z.object({
  displayName: z.string().min(2).max(50),
  // ...
})

export type MyFormSchema = z.infer<typeof formSchema>

export function Example() {
  const form = useFormAction<EmailCheckFormSchema>({
      resolver: zodResolver(formSchema),
      defaultValues: {
        displayName: '',
        // ...
      },
      mode: 'onChange',
      criteriaMode: 'all',
      shouldFocusError: false,
})

  return (
    <form action={() => form.handleAction(submitMyForm)}>
      // ...
      <Button className='w-full' type='submit' disabled={form.formState.isSubmitting}>
        Submit
      </Button>
    </form>
  )
}
'use server';

export async function submitMyForm(formData: MyFormSchema) {
  await new Promise(resolve => setTimeout(resolve, 10000))
  console.log('done')
}

When I click the submit button, isSubmitting is not set to true.

Any help would be appreciated!

@ezanglo
Copy link

ezanglo commented Feb 10, 2024

@gendaineko2222 since we are using form action, isSubmitting will not work coz i think it only triggers when you pass something to onSubmit. You can however, use "pending" from the useFormState, check the implementation here

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#pending-states

@gendaineko2222
Copy link

@gendaineko2222 since we are using form action, isSubmitting will not work coz i think it only triggers when you pass something to onSubmit. You can however, use "pending" from the useFormState, check the implementation here

https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#pending-states

Thanks for your help! I was able to get it working by using the useFormStatus hook in the Button component.

I was originally trying to use the useFormStatus hook in the parent component, but that didn't work
I hope this helps other people who are having the same problem.

Here is the documentation that helped me:
react.dev: useFormStatus will not return status information for a form rendered in the same component

@robahtou
Copy link

robahtou commented Feb 14, 2024

@gendaineko2222 @ezanglo
The only issue I see with using useFormAction is with the method form.trigger. Although this runs the validation on the form, if you are transforming data in your schema (like in zod with transform method) getValues does not return the transformed data.
As far as I know, the only way to get back the transformed data using RHF is implementing the handleSubmit method.

@ezanglo
Copy link

ezanglo commented Feb 15, 2024

@robahtou thanks for this, i think you can use the schema to parse the result, and get the transformed value.

I have also created a repo to experiment on some of the solutions on this thread.
If you are using shadcn/ui, I have extended the Form Compontent to achieve a better validation based on @yuyakinjo's solution.
https://github.com/ezanglo/reach-hook-form-server-actions

here is the updated useFormAction

import { zodResolver } from "@hookform/resolvers/zod";
import { FieldValues, SubmitHandler, useForm, UseFormProps } from "react-hook-form";
import { z } from "zod";


type UseFormActionProps<TFieldValues extends FieldValues = FieldValues, TContext = any> = UseFormProps<TFieldValues, TContext> & {
	schema: z.Schema<any, any>
}

export function useFormAction<TFieldValues extends FieldValues = FieldValues, TContext = any, TTransformedValues extends FieldValues = TFieldValues>({
	schema,
	...props
}: UseFormActionProps<TFieldValues, TContext>) {
	const form = useForm({
		...props,
		resolver: zodResolver(schema)
	})
	
	const handleAction = async (onAction: SubmitHandler<TFieldValues>) => {
		const valid = await form.trigger();
		if (valid) {
			return onAction(schema.parse(form.getValues()));
		}
	};
	
	return {
		...form,
		handleAction
	}
}

and to use it, just include the schema

const form = useFormAction<UseFormActionFormSchema>({
  schema: formSchema,
  resolver: zodResolver(formSchema),
  defaultValues: {
    email: 'email@example.com',
  },
})

@react-hook-form react-hook-form deleted a comment from stimw Feb 15, 2024
@robahtou
Copy link

@ezanglo thanks for the tip. But now it looks like you have to validate twice, once indirectly with RHF (.trigger) and then explicity with zod (.parse). Not ideal but works.

If you are going to use .parse anyways, why not just do it from the start:

const handleAction = async (onAction: SubmitHandler<TFieldValues>) => {
    const valid = schema.safeParse(form.getValues());

    if (valid.success) {
      return onAction(valid.data);
    }

    // handle errors `valid.error`
  };

and if you continue with this line of thought, in specific use cases regarding server actions, should RHF be used for validation or strictly use zod? Use RHF for other form features but validate explicityly with zod?

@ezanglo
Copy link

ezanglo commented Feb 15, 2024

@robahtou yes, that would be the case, I think the fix should really be from RHF, i saw that even the watch doesn't provide the transformed values, we need a way to get the value from the resolver.
Another way is to do a function outside that would get transformed value, so you can handle it without including inside the hook. something like this:

       const form = useFormAction<UseFormActionFormSchema>({
		schema: formSchema,
		getTransformedValues: (values) => {
			const validatedFields = formSchema.safeParse(values)
			return validatedFields.success ? validatedFields.data : values
		},
		resolver: zodResolver(formSchema),
		defaultValues: {
			email: 'email@example.com',
		},
	})

I agree with the double validation, the problem with manually doing the safeParse is it doesn't actually trigger the errors, so you need to manually set the errors into the form, I am experimenting on that too, you can check the commented code in here
https://github.com/ezanglo/reach-hook-form-server-actions/blob/main/hooks/useFormActions.ts

@robahtou
Copy link

@ezanglo Also, as other have already commented, isSubmitting won't work since it is coupled to the onSubmit variant. Another option is the dual use of action and onSubmit mentioned by @JanJakes. So we're coming to a point where we're explicitly using three different APIs to get our forms working instead of just RHF: 1) RSC w/ server actions, 2) RHF methods, and 3) validation libraries.

The ideal case would be the RHF adopts necessary changes to support server actions (progressive enhancement, etc) and out of the box form state (instead of explicitly coding useFormStatus in userland). Anything shy of that, I'd recommend to imperatively use RHF (use setError) and any advance features, and keep validation separate from RHF. There are too many hoops to jump through to get it to work 'correctly'.

In other words, use action={handleAction} attribute, use useFormState, use useFormStatus, in the action handler explicitly call out the validation (i.e. .safeParse), and explicity set the errors setErrors. You can still use the methods provided by useForm if you need them and you don't have to set resolver since you're explicitly using the validator in the action handler. Anyways, that's my approach for right now.

@hunterbecton
Copy link

hunterbecton commented Apr 15, 2024

I found a solution that does client-side verification while maintaining progressive enhancement.

<form
  ref={formRef}
  action={formAction}
  onSubmit={e => {
    trigger()

    if (formState.isValid) {
      formRef.current?.requestSubmit()
    } else {
      e.preventDefault()
    }
  }}
>

@robahtou
Copy link

hey @hunterbecton. I'm using safari browser with javascript disabled. WIth your example nothing happens when I submit the form. Same on Firefox. Can you share a working code sample?

@hunterbecton
Copy link

Hey @robahtou

I tested it on Safari, and everything worked as expected ( I did not test on Firefox ). My code is below.

LoginForm.tsx

'use client'

import { FC, useEffect, useTransition, useRef } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { LoginFormDataSchema } from '@/lib/validation/auth'
import { useFormState, useFormStatus } from 'react-dom'
import { loginPasswordless, loginGitHub } from '@/lib/actions/authActions'
import { Button } from '@/components/button/Button'
import { toast } from 'react-toastify'
import { TextInput } from '@/components/form/TextInput'
import { GitHubIcon } from '@/components/icon/default/frameless/GitHubIcon'

type LoginFormInputs = z.output<typeof LoginFormDataSchema>

export const LoginForm: FC = () => {
  const formRef = useRef<HTMLFormElement>(null)

  const [state, formAction] = useFormState(loginPasswordless, null)

  const methods = useForm<LoginFormInputs>({
    resolver: zodResolver(LoginFormDataSchema),
    mode: 'onBlur',
    defaultValues: {
      email: '',
      ...(state?.fields ?? {}),
    },
  })

  const { trigger, reset, formState } = methods

  const [isPending, startTransition] = useTransition()

  const onLoginGitHub = () => {
    startTransition(async () => {
      const { status, message } = await loginGitHub()

      if (status === 'error') {
        toast.error(message)
      }
    })
  }

  useEffect(() => {
    if (!state) return

    if (state.status === 'error') {
      toast.error(state.message)
    }

    if (state.status === 'success') {
      toast.success(state.message)
      reset()
    }
  }, [state, reset])

  const SubmitButton = () => {
    const { pending } = useFormStatus()

    return (
      <Button
        disabled={pending}
        type="submit"
        text={pending ? 'Processing...' : 'Continue'}
        className="mt-4 w-full"
        variant="color"
      />
    )
  }

  return (
    <>
      <FormProvider {...methods}>
        <form
          ref={formRef}
          action={formAction}
          onSubmit={e => {
            trigger()
            if (formState.isValid) {
              formRef.current?.requestSubmit()
            } else {
              e.preventDefault()
            }
          }}
          className="mt-8"
        >
          <div>
            <TextInput
              className="w-full"
              label="Email"
              name="email"
              placeholder="Enter your email"
            />
          </div>
          <SubmitButton />
          {state?.status === 'success' && (
            <p className="mt-2 text-xs font-normal text-emerald-600">
              {state.message}
            </p>
          )}
          {state?.status === 'error' && (
            <p className="mt-2 text-xs font-normal text-rose-600">
              {state.message}
            </p>
          )}
          <div className="mt-8 flex items-center gap-2">
            <div className="h-[1.5px] flex-1 bg-white/10"></div>
            <p className="text-sm font-normal text-white">Or continue with</p>
            <div className="h-[1.5px] flex-1 bg-white/10"></div>
          </div>
          <Button
            disabled={isPending}
            type="button"
            text="GitHub"
            className="mt-8 w-full"
            onClick={onLoginGitHub}
            withIcon={true}
            icon={<GitHubIcon className="h-[1.125rem] w-auto" />}
          />
        </form>
      </FormProvider>
    </>
  )
}

authActions.ts

'use server'

import { getErrorMessage } from '@/lib/utils/errorHandler'
import { LoginFormDataSchema } from '@/lib/validation/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { formatEmailToUsername } from '@/lib/utils/formatEmailToUsername'
import { createClient } from '@/lib/supabase/server'

export const loginPasswordless = async (prevState: any, data: FormData) => {
  const formData = Object.fromEntries(data)

  const fields: Record<string, string> = {}

  for (const key of Object.keys(formData)) {
    fields[key] = formData[key].toString()
  }

  const parsed = LoginFormDataSchema.safeParse(formData)

  if (!parsed.success) {
    return {
      fieldErrors: parsed.error.flatten().fieldErrors,
      fields,
    }
  }

  const supabase = createClient()

  try {
    const { error } = await supabase.auth.signInWithOtp({
      email: parsed.data.email,
      options: {
        emailRedirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/callback`,
        data: {
          user_name: formatEmailToUsername(parsed.data.email),
          avatar_url:
            'https://axcfyibnfbkmqbvnrcoa.supabase.co/storage/v1/object/public/avatars/default-avatar.png',
        },
      },
    })

    if (error) {
      throw new Error(error.message)
    }

    return {
      status: 'success',
      message: 'Success! A login link was sent to your email!',
    }
  } catch (error) {
    return {
      status: 'error',
      message: getErrorMessage(error),
      fields,
    }
  }
}

Note that it only works when you call trigger() and read the isValid state from formState.isValid.

@robahtou
Copy link

robahtou commented Apr 18, 2024

@hunterbecton thanks for sharing.
I should of been more clear.
With JS disabled the form still submits because of the action attribute.
What I am unable to observe is the onSubmit function working. Leaving out the Github button and just working with the single input email element, the trigger never gets executed.

When you test on Safari make sure when you disable JS, then quit the browser and start it up again. I tested this again in safari and the onSubmit works with JS enabled but not when it is disabled. 😢

@kilias07
Copy link

kilias07 commented May 2, 2024

Yeah, startTransition works perfect. I've done with this solution.

"use client";

import {
  CalculatorFormSchema,
  calculatorSchema,
} from "@/components/ui/CaluclatorPage/CalculatorSchema";
import { SubmitButton } from "@/components/ui/FormElements/submitButton";

import { zodResolver } from "@hookform/resolvers/zod";
import Container from "@mui/material/Container";
import Stack from "@mui/material/Stack";
import { useEffect, useRef, useTransition } from "react";
import { useFormState } from "react-dom";
import {
  FormProvider,
  RadioButtonGroup,
  TextFieldElement,
  useForm,
} from "react-hook-form-mui";

interface CalculatorFormProps {
  onFormAction: (
    prevState: {
      message: string;
      calculator?: CalculatorFormSchema;
      issues?: string[];
    },
    data: FormData,
  ) => Promise<{
    message: string;
    calculator?: CalculatorFormSchema;
    issues?: string[];
    status?: number;
  }>;
}

export const CalculatorForm = ({ onFormAction }: CalculatorFormProps) => {
  const formRef = useRef<HTMLFormElement>(null);
  const [state, formAction] = useFormState(onFormAction, { message: "test" });
  const [isPending, startTransition] = useTransition();

  const form = useForm<CalculatorFormSchema>({
    resolver: zodResolver(calculatorSchema),
    defaultValues: {
      investitionDescription: "",
    },
  });

  useEffect(() => {
    if (state.status === 200) {
      form.reset();
    }
  }, [state, form]);

  return (
    <Container maxWidth="sm">
      <FormProvider {...form}>
        <form
          action={formAction}
          ref={formRef}
          onSubmit={(evt) => {
            evt.preventDefault();
            startTransition(() => {
              form.handleSubmit(() => {
                formAction(new FormData(formRef.current!));
              })(evt);
            });
          }}
        >
          <div>{state?.message}</div>
          <Stack alignItems="start">
            <TextFieldElement
              name="investitionDescription"
              color="primary"
              label="opis inwestycji"
            />
            <RadioButtonGroup
              label="Typ inwestycji"
              name="investitionType"
              options={[
                {
                  id: "droga",
                  value: "droga",
                  label: "Droga",
                },
                {
                  id: "lotnisko",
                  value: "lotnisko",
                  label: "Lotnisko",
                },
              ]}
            />

            <SubmitButton isPending={isPending} />
          </Stack>
        </form>
      </FormProvider>
    </Container>
  );
};

kdh379 added a commit to kdh379/react-hook-form-server-actions that referenced this issue May 9, 2024
@kdh379
Copy link

kdh379 commented May 9, 2024

I created custom hooks to handle server actions with React Hook Form and Shadcn-ui
It's declarative way to handle form validation and submission, success and error cases.

  1. keep progressive enhancement working
  2. handle actions and form state via useFormState and useFormStatus.

Github Repo: https://github.com/kdh379/react-hook-form-server-actions

useFormAction

"use client";

import { useCallback, useEffect } from "react";
import { FieldValues, useForm, UseFormProps } from "react-hook-form";

import { toast } from "@/components/ui/use-toast";

type UseFormActionProps<TFieldValues extends FieldValues = FieldValues, TContext = any> = UseFormProps<TFieldValues, TContext> & {
  state: ActionState | unknown;
  onSuccess?: () => void;
}

export function useFormAction<TFieldValues extends FieldValues = FieldValues, TContext = any>({
  state,
  onSuccess,
  ...props
}: UseFormActionProps<TFieldValues, TContext>) {
  const form = useForm({
    ...props,
  });

  const handleSuccess = useCallback(() => {
    onSuccess?.();
  // eslint-disable-next-line
  }, []);

  useEffect(() => {
    if( !hasState(state) ) return;
    form.clearErrors();

    switch (state.code) {
    case "INTERNAL_ERROR":
      toast({
        title: "Something went wrong."
        description: "Please try again later.",
        variant: "destructive",
        duration: 5000,
      });
      break;
    case "VALIDATION_ERROR":
      const { fieldErrors } = state;
      Object.keys(fieldErrors).forEach((key) => {
        form.setError(key as any, { message: fieldErrors[key].flat().join(" ") });
      });
      break;
    case "EXISTS_ERROR":
      form.setError(state.key as any, { message: state.message });
      break;
    case "SUCCESS":
      toast({
        title: state.message,
        duration: 5000,
      });
      handleSuccess();
      form.reset();
      break;
    }
    
  }, [state, form, handleSuccess]);
	
  return {
    ...form,
  };
}

const hasState = (state: ActionState | unknown): state is ActionState => {

  if(!state || typeof state !== "object") return false;

  return "code" in state;
};

action.d.ts

type ActionState = 
  | {
      code: "SUCCESS"
      message: string;
    }
    | {
      code: "EXISTS_ERROR";
      key: string;
      message: string;
    }
  | {
      code: "INTERNAL_ERROR";
      err: any;
    }
  | {
    code: "VALIDATION_ERROR";
    fieldErrors: {
      [field: string]: string[];
    };
  };

client side form

"use client";

import { useFormState, useFormStatus } from "react-dom";
import { useFormContext } from "react-hook-form";

import { type FormValues, submitForm } from "@/app/actions";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { useFormAction } from "@/hooks/useFormAction";

export default function ClientSideForm() {

  const [state, formAction] = useFormState(submitForm, null);
  const form = useFormAction<FormValues>({
    state,
    defaultValues: {
      email: "",
      password: "",
    },
    onSuccess: () => {
      toast({
        title: "Form submitted successfully!",
        duration: 5000,
      });
    },
  });

  return (
    <Form {...form}>
      <form
        action={formAction}
      >
        <FormFields />
      </form>
    </Form>
  );
}

function FormFields() {
  const form = useFormContext();
  const { pending } = useFormStatus();

  return (
    <div className="space-y-4">
      <FormField
        control={form.control}
        name="email"
        render={({ field }) => (
          <FormItem>
            <FormLabel className="flex items-center justify-between">
              Email
              <FormMessage />
            </FormLabel>
            <FormControl>
              <Input
                {...field}
                type="email"
                placeholder="abc@abc.com"
                disabled={pending}
              />
            </FormControl>
          </FormItem>
        )}
      />
      <FormField
        control={form.control}
        name="password"
        render={({ field }) => (
          <FormItem>
            <FormLabel className="flex items-center justify-between">
              Password
              <FormMessage />
            </FormLabel>
            <FormControl>
              <Input
                {...field}
                type="password"
                placeholder="********"
                disabled={pending}
              />
            </FormControl>
          </FormItem>
        )}
      />
      <div className="flex justify-end">
        <Button
          isLoading={pending}
        >
          Submit
        </Button>
      </div>
    </div>
  );
}

server actions

"use server";

import { z } from "zod";

const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const EXISTS_USER = [
  "abc@abc.com",
];

export type FormValues = z.infer<typeof formSchema>;

export async function submitForm(
  _prevState: any,
  formData: FormData
): Promise<ActionState | void> {

  console.log("server action!!");

  // Delay for 1 second
  await new Promise((resolve) => setTimeout(resolve, 1000));

  const input = formSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!input.success) {

    const { fieldErrors } = input.error.flatten();

    return {
      code: "VALIDATION_ERROR",
      fieldErrors,
    };
  }

  try {

    if( EXISTS_USER.includes(input.data.email) ) {
      return {
        code: "EXISTS_ERROR",
        key: "email",
        message: "User already exists with this email.",
      };
    }

    // object equality check
    return {
      code: "SUCCESS",
      message: "Form submitted successfully!",
    };
  }
  catch (error) {
    return {
      code: "INTERNAL_ERROR",
      err: error,
    };
  }
}
_20240509_175116.webm

@jack-szeto
Copy link

Hey @robahtou

I tested it on Safari, and everything worked as expected ( I did not test on Firefox ). My code is below.

LoginForm.tsx

'use client'

import { FC, useEffect, useTransition, useRef } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { LoginFormDataSchema } from '@/lib/validation/auth'
import { useFormState, useFormStatus } from 'react-dom'
import { loginPasswordless, loginGitHub } from '@/lib/actions/authActions'
import { Button } from '@/components/button/Button'
import { toast } from 'react-toastify'
import { TextInput } from '@/components/form/TextInput'
import { GitHubIcon } from '@/components/icon/default/frameless/GitHubIcon'

type LoginFormInputs = z.output<typeof LoginFormDataSchema>

export const LoginForm: FC = () => {
  const formRef = useRef<HTMLFormElement>(null)

  const [state, formAction] = useFormState(loginPasswordless, null)

  const methods = useForm<LoginFormInputs>({
    resolver: zodResolver(LoginFormDataSchema),
    mode: 'onBlur',
    defaultValues: {
      email: '',
      ...(state?.fields ?? {}),
    },
  })

  const { trigger, reset, formState } = methods

  const [isPending, startTransition] = useTransition()

  const onLoginGitHub = () => {
    startTransition(async () => {
      const { status, message } = await loginGitHub()

      if (status === 'error') {
        toast.error(message)
      }
    })
  }

  useEffect(() => {
    if (!state) return

    if (state.status === 'error') {
      toast.error(state.message)
    }

    if (state.status === 'success') {
      toast.success(state.message)
      reset()
    }
  }, [state, reset])

  const SubmitButton = () => {
    const { pending } = useFormStatus()

    return (
      <Button
        disabled={pending}
        type="submit"
        text={pending ? 'Processing...' : 'Continue'}
        className="mt-4 w-full"
        variant="color"
      />
    )
  }

  return (
    <>
      <FormProvider {...methods}>
        <form
          ref={formRef}
          action={formAction}
          onSubmit={e => {
            trigger()
            if (formState.isValid) {
              formRef.current?.requestSubmit()
            } else {
              e.preventDefault()
            }
          }}
          className="mt-8"
        >
          <div>
            <TextInput
              className="w-full"
              label="Email"
              name="email"
              placeholder="Enter your email"
            />
          </div>
          <SubmitButton />
          {state?.status === 'success' && (
            <p className="mt-2 text-xs font-normal text-emerald-600">
              {state.message}
            </p>
          )}
          {state?.status === 'error' && (
            <p className="mt-2 text-xs font-normal text-rose-600">
              {state.message}
            </p>
          )}
          <div className="mt-8 flex items-center gap-2">
            <div className="h-[1.5px] flex-1 bg-white/10"></div>
            <p className="text-sm font-normal text-white">Or continue with</p>
            <div className="h-[1.5px] flex-1 bg-white/10"></div>
          </div>
          <Button
            disabled={isPending}
            type="button"
            text="GitHub"
            className="mt-8 w-full"
            onClick={onLoginGitHub}
            withIcon={true}
            icon={<GitHubIcon className="h-[1.125rem] w-auto" />}
          />
        </form>
      </FormProvider>
    </>
  )
}

authActions.ts

'use server'

import { getErrorMessage } from '@/lib/utils/errorHandler'
import { LoginFormDataSchema } from '@/lib/validation/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { formatEmailToUsername } from '@/lib/utils/formatEmailToUsername'
import { createClient } from '@/lib/supabase/server'

export const loginPasswordless = async (prevState: any, data: FormData) => {
  const formData = Object.fromEntries(data)

  const fields: Record<string, string> = {}

  for (const key of Object.keys(formData)) {
    fields[key] = formData[key].toString()
  }

  const parsed = LoginFormDataSchema.safeParse(formData)

  if (!parsed.success) {
    return {
      fieldErrors: parsed.error.flatten().fieldErrors,
      fields,
    }
  }

  const supabase = createClient()

  try {
    const { error } = await supabase.auth.signInWithOtp({
      email: parsed.data.email,
      options: {
        emailRedirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/callback`,
        data: {
          user_name: formatEmailToUsername(parsed.data.email),
          avatar_url:
            'https://axcfyibnfbkmqbvnrcoa.supabase.co/storage/v1/object/public/avatars/default-avatar.png',
        },
      },
    })

    if (error) {
      throw new Error(error.message)
    }

    return {
      status: 'success',
      message: 'Success! A login link was sent to your email!',
    }
  } catch (error) {
    return {
      status: 'error',
      message: getErrorMessage(error),
      fields,
    }
  }
}

Note that it only works when you call trigger() and read the isValid state from formState.isValid.

The works fine. The trigger() runs async.

<form
  ref={formRef}
  action={formAction}
  onSubmit={async (e) => {
    await trigger();
    if (formState.isValid) {
      formRef.current?.requestSubmit();
    } else {
      console.log(`[SurveyForm] form is invalid`, formState.errors);

      e.preventDefault();
    }
  }}
>

@tuon1602
Copy link

Maybe this is a possible solution?

We disable the progressive enhancement, however the action runs on the server and we get input validation in the client and can run server code in action.ts

// app/test/form.tsx

"use client";

import { useTransition } from "react";
import { useForm } from "react-hook-form";

import { action, type FormData } from "./action";

export function Form() {
  const [isPending, startTransition] = useTransition();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit = handleSubmit(data => {
    startTransition(() => {
      action(data);
    });
  });

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" {...register("name", { required: true })} />
      {errors.name && <span>This field is required</span>}
      <label htmlFor="description">Description</label>
      <input type="text" id="description" {...register("description", { required: true })} />
      {errors.description && <span>This field is required</span>}
      <button type="submit" disabled={isPending}>
        Submit
      </button>
    </form>
  );
}
// app/test/action.ts

"use server";

export type FormData = {
  name: string;
  description: string;
};

export async function action(data: FormData) {
  console.log(data);
}
// app/test/page.tsx

import { Form } from "./form";

export default function Page() {
  return <Form />;
}

Do you have solution for triggering toast in this code?

@isipisii
Copy link

@tuon1602 you can try to put the toast after the transition

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request request a feature to be added
Projects
Development

No branches or pull requests