reactjsformsnext.jszodserver-action

How to prevent Form Field Reset on Validation Error When Using useActionState and Zod Validation in React 19/Next.js 15


I'm working with React 19 and Next.js 15. I want to create a form that lets users update the payment amount and currency. Each currency has a different maximum payment limit, and if a user enters a value over that limit, the form should display an error message without resetting other fields. I'm using useActionState for handling form actions and Zod for data validation.

Expected User Flow

  1. User opens the form with a default payment value of 100 Euros. enter image description here
  2. User selects "Yen" as the currency and enters 200 as the payment value. enter image description here
  3. The form displays an error message indicating that the payment exceeds the allowed maximum for Yen. enter image description here
  4. User only has to update the payment value (without re-selecting the currency).

Issue

Currently, when the form displays a validation error: enter image description here

  1. The currency resets to the first option in the currency list (instead of retaining the user’s selection) - not even to the default of the state?.
  2. The user has to re-select the currency before changing the payment amount.

How can I prevent this reset, so the currency selection persists on validation errors? I’d like to keep using useActionState and server actions for handling form submission and validation.

Code

Below is a minimal code example that reproduces this issue.i

Page:

"use client";
import React, { useActionState, useState } from "react";
import { useFormStatus } from "react-dom";
import { currencies } from "./data-schema";
import { actionPaymentSubmit } from "./actionPaymentSubmit";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button type="submit">{pending ? "Pending..." : "Submit"}</button>;
}

export default function Home() {
  const [payment, setPayment] = useState(100);
  const [currency, setCurrency] = useState("EUR");
  const [state, formAction] = useActionState(actionPaymentSubmit, {
    data: {
      payment,
      currency,
    },
  });

  return (
    <main>
      <form action={formAction}>
        <label htmlFor="payment">Payment</label>
        <input
          id="payment_ammount"
          min="0"
          type="number"
          name="payment"
          value={payment}
          onChange={(e) => setPayment(Number(e.target.value))}
        />
        <label htmlFor="currency">Currency</label>
        <select
          key={currency}
          id="currency"
          name="currency"
          value={currency}
          onChange={(e) => setCurrency(e.target.value)}
        >
          {currencies.map((currency) => (
            <option key={currency.value} value={currency.value}>
              {currency.label}
            </option>
          ))}
        </select>
        <SubmitButton />
        {state.errors?.payment && <div>{state.errors.payment}</div>}
        {state.message && <div>{state.message}</div>}
      </form>
    </main>
  );
}

Data:

import { z } from "zod";

export const currencies = [
  { label: "US Dollar", value: "USD" },
  { label: "Euro", value: "EUR" },
  { label: "British Pound", value: "GBP" },
  { label: "Japanese Yen", value: "JPY" },
  { label: "Australian Dollar", value: "AUD" },
];

export const PaymentSchema = z
  .object({
    payment: z.number().int().positive(),
    currency: z.enum(currencies.map((currency) => currency.value)),
  })
  .superRefine((data, ctx) => {
    const maxPayments = {
      USD: 10,
      EUR: 90,
      GBP: 80,
      JPY: 100,
      AUD: 120,
    };

    const maxPayment = maxPayments[data.currency];

    if (data.payment > maxPayment) {
      ctx.addIssue({
        code: "custom",
        path: ["payment"],
        message: `The maximum payment for ${data.currency} is ${maxPayment}.`,
      });
    }
  });

Action:

"use server";

import { PaymentSchema } from "./data-schema";

export async function actionPaymentSubmit(previousState, formData) {
  await new Promise((resolve) => setTimeout(resolve, 300));

  const paymentData = {
    currency: formData.get("currency"),
    payment: Number(formData.get("payment")),
  };

  const validated = PaymentSchema.safeParse(paymentData);

  if (!validated.success) {
    const errors = validated.error.issues.reduce((acc, issue) => {
      acc[issue.path[0]] = issue.message;
      return acc;
    }, {});
    return {
      errors,
      data: paymentData,
    };
  }

  return {
    message: "Payment was done!",
    data: paymentData,
  };
}

Solution

  • As it usually happens, the moment you fully formulate your question the idea how to solve it comes to mind =) So I finally managed to do it. Nice thing is that we can completely get rid of useState and let the actionState control the defaultValue, which resets to last selected currency. Here is the solution:

    "use client";
    import React, { useActionState } from "react";
    import { useFormStatus } from "react-dom";
    import { currencies } from "./data-schema";
    import { actionPaymentSubmit } from "./actionPaymentSubmit";
    
    function SubmitButton() {
      const { pending } = useFormStatus();
      return <button type="submit">{pending ? "Pending..." : "Submit"}</button>;
    }
    
    export default function Home() {
      const [state, formAction] = useActionState(actionPaymentSubmit, {
        data: {
          payment: 100,
          currency: "EUR",
        },
      });
    
      return (
        <main>
          <form action={formAction}>
            <label htmlFor="payment">Payment</label>
            <input
              id="payment_ammount"
              min="0"
              type="number"
              name="payment"
              defaultValue={state.data.payment}
            />
            <label htmlFor="currency">Currency</label>
            <select
              key={state.data.currency}
              id="currency"
              name="currency"
              defaultValue={state.data.currency}
            >
              {currencies.map((currency) => (
                <option key={currency.value} value={currency.value}>
                  {currency.label}
                </option>
              ))}
            </select>
            <SubmitButton />
            {state.errors?.payment && <div>{state.errors.payment}</div>}
            {state.message && <div>{state.message}</div>}
          </form>
        </main>
      );
    }