Reputation: 1220
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,
};
}
Upvotes: 4
Views: 1230
Reputation: 33
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
}
Upvotes: 0
Reputation: 1220
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>
);
}
Upvotes: 2