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

[React 19] allow opting out of automatic form reset when Form Actions are used #29034

Open
stefanprobst opened this issue May 9, 2024 · 60 comments · May be fixed by #30728
Open

[React 19] allow opting out of automatic form reset when Form Actions are used #29034

stefanprobst opened this issue May 9, 2024 · 60 comments · May be fixed by #30728
Labels

Comments

@stefanprobst
Copy link

Summary

repo: https://github.com/stefanprobst/issue-react-19-form-reset

react 19@beta currently will automatically reset a form with uncontrolled components after submission. it would be really cool if there was a way to opt out of this behavior, without having to fall back to using controlled components - especially since component libraries (e.g. react-aria) have invested quite a bit of time to work well as uncontrolled form elements.

the main usecase i am thinking of are forms which allow saving progress, or saving a draft, before final submit. currently, every "save progress" would reset the form.

@pawelblaszczyk5
Copy link

I think you should return current values from action in such case and update the default value 😃

@glo85315
Copy link

glo85315 commented May 9, 2024

@adobe export issue to Jira project PWA

@officialyashagarwal
Copy link

I think you should return current values from action in such case and update the default value. and return required!

@zce
Copy link

zce commented May 14, 2024

This is very necessary in the step-by-step form, such as verifying the email in the auth form first

@tranvansang
Copy link

Be careful to handle if the action throws an error, your "returning the new default" at the end of the function will be ineffective.

#29090

@chungweileong94
Copy link

chungweileong94 commented May 18, 2024

The automatic form reset in React 19 actually caught me off guard, where in my case, I was trying to validate the form inputs on the server, then return & display the input errors on the client, but React will reset all my uncontrolled inputs.

For context, I wrote a library just for doing server-side validation https://github.com/chungweileong94/server-act?tab=readme-ov-file#useformstate-support.

I know that you can pass the original input (FormData #28754) back to the client, but it's not easy to reset the form based on the previously submitted FormData, especially when the form is somewhat complex, I'm talking about things like dynamic items inputs, etc.

It's easy to reset a form, but hard to restore a form.

@chungweileong94
Copy link

Now that I have played with React 19 form reset for a while, I think this behavior kind of forces us to write a more progressive enhancement code. This means that if you manually return the form data from the server and restore the form values, the user input will persist even without JavaScript enabled. Mixed feelings, pros and cons.

@jazsouf
Copy link

jazsouf commented Jun 1, 2024

what about using onSubmit as well the action to prevent default?

@eps1lon
Copy link
Collaborator

eps1lon commented Jun 1, 2024

If you want to opt-out of automatic form reset, you should continue using onSubmit like so:

+function handleSubmit(event) {
+  event.preventDefault();
+  const formData = new FormData(event.target);
+  startTransition(() => action(formData));
+}

...
-<form action={action}>
+<form onSubmit={handleSubmit}>

--

That way you still opt-into transitions but keep the old non-resetting behavior.

And if you're a component library with your own action-based API that wants to maintain form-resetting behavior, you can use ReactDOM.requestFormReset:

// use isPending instead of `useFormStatus().pending`
const [isPending, startTransition] from useTransition();
function onSubmit(event) {
  // Disable default form submission behavior
  event.preventDefault();
  const form = event.target;
  startTransition(async () => {
    // Request the form to reset once the action
    // has completed
    ReactDOM.requestFormReset(form);

    // Call the user-provided action prop
    await action(new FormData(form));
  })
}

--https://codesandbox.io/p/sandbox/react-opt-out-of-automatic-form-resetting-45rywk

We haven't documented that yet in https://react.dev/reference/react-dom/components/form. It would help us a lot if somebody would file a PR with form-resetting docs.

@rwieruch
Copy link

rwieruch commented Jun 5, 2024

@eps1lon do you think using onSubmit over action is the right call here? A bit of context:

I am surprised by this new default behavior here, because this forces essentially everyone to use onSubmit over action, because everyone wants to keep their form values intact in case of an (validation) error.

So if this reset behavior is a 100% set in stone for React 19, why not suggest using useActionState then with a payload object then where all the form values in the case of an error are sent back from the action so that the form can pick these up as defaultValues?

@karlhorky
Copy link
Contributor

karlhorky commented Jun 5, 2024

this forces essentially everyone to use onSubmit over action, because everyone wants to keep their form values intact in case of an (validation) error

@rwieruch I'm not sure this is true.

As @acdlite mentions in the PR below, it's for uncontrolled inputs.

It has no impact on controlled form inputs.

Controlled inputs are probably in almost every form case still desirable with RSC (as Sebastian mentions "I will also say that it's not expected that uncontrolled form fields is the way to do forms in React. Even the no-JS mode is not that great.")

Also, this is about "not diverging from browser behavior", as @rickhanlonii mentions in more discussion over on X here:

But it does indeed seem to be a controversial choice to match browser behavior and reset uncontrolled fields.

@rwieruch
Copy link

rwieruch commented Jun 5, 2024

EDIT: I have written about the solution over HERE.

Thanks for the input here @karlhorky and putting all the pieces together. I have seen that this matches the native browser more closely, so I see the incentive for this change. Just wanted to double check here, because I am re-adjusting my teaching material again (my own fault here, because we are still quite early on this :)).

So if I am not using a third-party library for forms or actions, would the following code look good for upserting an entity with form + server action, if I still would want to use the action attribute on the form?

const TicketUpsertForm = ({ ticket }: TicketUpsertFormProps) => {
  const [actionState, action] = useActionState(
    upsertTicket.bind(null, ticket?.id),
    { message: "" }
  );

  return (
    <form action={action} className="flex flex-col gap-y-2">
      <Label htmlFor="title">Title</Label>
      <Input
        id="title"
        name="title"
        type="text"
        defaultValue={
          (actionState.payload?.get("title") as string) || ticket?.title
        }
      />

      <Label htmlFor="content">Content</Label>
      <Textarea
        id="content"
        name="content"
        defaultValue={
          (actionState.payload?.get("content") as string) || ticket?.content
        }
      />

      <SubmitButton label={ticket ? "Edit" : "Create"} />

      {actionState.message}
    </form>
  );
};

And then the action returns the payload in the case of an error, so that the form can show this as the defaultValues, so that it does not reset.

const upsertTicketSchema = z.object({
  title: z.string().min(1).max(191),
  content: z.string().min(1).max(1024),
});

export const upsertTicket = async (
  id: string | undefined,
  _actionState: {
    message: string;
    payload?: FormData;
  },
  formData: FormData
) => {
  try {
    const data = upsertTicketSchema.parse({
      title: formData.get("title"),
      content: formData.get("content"),
    });

    await prisma.ticket.upsert({
      where: {
        id: id || "",
      },
      update: data,
      create: data,
    });
  } catch (error) {
    return {
      message: "Something went wrong",
      payload: formData,
    };
  }

  revalidatePath(ticketsPath());

  if (id) {
    redirect(ticketPath(id));
  }

  return { message: "Ticket created" };
};

EDIT: I think that's something @KATT wanted to point out in his proposal: #28491 (comment)

@pawelblaszczyk5
Copy link

Thanks for the input here @karlhorky and putting all the pieces together. I have seen that this matches the native browser more closely, so I see the incentive for this change. Just wanted to double check here, because I am re-adjusting my teaching material again (my own fault here, because we are still quite early on this :)).

So if I am not using a third-party library for forms or actions, would the following code look good for upserting an entity with form + server action, if I still would want to use the action attribute on the form?

const TicketUpsertForm = ({ ticket }: TicketUpsertFormProps) => {
  const [actionState, action] = useActionState(
    upsertTicket.bind(null, ticket?.id),
    { message: "" }
  );

  return (
    <form action={action} className="flex flex-col gap-y-2">
      <Label htmlFor="title">Title</Label>
      <Input
        id="title"
        name="title"
        type="text"
        defaultValue={
          (actionState.payload?.get("title") as string) || ticket?.title
        }
      />

      <Label htmlFor="content">Content</Label>
      <Textarea
        id="content"
        name="content"
        defaultValue={
          (actionState.payload?.get("content") as string) || ticket?.content
        }
      />

      <SubmitButton label={ticket ? "Edit" : "Create"} />

      {actionState.message}
    </form>
  );
};

