Skip to content

Instantly share code, notes, and snippets.

@jdnichollsc
Last active October 28, 2025 21:52
Show Gist options
  • Select an option

  • Save jdnichollsc/464abbcc182f9b8ab44875932e7948e8 to your computer and use it in GitHub Desktop.

Select an option

Save jdnichollsc/464abbcc182f9b8ab44875932e7948e8 to your computer and use it in GitHub Desktop.
Real-world validation Forms with React-Router/Remix and Conform @conform-to/react; The formId Pattern 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.

Form Validations with Conform

The formId Pattern

Why We Use formId

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.

How It Works

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)

Implementation Pattern

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

Two Scenarios

Scenario 1: Validation Errors (Form Preserves State)

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

Scenario 2: Successful Submission (Form Resets)

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

Checkbox Handling

For boolean checkboxes, follow this pattern:

Schema

isSomethingEnabled: z
  .union([
    z.boolean(),
    z.string().transform((val) => {
      const normalized = val.toLowerCase().trim();
      return normalized === "on";
    }),
  ])
  .pipe(z.boolean())
  .optional(),

Input

<input {...getInputProps(fields.isSomethingEnabled, { type: "checkbox" })} />

Array Fields with getFieldList

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.initialValue for displaying data

Field-Specific Errors

To show errors for a specific array item:

return data({
  formId,
  lastResult: submission.reply({
    fieldErrors: {
      [`items[${itemIndex}]`]: ["Error message for this specific item"],
    },
  }),
});

Best Practices

  1. Always use formId pattern for forms with server-side validation
  2. Include hidden formId input in every form
  3. Use isIdle check before showing lastResult to avoid flash during navigation
  4. Normalize data types (dates to strings, booleans to "on"/undefined) for consistent SSR/client rendering
  5. Use noValidate on Form to disable browser validation (we use Zod)
  6. Handle checkbox values properly - they send "on" when checked, nothing when unchecked

Common Pitfalls

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

References

import {
data,
Form,
useNavigation,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "react-router";
import { parseWithZod } from "@conform-to/zod";
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import type { Route } from "./+types/my-route";
export async function loader(args: LoaderFunctionArgs) {
return {
// Other data here...
   formId: Date.now(),
};
}
const formSchema = z.object({
id: z.string(),
items: z.array(
z.object({
id: z.string(),
name: z.string().nullish(),
}),
),
});
export async function action({ params, 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,
     lastResult: submission.reply(),
   });
}
// Do Magic
// Oops something happened here
// General error
if (error) {
return data({
formId,
lastResult: submission.reply({
formErrors: ["There was an error saving the data. Please try again."],
}),
});
}
// Specific error from an item (while using getFieldList with arrays)
if (error with a specific item of an array) {
const itemIndex = 2;
return data({
formId,
lastResult: submission.reply({
fieldErrors: {
[`items[${itemIndex}]`]: ["Something is wrong with this item"],
},
}),
});
}
// Don't care about the formId anymore...
return data({ ok: true });
}
export default function Index({
loaderData,
actionData,
params,
}: Readonly<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}`,
shouldValidate: "onBlur",
shouldRevalidate: "onInput",
defaultValue: formDefaults, // JSON mapping the default values of your fields...
lastResult: isIdle ? lastResult : null,
onValidate({ formData }) {
return parseWithZod(formData, { schema: formSchema });
},
});
// Maybe having arrays in your amazing form
const itemFields = fields.items?.getFieldList?.() ?? [];
return (
<Form method="POST" {...getFormProps(form)} noValidate>
<input type="hidden" name="formId" value={formId} />
<!-- Your amazing form here -->
<!-- Hidden inputs maybe -->
<input {...getInputProps(fields.id, { type: "hidden" })} />
<button type="submit" intent="save">Save</button>
</Form>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment