react icon indicating copy to clipboard operation
react copied to clipboard

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

Open stefanprobst opened this issue 9 months ago • 46 comments

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.

stefanprobst avatar May 09 '24 05:05 stefanprobst

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

pawelblaszczyk5 avatar May 09 '24 09:05 pawelblaszczyk5

@adobe export issue to Jira project PWA

glo85315 avatar May 09 '24 09:05 glo85315

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

officialyashagarwal avatar May 09 '24 15:05 officialyashagarwal

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

zce avatar May 14 '24 13:05 zce

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

https://github.com/facebook/react/issues/29090

tranvansang avatar May 16 '24 02:05 tranvansang

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 https://github.com/facebook/react/pull/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 avatar May 18 '24 06:05 chungweileong94

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.

chungweileong94 avatar May 27 '24 05:05 chungweileong94

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

jazsouf avatar Jun 01 '24 19:06 jazsouf

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.

eps1lon avatar Jun 01 '24 21:06 eps1lon

@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?

rwieruch avatar Jun 05 '24 09:06 rwieruch

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.

  • Source: https://github.com/facebook/react/pull/28804

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:

  • https://x.com/rickhanlonii/status/1796703078894440615

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

karlhorky avatar Jun 05 '24 09:06 karlhorky

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: https://github.com/facebook/react/pull/28491#issuecomment-2015032940

rwieruch avatar Jun 05 '24 10:06 rwieruch

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

pawelblaszczyk5 avatar Jun 05 '24 10:06 pawelblaszczyk5

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?

adammark avatar Jul 10 '24 21:07 adammark

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.

LutherTS avatar Jul 17 '24 09:07 LutherTS

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.

eps1lon avatar Jul 17 '24 10:07 eps1lon

@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).

LutherTS avatar Jul 17 '24 10:07 LutherTS

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.

chungweileong94 avatar Jul 17 '24 10:07 chungweileong94

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 https://github.com/facebook/react/pull/28804 which was included in https://github.com/vercel/next.js/pull/65058 which is not part of any stable Next.js release as far as I can tell.

eps1lon avatar Jul 17 '24 12:07 eps1lon

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?

LutherTS avatar Jul 17 '24 13:07 LutherTS

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.

chungweileong94 avatar Jul 17 '24 13:07 chungweileong94

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 https://github.com/facebook/react/issues/29034#issuecomment-2143595195 doesn't work in that case?

eps1lon avatar Jul 17 '24 15:07 eps1lon

Maybe we should focus why using onSubmit as shown in https://github.com/facebook/react/issues/29034#issuecomment-2143595195 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.

renke avatar Jul 17 '24 21:07 renke

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.

eps1lon avatar Jul 17 '24 23:07 eps1lon

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.

This is actually intended. useFormStatus is a convenience wrapper when using the action and formAction prop. Since you manually use startTransition, you can just use the pending indicator returned from this particular useTransition. I updated my original example to call this out.

eps1lon avatar Aug 01 '24 14:08 eps1lon

It would be really great to make this opt-in instead of opt-out! I understand react is trying to mirror the default behavior of the browser here, but, as the people commenting on this issue seem to suggest, that isn't the expected behavior.

My use case (and the use case that is highlighted on the nextjs docs) is server-side validation. If you receive an error back from the server, I can't think of any case where resetting all of the form fields would make sense. I believed that so much so that when I first went to debug this, I assumed it was a re-rendering issue, not the intentional behavior of next. Alas, I was wrong, and judging by other comments, I'm not the only one who is wrong.

That's because the way that this feature is presented is directly opposite to how it works. The expectations (form validation, draft saving, etc.) and reality of this feature are at odds. It feels like react wanted to create some really cool form primitives, but tripped while crossing the finish line.

But this race isn't over. React can still fix this, even if it's just a prop rather than the default. Even better, let us configure what works best for us at a project level then individually opt in/out at a form/input level too.

quick007 avatar Aug 25 '24 00:08 quick007

Unfortunately, MGSimard's comment appears to have disappeared but it was factually on point. Here's a situation I've just encountered. I submit a form to be validated on the server. On the server, I notice that one of the fields was not compatible with the database, so I return an error object. The only thing that would need to be fixed to resend the form would be this one field, but since my action is on the form itself (in order to have access to the formData) which triggers when the form is submitted, now all the fields are gone.

Edit: In the end I had to revert to controlling all of my fields and reset them on success manually, and now I don't see myself using the form action's provided formData ever again. I'm sure a lot of React 19 developers are going to follow the same path, and all of that hard work engineers put into this feature will have been for nothing. I assume Next.js 15 will release alongside Next.js Conf 24 this October 24 in less than a month, so if you care I really encourage you guys to fix this before that launch.

LutherTS avatar Sep 28 '24 18:09 LutherTS

Hmm, I back the decision to reset the form on submit to be fair. Since it is a good old fashioned POST request, it seems nice and RESTful to match browser behaviour; even if it is inconvenient. However, this is $CURRENT_YEAR, and I'd love to see a pleaseDontResetMyForm option for these actions. Good stuff either way 💯

awcot avatar Oct 02 '24 16:10 awcot

So I took the time to control every single one of my form fields in order to successfully implement server-side validations. Turns out, by using the action prop of the form for my action, even though I actually control my <select>s, this current state of React 19 resets the <select>s to their first valid entry, bypassing my control. After having my mind blown and legitimately throwing a tantrum at seeing my controlled React components getting controlled by React, I implemented a loose workaround, but the fact is anybody using the action prop of a form that depends on a <select> is going to be faced with a rude awakening. This, is indefensible.

Since I don't think I'm going to comment any further on this – unless something even more insane happens (I wasn't expecting to pen this experience after all) – allow me to recap everything that is wrong with how this issue is being handled. First, the case for not resetting the form is literally behind a use case presented in the Next.js docs. So it's not just me who depends on this, it's literally the framework which is the canary in the coal mine for React 19 that does, and every developer who uses it. Second, it is also behind a use case presented in the React docs. For this, in the React 18 version the draft doesn't disappear when saved, as expected. In the React 19 version the draft disappears when saved, which defeats the idea of a draft.

You can clearly see in the two examples from the React docs that there has been a change in behavior here imputable to React alone, and one that goes against what the React docs themselves are advertising. At the risk of being rude I sincerely do not care about the logic behind this decision as a React end user, I just do not believe the amount of "fighting against the framework" I and many developers have and will have to do with this along with all the bugs that are bound to ensue is what the React team intended, and I do not hide my concern of seeing Next.js 15 ship in the next 22 days with this issue unresolved.

LutherTS avatar Oct 02 '24 20:10 LutherTS

Continuing on from @LutherTS's great points and questions, I'd love to hear the official react/nextjs teams view on the plan for handling default values for inputs such as select and what the recommended approach is now. Do we have to just write that boilerplate code with onSubmit + preventing default? (yayyy, back to that again)

I'm not really deep into the understanding of how these forms/actions/react stuff are working, but isn't there a way similar to what Remix did with their Form component that can essentially hijack the event to prevent resetting. If i'm not mistaken that component handles no-JS as well by just reverting to native form handling (resetting included, but thats fine, most people have JS enabled?). But if we were to write the onSubmit version mentioned by @eps1lon aren't we now completely opting out of no-JS in that scenario, so it doesn't even work anymore at all? why can't there be a version that works both with the same API?

I've been testing react/next canary and handling forms, default values and resetting has just become instantly really hard to manage. I get thats probably now due to how im handling stuff "incorrectly" but I just want a form with some uncontrolled inputs which submits to a certain server action, if I have to use an enhanced "Form" component, from React/Next or another library im fine with that :)

JClackett avatar Oct 06 '24 14:10 JClackett