And then the action returns the payload in the case of an error, so that the form can show this as the defaultValues, so that it does not reset.

const upsertTicketSchema = z.object({
  title: z.string().min(1).max(191),
  content: z.string().min(1).max(1024),
});

export const upsertTicket = async (
  id: string | undefined,
  _actionState: {
    message?: string;
    payload?: FormData;
  },
  formData: FormData
) => {
  try {
    const data = upsertTicketSchema.parse({
      title: formData.get("title"),
      content: formData.get("content"),
    });

    await prisma.ticket.upsert({
      where: {
        id: id || "",
      },
      update: data,
      create: data,
    });
  } catch (error) {
    return {
      message: "Something went wrong",
      payload: formData,
    };
  }

  revalidatePath(ticketsPath());

  if (id) {
    redirect(ticketPath(id));
  }

  return { message: "Ticket created" };
};

Yup, that’s pretty much it. This way it works the same if submitted before hydration happens

@adammark
Copy link
Contributor

Resetting the form automatically is a real head-scratcher. How should we preserve the state of a form when errors occur?

Using defaultValue doesn't work on all input types (e.g. <select>).

Using controlled components defeats the purpose of useActionState().

The example here is deceptively simple, as there are no visible form inputs.

What am I missing?

@LutherTS
Copy link

The docs are misleading on this topic because on the React 19 docs, it's the React 18 canary version that is shown as an example which does not reset the form. https://19.react.dev/reference/react-dom/components/form#handling-multiple-submission-types
This in this very example completely defeats the purpose of saving a draft. So rather than allowing opting out of automatic form reset, I believe it's the reset itself that should be an option. Because the current decision is tantamount to breaking every form that would upgrade to React 19.

@eps1lon eps1lon changed the title [React 19] allow opting out of automatic form reset [React 19] allow opting out of automatic form reset when Form Actions are used Jul 17, 2024
@eps1lon
Copy link
Collaborator

eps1lon commented Jul 17, 2024

Because the current decision is tantamount to breaking every form that would upgrade to React 19.

Automatic form reset only applies when passing functions to the action or formAction prop. A new feature that wasn't available before React 19.

The original issue description isn't explicit about this.

@LutherTS If there was a change in behavior to APIs available in previous React stable versions, please include a reproduction.

@LutherTS
Copy link

LutherTS commented Jul 17, 2024

@eps1lon You're correct, the feature has only been available since the React 18 canary version so it's only going to be breaking for those using the canary version. However, the canary version is the default version running on Next.js, so the change may be breaking for a significant number of codebases there.
But what is most important to me then is that the React docs need to correctly reflect these changes at the very least on their https://19.react.dev/ site. Because again, automatically resetting forms not only defeat the entire purpose of the example being shown (https://19.react.dev/reference/react-dom/components/form#handling-multiple-submission-types) they're also not being reflected in the example which actually runs on React 18 canary instead of React 19 (https://codesandbox.io/p/sandbox/late-glade-ql6qph?file=%2Fsrc%2FApp.js&utm_medium=sandpack).

@chungweileong94
Copy link

Automatic form reset only applies when passing functions to the action or formAction prop. A new feature that wasn't available before React 19.

The same thing doesn't apply to NextJS app router tho, where both action & formAction is available and marked as stable via React 18 canary for over a year or two, so it's pretty unfair to most NextJS users, where they kinda get screwed by the way NextJS/React handles the feature rollout or versioning.

@eps1lon
Copy link
Collaborator

eps1lon commented Jul 17, 2024

Sure, but that would be an issue for Next.js.

I don't think we rolled this change out in a 14.x Next.js stable release. The automatic form reset was enabled in #28804 which was included in vercel/next.js#65058 which is not part of any stable Next.js release as far as I can tell.

@LutherTS
Copy link

LutherTS commented Jul 17, 2024

OK, so what you're saying is this behavior only happens in Next.js 15 RC which uses React 19 RC, both of which being currently unstable, and therefore this is a trade-off for using unstable versions.

Then at the very least the React 19 docs should reflect these changes. And I reiterate that if these changes are reflected in the React 19 docs, the entire example for "Handling multiple submission types" is completely irrelevant, because there is no point in saving a draft if after saving said draft it disappears from the textarea.

