Forms & Submissions
Getting data from a page into your database: choosing how to submit, validating on the server, showing errors, and the small details that make a form feel finished.
The model
A form does not call an API you wrote. It submits to the action in the same file, and the action saves the data.
<Form method="post"> // the page
↓
export const action = ... // same file, runs on the server
↓
loaders re-run automatically // page shows fresh data
No fetch, no API route, no manual refresh. That last step catches people out — you do not update the screen yourself after saving.
Three ways to submit
| Tool | Behaviour | Use for |
|---|---|---|
<Form> | Navigates. URL can change. | Create and edit pages |
useFetcher() | Stays on the page. | Toggles, row deletes, inline saves |
useSubmit() | Navigates, triggered by code. | Submitting without a button click |
Ninety percent of the time the question is just: should the page change after this? Yes → Form. No → useFetcher.
// Form — the browser collects the inputs for you
<Form method="post">
<input name="title" />
<button>Save</button>
</Form>
// fetcher — you pass the data yourself
const fetcher = useFetcher();
fetcher.submit({ intent: "delete", id: "42" }, { method: "post" });
Reading what was sent
export const action = async ({ request }) => {
const formData = await request.formData();
const title = formData.get("title"); // always a string (or null)
const tags = formData.getAll("tag"); // several inputs, same name
const price = Number(formData.get("price")); // convert it yourself
};
- No
name, no data. An input without anameattribute is invisible to the server. This is the most common "why is it empty?" bug. - Everything is a string.
"5", not5. AndNumber("")is0, so an empty field can silently become zero. - Unchecked checkboxes are missing entirely. Not
false— absent.
// checkbox: absent means unchecked
const enabled = formData.get("enabled") !== null;
// number: guard against empty
const raw = formData.get("price")?.toString().trim();
const price = raw ? Number(raw) : null;
if (price !== null && !Number.isFinite(price)) {
return { error: "Price must be a number" };
}
When your data is not flat
FormData is a flat list of name/value pairs. It is a poor fit for nested structures — a list of variants, a tree of options. For those, send JSON instead.
// sending
fetcher.submit(
{ title: "Shirt", variants: [{ color: "red" }, { color: "blue" }] },
{ method: "post", encType: "application/json" },
);
// receiving
export const action = async ({ request }) => {
const data = await request.json();
data.variants[0].color; // "red" — real types preserved
};
FormData for ordinary forms — it works without JavaScript and handles file uploads. JSON when the shape is nested, or when numbers and booleans should stay numbers and booleans.
Validation and showing errors
Validate on the server, always. Browser validation is a convenience for users, not a defence — anyone can bypass it.
Return an object of errors and the page stays put:
export const action = async ({ request }) => {
const { session } = await authenticate.admin(request);
const form = await request.formData();
const title = form.get("title")?.toString().trim() ?? "";
const email = form.get("email")?.toString().trim() ?? "";
const errors = {};
if (!title) errors.title = "Title is required";
if (title.length > 100) errors.title = "Title is too long";
if (email && !email.includes("@")) errors.email = "Enter a valid email";
if (Object.keys(errors).length) {
// send the values back so the user doesn't retype everything
return { errors, values: { title, email } };
}
await db.widget.create({ data: { title, email, shop: session.shop } });
return redirect("/app/widgets");
};
export default function NewWidget() {
const actionData = useActionData();
return (
<Form method="post">
<input name="title" defaultValue={actionData?.values?.title} />
{actionData?.errors?.title && (
<p style={{ color: "crimson" }}>{actionData.errors.title}</p>
)}
<button>Save</button>
</Form>
);
}
Without values, a failed submit clears the form and the user retypes everything. Sending them back and using defaultValue is a two-line change that makes a form feel professional.
Loading states, and stopping double-clicks
If nothing changes when the button is pressed, users press it again — and you create two records.
With <Form>
const nav = useNavigation();
const busy = nav.state !== "idle";
With useFetcher
const f = useFetcher();
const busy = f.state !== "idle";
<button type="submit" disabled={busy}>
{busy ? "Saving…" : "Save"}
</button>
Disabling the button while busy prevents the duplicate, and the changing label tells the user something is happening.
"idle" nothing happening · "submitting" your action is running · "loading" the action finished and loaders are refreshing.
Several actions on one page
Save, delete, duplicate, publish — all submit to the same action. Send an intent so it knows which:
<Form method="post">
<input name="title" />
<button name="intent" value="save">Save</button>
<button name="intent" value="delete">Delete</button>
</Form>
const intent = formData.get("intent");
if (intent === "delete") { /* ... */ }
if (intent === "save") { /* ... */ }
return { error: "Unknown action" }; // always handle the fall-through
Independent buttons in a list
One fetcher shared across many rows makes every row show "deleting". Give each row its own:
function Row({ item }) {
const fetcher = useFetcher(); // one per row
const busy = fetcher.state !== "idle";
return (
<div>
{item.title}
<button
disabled={busy}
onClick={() => fetcher.submit(
{ intent: "delete", id: String(item.id) },
{ method: "post" },
)}
>{busy ? "Deleting…" : "Delete"}</button>
</div>
);
}
Polaris web components need a different approach
Shopify's <s-text-field>, <s-select> and friends are custom elements, not real <input> elements.
Custom elements do not reliably take part in native form collection. Put a name on an <s-text-field>, submit the form, and the server may receive nothing.
The reliable pattern: hold values in React state, then submit them yourself.
const [title, setTitle] = useState("");
const fetcher = useFetcher();
<s-text-field
label="Title"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
<s-button onClick={() => fetcher.submit({ title }, { method: "post" })}>
Save
</s-button>
Note onClick rather than type="submit", and fetcher.submit rather than letting the browser collect fields.
Plain HTML inputs work normally, so mixing is fine — just know which kind you are using.
Submitting to a different route
Sometimes the action lives elsewhere — a shared endpoint used by several pages:
<Form method="post" action="/app/widgets/bulk">
fetcher.submit(data, { method: "post", action: "/app/widgets/bulk" });
If the target route has a default export, you get an HTML document back and fetcher.data is not what you expect.
Shared endpoints belong in their own file with no default export.
A complete edit form
export const loader = async ({ request, params }) => {
const { session } = await authenticate.admin(request);
const widget = await db.widget.findFirst({
where: { id: Number(params.id), shop: session.shop },
});
if (!widget) throw new Response("Not Found", { status: 404 });
return { widget };
};
export const action = async ({ request, params }) => {
const { session } = await authenticate.admin(request);
const form = await request.formData();
const id = Number(params.id);
if (form.get("intent") === "delete") {
await db.widget.deleteMany({ where: { id, shop: session.shop } });
return redirect("/app/widgets");
}
const title = form.get("title")?.toString().trim() ?? "";
if (!title) return { errors: { title: "Title is required" } };
await db.widget.updateMany({
where: { id, shop: session.shop },
data: { title, enabled: form.get("enabled") !== null },
});
return redirect("/app/widgets");
};
export default function EditWidget() {
const { widget } = useLoaderData();
const actionData = useActionData();
const busy = useNavigation().state !== "idle";
return (
<s-page heading="Edit widget">
<Form method="post">
<input name="title" defaultValue={widget.title} />
{actionData?.errors?.title && <p>{actionData.errors.title}</p>}
<input type="checkbox" name="enabled" defaultChecked={widget.enabled} />
<button name="intent" value="save" disabled={busy}>
{busy ? "Saving…" : "Save"}
</button>
<button name="intent" value="delete" disabled={busy}>Delete</button>
</Form>
</s-page>
);
}
Note the small safety details: the loader scopes by shop, the action does too, and updateMany/deleteMany make that scoping possible.
Common problems
The action runs but every field is empty
- Inputs are missing
nameattributes. - You used Polaris web components without state (see above).
- You used a plain
<form>instead of the imported<Form>.
The page reloads and my input is empty again
You returned errors but not the submitted values. Return values and use defaultValue.
Two records get created
A double-click. Disable the button while busy.
My checkbox always saves as true
You are checking Boolean(formData.get("enabled")) against the string "on", which is truthy. Compare with !== null instead.
Nested data arrives as "[object Object]"
FormData turns everything into strings. Send JSON with encType: "application/json" and read it with request.json().
Checklist for every form
- Every input has a
name— or values are held in state and submitted manually. - Validation runs on the server, not only in the browser.
- Errors and submitted values are returned together, so nothing is retyped.
- The submit button is disabled while busy, with a changed label.
- Numbers are converted and checked; empty fields do not become
0. - Checkboxes are read as "present or absent", not true/false.
- Database reads and writes are scoped by
shop.
Cheat sheet
# submit
<Form method="post"> navigates
useFetcher().submit(data, {...}) stays put
# read in the action
await request.formData() normal forms
await request.json() encType: "application/json"
formData.get / getAll single / multiple
# gotchas
no name attribute → field missing
everything is a string
unchecked checkbox → absent, not false
Polaris components → use state + fetcher.submit
# feedback
useNavigation().state for <Form>
fetcher.state for fetchers
disabled={busy} stops double submits
# many buttons
<button name="intent" value="delete">