The formId controls whether a form resets or preserves its state after submission through React's reconciliation mechanism. This provides automatic form reset on success while preserving user input on validation errors.
Conform uses the form's id prop as a cache key:
- Same
id→ React preserves component state (user input maintained) - Different
id→ React remounts component (form resets to defaultValue)
// Loader - Generate new formId
export async function loader(args: LoaderFunctionArgs) {
return {
// Other data...
formId: Date.now(), // Fresh timestamp for each page load
};
}
// Action - Return same formId on validation errors
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const formId = formData.get("formId")?.toString() ?? "";
const submission = parseWithZod(formData, { schema: formSchema });
if (submission.status !== "success") {
return data({
formId, // Return SAME formId to preserve form state
lastResult: submission.reply(),
});
}
// On success, redirect (new loader runs = new formId = form reset)
return redirect("/success-page");
}
// Component - Use formId strategy
export default function MyRoute({ loaderData, actionData }: Route.ComponentProps) {
const formId = `${actionData?.formId || loaderData.formId}`;
const navigation = useNavigation();
const isIdle = navigation.state === "idle";
const lastResult = actionData && "lastResult" in actionData ? actionData.lastResult : null;
const [form, fields] = useForm({
id: `my-form-${formId}`, // Key component: form id uses formId
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
defaultValue: formDefaults,
lastResult: isIdle ? lastResult : null, // Show errors only when idle
onValidate({ formData }) {
return parseWithZod(formData, { schema: formSchema });
},
});
return (
<Form method="POST" {...getFormProps(form)} noValidate>
<input type="hidden" name="formId" value={formId} />
{/* Form fields */}
</Form>
);
}User submits → Action validates → Errors found
→ Action returns { formId: "same-id", lastResult: errors }
→ Component uses same formId
→ Form id unchanged: `my-form-same-id`
→ React keeps component mounted
→ User input preserved + errors displayed
→ User can fix errors without re-entering data
User submits → Action validates → Success
→ Action redirects to same/different page
→ Loader runs → Returns new formId: Date.now()
→ Form id changes: `my-form-1234567890` → `my-form-1234567999`
→ React remounts component (id changed)
→ Form resets with fresh defaultValue
→ User sees clean form with updated data
For boolean checkboxes, follow this pattern:
isSomethingEnabled: z
.union([
z.boolean(),
z.string().transform((val) => {
const normalized = val.toLowerCase().trim();
return normalized === "on";
}),
])
.pipe(z.boolean())
.optional(),<input {...getInputProps(fields.isSomethingEnabled, { type: "checkbox" })} />When working with dynamic arrays (e.g., my items):
const items = fields.items?.getFieldList() ?? [];
{items.map((item, index) => {
const itemFields = item.getFieldset();
return (
<div key={item.key}>
{/* Use item.value ?? item.initialValue for display */}
<DisplayCard item={item.value ?? item.initialValue} />
{/* Use itemFields for form inputs */}
<input {...getInputProps(itemFields.name, { type: "text" })} />
</div>
);
})}Important:
item.value- Current form state (undefined until user edits)item.initialValue- Initial data from loader- Use
item.value ?? item.initialValuefor displaying data
To show errors for a specific array item:
return data({
formId,
lastResult: submission.reply({
fieldErrors: {
[`items[${itemIndex}]`]: ["Error message for this specific item"],
},
}),
});- Always use formId pattern for forms with server-side validation
- Include hidden formId input in every form
- Use
isIdlecheck before showinglastResultto avoid flash during navigation - Normalize data types (dates to strings, booleans to "on"/undefined) for consistent SSR/client rendering
- Use
noValidateon Form to disable browser validation (we use Zod) - Handle checkbox values properly - they send "on" when checked, nothing when unchecked
❌ Don't return different formId on validation errors:
// WRONG - This resets the form
return data({
formId: Date.now(), // New ID loses user input
lastResult: submission.reply(),
});❌ Don't forget the hidden formId input:
// WRONG - Action won't receive formId
<Form method="POST">
{/* Missing: <input type="hidden" name="formId" value={formId} /> */}
</Form>✅ Do return the received formId on errors:
// CORRECT - Preserves user input
const formId = formData.get("formId")?.toString() ?? "";
return data({ formId, lastResult: submission.reply() });