skip to Main Content

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,
  };
}

2

Answers


  1. Chosen as BEST ANSWER

    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>
      );
    }
    

  2. I had a similar concern,
    My solution was to simply add default values from useActionState and on my server actions i can return an object the overrides the state with the entered values.

    Here is an example:

    const [state, formAction] = useActionState(action, {
            fields: {
                code: '',
                name: '',
                country: '',
            }
    });
    

    One of the inputs:

    <div>
         <Box 
              name="code" 
              type="text" 
              placeholder="Code" 
              defaultValue={state.fields.code} />
    </div>
    

    It has a default value from the useActionState hook.

    And on my server action:

    try {
            insertCurrency(code, name, country);
        } catch (error) {
            return {
                error: error.message,
                fields: {
                    code, name, country
                }
            }
        }
    

    As mentioned above, the server action returns an object that has fields which matches the initial state set of the hook, the will make the default value set the the values submitted by the user.

    Tool tip: This is the full server action function

    export async function createCurrency(_, formData) {
        const code = formData.get('code');
        const name = formData.get('name');
        const country = formData.get('country');
    
        // ... try catch block
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search