Skip to content

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

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

Open
stefanprobst opened this issue May 9, 2024 · 88 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 · 88 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.

@JClackett
Copy link

@devdatkumar but why do all this? just use the built in html input validation and submit the form, what do you mean write regex for every component??

@schimi-dev
Copy link

use this video by lee rob from next js it wil help you https://www.youtube.com/watch?v=KhO4VjaYSXU

This video only covers a very basic, server-centered scenario. I think the community does not have any problems with understanding these patterns. However, forms in many real-world applications typically require a bit more interactivity. Where I see the community currently having problems is building client-side interactivity around these new server-centered building blocks. Unmounting a form or closing a dialog after completing the Action, resetting the state of a dialog that internally used useActionState when it is opened again, or showing Toast notifications after an Action which typically rely on external storage and therefore do not work well with Transitions are examples of patterns where the community currently has problems. Requiring you to manage input state in useActionState which you can't easily reset in an onClose or onOpen event handler is just an additional challenge for building client-side interactivity. Resetting action state on the client requires some artificial "Resetting state with a key" where you would typically increase a counter used as key. Toast notifications only work by using useEffect as a duct tape. @leerob I think these are the aspects that should be covered in a video. Ironically, the Vercel Dashboard and also v0 implement very interactive forms, e.g., inside dialogs or with Toast notifications.

@devdatkumar
Copy link

devdatkumar commented Dec 23, 2024

@devdatkumar but why do all this? just use the built in html input validation and submit the form, what do you mean write regex for every component??

Don't you think the built-in html input validation code will be cultured for validation below.
also i want to trim and lowercase the data, and how do i ensure they are lowercased?
I mean there is a reason to use zod.

const name = z
  .string()
  .min(4, "At least 4 characters.")
  .max(64, "Up to 64 characters.");

const email = z.string().email("Invalid email address.").trim().toLowerCase();