So how does the React team reconcile presenting a feature for one purpose when the actual feature currently does the exact opposite?

@chungweileong94
Copy link

Sure, but that would be an issue for Next.js.

True, fair enough.

I don't think we rolled this change out in a 14.x Next.js stable release.

Yes, it is not. But that's the whole points right, where we feedback on a feature before stable release.

I do think that auto form reset behaviour does bring some benefits in terms of progressive enhancement, but if you think again, React is kinda doing extra stuff unnecessarily. By default, the browser will reset the form when we submit it, then when we submit a form via JS(React), it retains the form values after submit, but React then artificially reset the form. Yes, form reset is a cheap operation, but why not make it an option for people to opt-in instead of doing it automatically.

@eps1lon
Copy link
Collaborator

eps1lon commented Jul 17, 2024

Yes, it is not. But that's the whole points right, where we feedback on a feature before stable release.

And that's certainly appreciated. Though there's an important difference between a change in behavior and the behavior of a new feature.

The comments here read as though this breakage is not the norm when we didn't change any behavior between stable, SemVer minor React releases nor between stable, SemVer minor Next.js releases. Changes in behavior between Canary releases should be expected.

Now that we established that this isn't a change in behavior, we can discuss the automatic form reset.

The reason this was added was that it matches the native browser behavior before hydration or with no JS (e.g. when <form action="/some-endpoint">) would be used. Maybe we should focus why using onSubmit as shown in #29034 (comment) doesn't work in that case?

@renke
Copy link

renke commented Jul 17, 2024

Maybe we should focus why using onSubmit as shown in #29034 (comment) doesn't work in that case?

I have no minimal example at hand, but it seems like useFormStatus does not work as expected (as in pending is never set to true) when using the onSubmit solution. I am using said solution with a v18 canary version though (because the automatic form reset in v19 will cause problems for me in the future). It does work when using action.

@eps1lon
Copy link
Collaborator

eps1lon commented Jul 17, 2024

I have no minimal example at hand, but it seems like useFormStatus does not work as expected (as in pending is never set to true) when using the onSubmit solution.

That seems like something you should be able to highlight starting with https://react.new. Though I believe useFormStatus only works with action. Though this is best served as a separate issue.

@LutherTS
Copy link

LutherTS commented Oct 7, 2024

You could also return a unique value from server action, like timestamps, and assign it to <form key={timestamp} />, this will force the form to be re-rendered, and indirectly reset the form, which IMO much easier to do so without tapping into event handler.

Nice idea, I didn't think about it this way. I'm just really skeptical about relying on React features right now for stuff that can be done via HTML and pure JavaScript, because if the key prop experiences breaking changes in the future, this form feature may break down on the entire project. I don't think it will happen, but if anything can simply be handled with pure JavaScript, I consider it to be more reliable.

@leerob
Copy link

leerob commented Oct 7, 2024

Hey y'all – apologies if the Next.js docs added more confusion here. Transparently, I'm still learning the best way to teach these patterns too! So I appreciate this discussion.

This is what I was doing previously using an event handler (as mentioned above):

const handleSubmit = (event) => {
  event.preventDefault();
    startTransition(() => {
      formAction(new FormData(event.currentTarget));
    });
};

I prefer the default to reset the form, but fully acknowledge this has tripped me up, and will take more education to get it right (in both the Next.js and React docs). @rwieruch's example above is helpful and how I plan to teach this going forward, when you want to retain the client state.

@rwieruch
Copy link

rwieruch commented Oct 8, 2024

@leerob thanks for jumping in! Do you happen to know if there's been a final decision on whether the "new" automatic reset will be included in React 19? It would be really helpful to get confirmation so we can adjust our teaching accordingly going forward.

@eps1lon
Copy link
Collaborator

eps1lon commented Oct 8, 2024

Turns out, by using the action prop of the form for my action, even though I actually control my s, this current state of React 19 resets the s to their first valid entry,

This is a bug we're tracking in #30580

