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
- User opens the form with a default payment value of 100 Euros.
- User selects "Yen" as the currency and enters 200 as the payment value.
- The form displays an error message indicating that the payment exceeds the allowed maximum for Yen.
- User only has to update the payment value (without re-selecting the currency).
Issue
Currently, when the form displays a validation error:
- 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?.
- 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
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:
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 thestate
with the entered values.Here is an example:
One of the inputs:
It has a default value from the
useActionState
hook.And on my server action:
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