const password = z
  .string()
  .min(8, "8 characters.")
  .regex(/\d/, "a number.")
  .regex(/[A-Z]/, "an uppercase letter.")
  .regex(/[a-z]/, "a lowercase letter.")
  .regex(/[@$!%*?&#]/, "a special character.")
  .max(64, "Up to 64 characters.");

@shemaikuzwe
Copy link

shemaikuzwe commented Dec 24, 2024

The way react and next js handles forms is about server side only but we may want also client side validation just like how react-hook-form works.But the problem is that it uses onSubmit and it can't be used with useActionState.A simple sln i think we can use built in Html validation as our client side validation.or use this library https://conform.guide/ works with action attribute,zod and useActionState but idon't think it is ready for production.

@devdatkumar
Copy link

devdatkumar commented Dec 24, 2024

@shemaikuzwe https://conform.guide/ doesn't really have clean code.
I would prefer to write a wrapper instead of using https://conform.guide/

but i what i really prefer is a solution from the react team / @leerob.
forms should be reset or persist data data a choice.

for example the scenario I ran into: on client side, do not reset form if Zod validation fails, so that user can fix data and then finally submit.

this is what i came up with.

type FieldError = {
  email?: string[] | undefined;
  password?: string[] | undefined;
};

export default function SigninForm() {
  const [user, setUser] = useState({ email: "", password: "" });
  const [error, setError] = useState<FieldError | undefined>({});
  const [state, dispatch, isPending] = useActionState(signinAction, undefined);

  const handleAction = (formData: FormData) => {
    const validationResult = signinSchema.safeParse(
      Object.fromEntries(formData)
    );

    if (!validationResult.success) {
      setError(validationResult.error.flatten().fieldErrors);
      setUser({
        email: formData.get("email")?.toString() ?? "",
        password: formData.get("password")?.toString() ?? "",
      });
      return;
    }

    setError({});
    dispatch(formData);
    setUser({ email: "", password: "" });
  };

well, the other way is (as you said) to implement basic validation, but then again i need to write regex for every component.

@og2t
Copy link

og2t commented Jan 15, 2025

I wonder what's reason behind this change. The expected user's behaviour is to retain their inputs in case there's an error.
Patching client/server components to return the unchanged user data back and forth doesn't make much sense.

And how does it work with returning 25MB PDF from the server when there's an error in another field? 🤔

@chungweileong94
Copy link

chungweileong94 commented Jan 15, 2025

returning 25MB PDF from the server

That's to me consider a huge file tho, overtime it's will cost you a lot of bandwidth, you might want to change the strategy?

What I usually do in this case is that I will upload the file to the server separately, then return a file ID for the rest of the form submission.

@LutherTS
Copy link

LutherTS commented Jan 15, 2025

I wonder what's reason behind this change. The expected user's behaviour is to retain their inputs in case there's an error. Patching client/server components to return the unchanged user data back and forth doesn't make much sense.

And how does it work with returning 25MB PDF from the server when there's an error in another field? 🤔

That's what I've been saying since before React 19 became stable. The solution for me – and really what I think is the solution so far – is doing it like @leerob and @rwieruch before initially recommended, which is using a Server Action via onSubmit with event.preventDefault() instead. Because indeed, Server Actions (meaning Server Functions sent to the client then wrapped into startTransition or managed with useActionState) can be used on any listener, not just on the action prop of the form or the formAction prop of a button.

...Or you can always use React Router v7 where the inputs are retained after the action and can be dismissed on success by handling the actionData.

@devdatkumar
Copy link

devdatkumar commented Jan 16, 2025

@devdatkumar but why do all this? just use the built in html input validation and submit the form, what do you mean write regex for every component??

syntactic:
type email,
type password,
type text

semantic:
for email:pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$",
for password: pattern= "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*_=+-]).{8,12}$"

type validation:
Zod validation.
Yup validation

now if i just consider built-in html input validation, how do i confirm sematic validation? not only that, even if i use sematic validation, it doesn't display what is missing, it just highlight invalid data by default.

so, for me to display what's missing / wrong with data,
i don't use pattern for semantic, but use only syntactic and type validation using zod, which also provides sematic validation.
and then i can display error details, unlike html built-in validation which only displays Invalid data.

@JClackett
Copy link

@devdatkumar I get it, but I would say that its not reacts job to consider that you want to use zod/yup as a validating library - so yeah you will need to use onSubmit + prevent default and handle errors manually - orrr just do server side validation only and not mess around with it :) there will eventually be a form library that integrates with it all and provides a seamless experience but react 19 is new so it will take time. I think there will probably always be caveats/boilerplate as trying to merge server/client side experiences into one thing may just not be possible - but who knows

@ftognetto
Copy link

Hello all I just wrote a simple library the purpose of which is have server side validation (with zod), client side native validation and maintain form values across submits.
Maybe this will help someone -> https://www.npmjs.com/package/react-server-actions

@machineghost
Copy link

While I applaud the effort ... am I crazy for thinking it SHOULD NOT require a separate library just to have the client retain knowledge it already has (instead of arbitrarily discarding it after every server communication)? 😦

@ftognetto
Copy link

I agree with you @machineghost this is quite unnatural and should be part of the react api, at least an option to choose if reset or not the form across submits.
Anyway the library is not doing only this :)

@dimikot
Copy link

dimikot commented Apr 5, 2025

This form resetting has just eaten 2h of my time. It is very counter-intuitive. In real life, we just don't reset forms, because we don't want to lose user's input accidentally!

And it's not even documented in https://react.dev/reference/react-dom/components/form#handling-multiple-submission-types

Related radix-ui (shadcn) issue: radix-ui/primitives#3444

@chungweileong94
Copy link

@dimikot The doc does mention the behavior. https://react.dev/reference/react-dom/components/form#handle-form-submission-on-the-client.

You either return the original data from server function and set the default input values, or use onSubmit.

@vsDizzy
Copy link

vsDizzy commented Apr 14, 2025

Just discovered that useFormState was working fine.

	// const [state, action] = useFormState(loginAction, initialState);    //  <-------   keeps data
	const [state, action] = useActionState(loginAction, initialState);    //  <--------  resets form

@LutherTS
Copy link

@vsDizzy That's because useFormState is the older version of useActionState. Perhaps I'm wrong but I assume it is supposed to be deprecated and I don't think it comes with the 3rd isPending member of the returned array.

@vsDizzy
Copy link

vsDizzy commented Apr 15, 2025

I just wanted to mention that when it was canary and lived in 'react-dom' it was working fine.
Then they moved it to 'react', renamed and also changed the behavior. And now they're calling reset form intentionally under the hood.

@danieltott
Copy link
Contributor

Here's another workaround - it turns out that React is actually calling reset() on the <form> element - so you can add an event listener and preventDefault() on it:

'use client';

import { useActionState, useEffect, useRef } from 'react';
import { serverAction } from './actions';

export function FormNoReset() {
  const [state, formAction] = useActionState(serverAction, { data: {} });

  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    function watchReset(e: Event) {
      console.log('reset in react', e);
      e.preventDefault();
    }
    const form = formRef.current;
    form?.addEventListener('reset', watchReset);

    return () => {
      form?.removeEventListener('reset', watchReset);
    };
  }, []);

  return (
    <form action={formAction} ref={formRef}>
      {state.message && <div>{state.message}</div>}
      <label>
        This input will retain it's value
        <input type="text" name="input1" />
      </label>
      <input type="submit" value="Submit" />
    </form>
  );
}
Image