To avoid form reset, you can send back the original FormData and pass that to the defaultValue. That way you can control which inputs to reset and it even works for the case where an MPA navigation was triggered i.e. JS wasn't loaded yet.

async function sendMessage(currentState, formData) {
  const name = formData.get("name");
  const message = formData.get("message");

  if (!name || !message) {
    return { form: formData, error: "Missing inputs" };
  }

  return {};
}

function Form({ action }) {
  const [{ form = new FormData(), error }, formAction] = useActionState(
    action,
    {}
  );

  return (
    <form action={formAction}>
      <p>{error !== undefined ? error : null}</p>
      <input name="name" defaultValue={form.get("name")} />
      <input name="message" defaultValue={form.get("message")} />
      <input type="submit" />
    </form>
  );
}

If you want to control which inputs to reset on the Server, you have to send back a new FormData object. Using FormData.delete() does not work since React avoids serializing the same object again if two objects have referential equality.

@chungweileong94
Copy link

I'm not sure if this is a bad idea, but by utilizing both onSubmit & action, it will have the same behaviour as React 18 Canary, where it won't reset the form when JS is available, while still working when JS is not available.

<form
     action={formAction}
     onSubmit={(e) => {
       e.preventDefault();
       startTransition(() => {
         formAction(new FormData(e.currentTarget));        
       });
     }}
   >

@chungweileong94
Copy link

To avoid form reset, you can send back the original FormData and pass that to the defaultValue.

Unfortunately, this won't work for inputs like <select>. It only works for MPA navigation.

@eps1lon
Copy link
Collaborator

eps1lon commented Oct 8, 2024

Unfortunately, this won't work for inputs like <select>. It only works for MPA navigation.

This is a bug. Likely related to #30580

@LutherTS
Copy link

LutherTS commented Oct 8, 2024

Thanks @leerob and @eps1lon for the two solutions you've highlighted, and special thanks to @rwieruch for bringing it up initially. The first solution has the benefit of preventing the form submission altogether with preventDefault while still accessing the formData for the action, whereas the second solution brings the benefit of being able to choose the values that each uncontrolled field should reset to by returning the formData or even a modified version of it from the server.

That being said, I feel like Lee's first solution is a bit of workaround which effectively bypasses the action prop and defeats its purpose in this circumstance, while Sebastian's solution still resets the form which is an extra undesired step in this case – even though the fields are reset with the appropriate values – and a step which is at the root of the bug this solution is currently facing with selects.

Is there a one-size-fits-all solution that the React team by itself or in conjuction with the Next.js team (or any other framework) could produce which, in my opinion, would be able to bring the best of both worlds? Should React as a library focus on being closer to the original defaults, while Next.js as a framework focuses on the developer experience of this matter? I understand this is not an easy task, but it's one that I'd love for both teams to tackle internally.

@aurorascharff
Copy link

aurorascharff commented Oct 10, 2024

Adding here an example of the solution of returning the data from the action and using it as the defaultvalues. In this example, the item that's being edited is being used as the initialstate of useActionState. It becomes a very small piece of code that does not need javascript to run. It also returns the errors in the same way.

export default function ContactForm({ contact }: { contact: Contact }) {
  const updateContactById = updateContact.bind(null, contact.id);
  const [state, updateContactAction] = useActionState(updateContactById, {
    data: {
      avatar: contact.avatar,
      first: contact.first,
      last: contact.last,
      notes: contact.notes,
      twitter: contact.twitter,
    },
    errors: {} as ContactSchemaErrorType,
  });

  return (
    <form className="flex max-w-[40rem] flex-col gap-4 @container" action={updateContactAction}>
      <div className="grip-rows-5 grid gap-2 @sm:grid-cols-[1fr_4fr] @sm:gap-4">
        <span className="flex">Name</span>
        <div className="flex gap-4">
          <Input
            errors={state.errors?.fieldErrors?.first}
            defaultValue={state.data?.first || undefined}

code for form

type State = {
  data?: ContactSchemaType;
  errors?: ContactSchemaErrorType;
};

export async function updateContact(contactId: string, _prevState: State, formData: FormData) {
  const data = Object.fromEntries(formData);
  const result = contactSchema.safeParse(data);

  if (!result.success) {
    return {
      data: data as ContactSchemaType,
      errors: result.error.formErrors,
    };
  }

code for action

Blog post explaining each step:
https://aurorascharff.no/posts/handling-form-validation-errors-and-resets-with-useactionstate/

And by the way, using the action prop will allow you to optionally use the onSubmit for client-only extra functionality, like optimistic updates, that can be a progressive enhancement on top of the no-js base case as seen here

@eps1lon
Copy link
Collaborator

eps1lon commented Oct 11, 2024

const data = Object.fromEntries(formData);
const result = contactSchema.safeParse(data);

if (!result.success) {
return {
data: data as ContactSchemaType,

Ideally you'd send back the original formData since that means React doesn't need to serialize it again when frameworks implement "temporary references" (missing from Next.js).

@aurorascharff
Copy link

aurorascharff commented Oct 11, 2024

const data = Object.fromEntries(formData);
const result = contactSchema.safeParse(data);
if (!result.success) {
return {
data: data as ContactSchemaType,

Ideally you'd send back the original formData since that means React doesn't need to serialize it again when frameworks implement "temporary references" (missing from Next.js).

I tried this initially, but for some reason it wasn't working then. I can test it again!
Edit: I understand it's better not to serialize it again, but it's also nice not having to deal with the formData object in the form component and just have the same shape and typed object in and out.

@eps1lon
Copy link
Collaborator

eps1lon commented Oct 11, 2024

I tried this initially, but for some reason it wasn't working then. I can test it again!

It won't work in Next.js just yet. It should work in fixtures/flight if you're familiar with how to work with that fixture.

@JianWei-0510
Copy link

This work for me:

const formAction = () => {
    startTransition(async () => {
      await askAIAnalysis(new FormData(formRef.current!); // formRef is obtained from useRef of your form
  };

<Button
  disabled={pending}
  onClick={formAction}>
   Submit
</Button> 

@chungweileong94
Copy link

chungweileong94 commented Oct 31, 2024

This work for me:

const formAction = () => {

    startTransition(async () => {

      await askAIAnalysis(new FormData(formRef.current!); // formRef is obtained from useRef of your form

  };



<Button

  disabled={pending}

  onClick={formAction}>

   Submit

</Button> 

Yes, but you might as well just use onSubmit, and skip the needs of formRef.

@karlhorky
Copy link
Contributor

karlhorky commented Nov 7, 2024

@rwieruch just saw your new "How to (not) reset a form after a Server Action in React" blog post, thanks for that!

Seems like it could be a nice pattern:

action.ts

'use server';

type ActionState = {
  message: string;
  payload?: FormData;
};

export async function createPost(
  _actionState: ActionState,
  formData: FormData,
) {
  const data = {
    name: formData.get('name'),
    content: formData.get('content'),
  };

  if (!data.name || !data.content) {
    return {
      message: 'Please fill in all fields',
      payload: formData,
    };
  }

  // TODO: create post in database

  return { message: 'Post created' };
}

PostCreateForm.tsx

'use client';

import { useActionState } from 'react';
import { createPost } from './action';

export function PostCreateForm() {
  const [actionState, action] = useActionState(createPost, {
    message: '',
  });

  return (
    <form action={action}>
      <label htmlFor="name">Name:</label>
      <input
        name="name"
        id="name"
        defaultValue={(actionState.payload?.get('name') || '') as string}
      />

      <label htmlFor="content">Content:</label>
      <textarea
        name="content"
        id="content"
        defaultValue={(actionState.payload?.get('content') || '') as string}
      />

      <button type="submit">Send</button>

      {actionState.message}
    </form>
  );
}

@rwieruch
Copy link

rwieruch commented Nov 7, 2024

Yeah, thanks for sharing @karlhorky Seems pretty straightforward, but I didn't run into all the edge cases yet I guess 😅

@johnyvelho
Copy link

johnyvelho commented Nov 12, 2024

@karlhorky thanks for your blog post. it was excellent. do you know how to handle it when its a <input type="file" />?

---- edit ----
I assume it needs to be controlled since browsers don't retain file selections and don't allow us to set them due to security purposes. This makes sense as it has always been the default behavior. I just thought there might be a way to implement progressive enhancement for file input types nowadays.

@karlhorky
Copy link
Contributor

@johnyvelho to be clear, it's not my blog post / research haha - it's from @rwieruch

@devdatkumar
Copy link

How do I handle form state if I want to use Zod on the frontend, and in case of errors, ensure that the form does not reset?

@quick007
Copy link

quick007 commented Dec 11, 2024

How do I handle form state if I want to use Zod on the frontend, and in case of errors, ensure that the form does not reset?

@devdatkumar
The easiest way is to just ensure that all of your inputs are controlled. That will prevent resetting. You can also use a good old onSubmit listener (i.e. <form onSubmit={(e) => ...} >), and that does the job just as well. Another way is to follow the pattern outlined by this reply which sets the form values once data is returned. I don't recommend this method though as it seems to overwrite data that a user adds while the server is processing, potentially resulting in unintended behavior.

There are a couple of other solutions above that you're welcome to pursue as well and figure out what best fits your needs. I do want to point out that actions are uniquely situated to validate backend data, so maybe stick with an onSubmit?

@devdatkumar
Copy link

devdatkumar commented Dec 11, 2024

@quick007
While creating a basic sign-in/up form, I wanted to validate the data on the frontend using Zod. If validation fails, I don’t want to reset the form data. If validation succeeds, I don’t mind the reset.

I spent quite some time on this problem. I could persist data using the useState() hook, but then I had to manage the pending state. Additionally, I wanted to use the useActionState() hook, but there isn’t a clean code solution around it.

So, I came up with a solution that you don’t recommend. Please guide me if this is the right way to handle things for creating a login/signup form. should i use react-hook-form ? and accept the defeat! :D.

"use client";

// imports here.

export default function SignupCredentialsForm() {
  const [state, dispatch, isPending] = useActionState(
    signupAction,
    undefined
  );

  return (
    <Form
      action={dispatch}
    >

      <Input
        name="email"
        defaultValue={state?.formData?.get("email")?.toString() ?? ""}  //this is how i persist data.
      />

      {state?.errorField?.email && (
        <ul>
          {state.errorField.email.map((error, index) => (
            <li key={index}>{error}</li>
          ))}
        </ul>
      )}

    // other fields

      {state?.errorMessage && (
        <div>
          <p>{state.errorMessage}</p>
        </div>
      )}

      <Button type="submit" disabled={isPending}>
        {isPending ? "loading" : "Sign up"}
      </Button>
      
    </Form>
  );
}
"use server";

// import here

export async function signup(_prevState: unknown, formData: FormData) {
  const validationResult = signupSchema.safeParse(Object.fromEntries(formData));

  if (!validationResult.success) {
    return {
      errorField: validationResult.error?.flatten().fieldErrors,
      formData,
    };
  }

  try {
    // something with database
  } catch {
    return {
      errorMessage: "Something went wrong with Database!",
      formData,
    };
  }
}

@chungweileong94
Copy link

Another way is to follow the pattern outlined by this reply which sets the form values once data is returned. I don't recommend this method though as it seems to overwrite data that a user adds while the server is processing

I understand where you came from, but in what scenario does a user want to persist the changes that were made during the transition though? I personally kinda like the defaultValue way, where the user is able to know what they did wrong by looking at the inputs.

For example: User enters an invalid input and clicks submit, during the transition user made some changes to the input, after the state(with validation errors) being sent back and reset via defaultValues, at this state, the user can refer to the errors and the value that was sent to locate the mistake that they made.

I'm curious in what scenario where persisting the changes is needed? The form submission is usually fast (based on my usage of course), so I don't think they have enough time to change any of the fields

@devdatkumar
Copy link

devdatkumar commented Dec 11, 2024

Form state and validation

Hi everyone, I'm working on form validation for a project and would love your thoughts on the approach. Here's what I've been considering:

  1. Front-end validation:
    I need to use additional hooks like useState, useRef, useTransition, and useFormStatus, which defeats the purpose of using the useActionState hook with server actions, as I want the data to persist in case validation fails. However, this approach still requires back-end validation.
    (Security Concern: If I skip separate back-end validation, anyone could bypass the front-end by directly calling the API and injecting data, even if front-end is validated.)

  2. Both front-end and back-end validation:
    This approach requires implementing almost all the logic for front-end validation along with separate back-end validation. The back-end validation would return generic errors rather than detailed ones.

  3. Back-end validation (the approach I ended up using):
    This involves validating data solely on the back-end and returning detailed field-specific errors to render them appropriately in the UI. While this addresses the security concern by ensuring validation happens on the back-end, it raises the question of whether this increases the load on the back-end. However, since back-end validation is necessary anyway, returning the response seems practical.

Although I don’t particularly like the "back-end validation" approach, I would prefer the "both front-end and back-end validation" option. Most users will interact with the form via the front-end, so validating on the client side and sending sanitized data seems better. This approach might reduce server load and avoids relying on the back-end response for basic validation. Additionally, it ensures that no unnecessary API calls are made for invalid front-end inputs, which is a significant advantage in my opinion.

Would love to hear your thoughts or suggestions for optimizing this further!

  • generic error:
return { error: "Invalid data type." }
  • field error:
return { error: validationResult.error?.flatten().fieldErrors }

well, this comes with a catch: Type error is thrown if other type of errors are returned.

const validationResult = signinSchema.safeParse(Object.fromEntries(formData));
if (!validationResult.success) {
    return { error: validationResult.error?.flatten().fieldErrors };
}
  
// authenticate credentials 
if (!authenticated) {
    return { error: "Invalid credentials!" };
}

there are two ways to mitigate this error

  1. Destructure error
const validationResult = signinSchema.safeParse(Object.fromEntries(formData));
if (!validationResult.success) {
    errors: { ...validationResult.error?.flatten().fieldErrors, authError: undefined,}
}
  
// authenticate credentials 
if (!authenticated) {
return {
    error: {
        email: undefined,
        password: undefined,
        authError: "Invalid credentials!",
    }
}
  1. throw separate errors: fieldError and authError (I'm using this approach)
const validationResult = signinSchema.safeParse(Object.fromEntries(formData));
if (!validationResult.success) {
    return { fieldError: validationResult.error?.flatten().fieldErrors };
}
  
// authenticate credentials 
if (!authenticated) {
    return { authError: "Invalid credentials!" };
}

@devdatkumar
Copy link

devdatkumar commented Dec 11, 2024

I'm curious in what scenario where persisting the changes is needed? The form submission is usually fast (based on my usage of course), so I don't think they have enough time to change any of the fields

This can be handled using useActionstate() hooks, it allows to change the data while in pending state, but too much boilerplate code.

@quick007
Copy link

quick007 commented Dec 12, 2024

Another way is to follow the pattern outlined by this reply which sets the form values once data is returned. I don't recommend this method though as it seems to overwrite data that a user adds while the server is processing

I understand where you came from, but in what scenario does a user want to persist the changes that were made during the transition though? I personally kinda like the defaultValue way, where the user is able to know what they did wrong by looking at the inputs.

One of the original examples from the react team was a save draft button. If you clicked this (especially if you coded in a hotkey for it), I could see it creating unintentional behavior. It's also worth noting server actions are only as fast as your server is, and if that server isn't vercel (or you have a really slow db connection from that), saving might not always be super fast.

When submitting a form I don't see it as a huge issue, but saving (even for smth like a user profile), can become an issue. I also sometimes notice a problem with what I input after I click submit when filling out forms that will trigger an error, so I start editing while it's still submitting.

Overall though, the defaultValue method is probably fine, just not my preferred method of implementation. I do think it fares better/worse in different scenarios though, so if it doesn't matter a lot in your implementation, have at it.

Edit: Re-reading your message, I do see your concern about returning errors that are no longer valid. It's definitely a trade-off, with both resulting in a bad user experience. But if you move away from actions to client-side validation (with server-side as well), you can cut down on API calls and solve both issues in one swoop.

@quick007
Copy link

Personally, I've gone back to just using onSubmit. Doesn't support progressive enhancement, but realistically my website isn't functional without js anyways. It also supports both frontend and backend validation, but (obviously) doesn't have anywhere near the DX of the native stuff. It's a shame, but still works fine and doesn't require controlled inputs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.