Shopify app notes · 07

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.

React Router v7 Polaris components

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

ToolBehaviourUse 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
};
Trap — three things about FormData
  1. No name, no data. An input without a name attribute is invisible to the server. This is the most common "why is it empty?" bug.
  2. Everything is a string. "5", not 5. And Number("") is 0, so an empty field can silently become zero.
  3. 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
};
Choosing between them

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>
  );
}
Returning the values back matters

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.

The states

"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.

Trap — the form submits, but the fields are empty

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" });
Trap — posting to a page route returns HTML

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 name attributes.
  • 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">