I haven't tested this out fully so there might be edge cases where it turns out this is a bad idea. But so far this is solving these issues for me, while we wait for #30580 to be solved.

@danieltott
Copy link
Contributor

I forgot to mention above, the reset -> preventDefault() hack also solved the issue with <select>s:

Image

@mdj-uk
Copy link

mdj-uk commented May 12, 2025

A lot of posts above recommend returning FormData from the server function and using that to set defaultValue on each form element.

Is there any advantage to that method over just using onSubmit in addition to action?

i.e.

function Form() {
    const [formState, formAction, pending] = useActionState(
        addPostServerAction,
        {
            status: null,
        }
    )

    function handleSubmit(e: FormEvent<HTMLFormElement>) {
        e.preventDefault() 
        startTransition(() => {
            formAction(new FormData(e.currentTarget))
        })
    }
    return (
        <form
            action={formAction}
            onSubmit={handleSubmit}
        >
//...

Here we get no-js submission thanks to the form action. But once hydrated, the handleSubmit function will be used instead, so the inputs are not cleared if there's a validation error.

This seems a lot simpler than the other method, and cuts down on data being sent to and from the server.

What's also nice is that it's now easy to add whatever else we want to handleSubmit, e.g. client side validation, optimistic updates etc. without impairing progressive enhancement.

Just wondering if there's something I'm missing and whether there's a reason not to do this?

@devdatkumar
Copy link

devdatkumar commented May 20, 2025

@eps1lon @mdj-uk Thank you for suggesting a good approach.
However, when applied in practice, it doesn't perform well.

Please take a look at this code:

"use client";

import React, { startTransition, useActionState, useState } from "react";
import Form from "next/form";
import {
  CircleAlert,
  Eye,
  EyeClosed,
  LoaderPinwheel,
  LogIn,
  UserRoundCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signinSchema } from "@/lib/types/auth-schema";
import { signInAction} from "@/actions/auth/signin";
import { cn } from "@/lib/utils";

type FieldError = {
  email?: string[] | undefined;
  password?: string[] | undefined;
};

export default function SigninForm() {
  const [showPassword, setShowPassword] = useState(false);
  const [fieldError, setFieldError] = useState<FieldError | undefined>({});
  const [state, dispatch, isPending] = useActionState(signInAction, undefined);

  const handleAction = async (formData: FormData) => {
    const validationResult = signinSchema.safeParse(
      Object.fromEntries(formData)
    );

    setFieldError({});
    if (!validationResult.success) {
      setFieldError(validationResult.error.flatten().fieldErrors);
      return;
    }
    dispatch(formData);
  };

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    startTransition(() => {
      handleAction(new FormData(event.currentTarget));
    });
  };

  return (
    <Form action={signInAction} onSubmit={handleSubmit}>
      <div className="grid gap-4">
        <div className="grid gap-2">
          <Label htmlFor="email">Email</Label>
          <Input
            id="email"
            name="email"
            type="email"
            autoComplete="email"
            placeholder="[email protected]"
            required
            disabled={isPending}
          />
          {fieldError?.email && (
            <ul className="text-red-500 text-sm">
              {fieldError.email.map((error, index) => (
                <li key={index}>{error}</li>
              ))}
            </ul>
          )}
        </div>
        <div className="grid gap-2">
          <Label htmlFor="password">Password</Label>
          <div className="flex gap-x-1">
            <Input
              id="password"
              name="password"
              type={showPassword ? "text" : "password"}
              placeholder="********"
              autoComplete="current-password"
              required
              disabled={isPending}
            />
            <Button
              className="rounded-xl"
              type="button"
              variant={"outline"}
              onClick={() => {
                setShowPassword((prev) => !prev);
              }}
              aria-label={showPassword ? "Hide password" : "Show password"}
              aria-pressed={showPassword}
            >
              {showPassword ? <Eye /> : <EyeClosed />}
            </Button>
          </div>
          <div>
            {fieldError?.password && (
              <ol className="text-red-500 text-sm">
                Password must contain:
                {fieldError.password.map((error, index) => (
                  <li key={index} className="pl-2">
                    - {error}
                  </li>
                ))}
              </ol>
            )}
          </div>
        </div>
        {state && (
          <div
            className={cn(
              "p-3 rounded-md flex items-center gap-x-2 text-sm border",
              state?.success
                ? "bg-green-500/20 text-green-500"
                : "bg-destructive/20 text-red-500"
            )}
          >
            {state?.success ? <UserRoundCheck /> : <CircleAlert />}
            <p>{state.message}</p>
          </div>
        )}
        <Button type="submit" disabled={isPending}>
          {isPending && <LoaderPinwheel className="animate-spin" />}
          <LogIn />
          Sign in
        </Button>
      </div>
    </Form>
  );
}

Server Actions

"use server";

import { db } from "@/db";
import { eq } from "drizzle-orm";
import { users } from "@/db/schema";
import { signinSchema } from "@/lib/types/auth-schema";
import bcryptjs from "bcryptjs";

export async function signInAction(_prevState: unknown, formData: FormData) {
  const rawData = Object.fromEntries(formData.entries());

  const result = signinSchema.safeParse(rawData);
  if (!result.success) {
    return {
      message: "Data validation failed",
      success: false,
    };
  }

  const { email, password } = result.data;

  try {
    const [user] = await db
      .select({
        userId: users.userId,
        password: users.password,
        role: users.role,
      })
      .from(users)
      .where(eq(users.email, email));

    if (!user || !user.password) {
      return {
        message: "User not found!",
        success: false,
      };
    }

    const isPasswordValid = await bcryptjs.compare(password, user.password);
    if (!isPasswordValid) {
      return {
        message: "Invalid credentials!",
        success: false,
      };
    }

    return {
      message: "Sign in success!",
      success: true,
    };
  } catch (error) {
    return {
      message: error instanceof Error ? error.message : "Something went wrong!",
      success: false,
    };
  }
}

I tried disabling the JS in browser, and it doesn't work since the useStateAction and zod validation leverages JS.
And that is the problem, it doesn't scale well.

But, along with this I tested with calling the server Action directly, and it works:

Changed the action from signInAction to signInAction2

  <Form action={signInAction2} onSubmit={handleSubmit}>
export async function signInAction2(formData: FormData) {
  const rawData = Object.fromEntries(formData.entries());
  const result = signinSchema.safeParse(rawData);

  console.log(result);
}

now this does work when JS disabled, and print the results in console.

Anyone reading this, understand this in a little depth, please comment and let's discuss.
Thank you.

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.