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.
Currently, when the form displays a validation error:
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.
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,
};
}
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>
);
}