Sergio Xalambrí

Persist inputs after a form submit in Remix

Let's say you have a form with a few inputs. You want to persist the inputs' values after submitting a form in case of an error.

The simplest way is to use the <Form> component because that will use fetch to submit the form data to the action, then return the error.

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  // do something with formData
  return json({ error: "Something failed" }, 400);
}

export default function Route() {
  let actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      {actionData?.error ? <p>{actionData.error}</p> : null}
      <input name="value" type="text" />
      <button type="submit">Submit</button>
    </Form>
  );
}

Because we don't reload the page unless we return a redirect response or throw any response, causing the CatchBoundary to render, it will leave the inputs with the values they had before the submission.

But let's say we want to support a no-JS environment. In this scenario, the whole page will reload after the submission, so we need to persist the values of the inputs. We can do this in two ways.

Return them from the action

The simplest way, we have the value on the FormData object so we can add them to the response, then in the UI, get the values from the useActionData hook and use them as defaultValue on the input.

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  // do something with formData
  return json(
    { error: "Something failed", fields: { value: formData.get("value") } },
    400
  );
}

export default function Route() {
  let actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      {actionData?.error ? <p>{actionData.error}</p> : null}
      <input name="value" type="text" defaultValue={actionData?.fields.value} />
      <button type="submit">Submit</button>
    </Form>
  );
}

Another option here is to use the value from the useActionData hook as the initial value of a State.

export default function Route() {
  let actionData = useActionData<typeof action>();

  let [value, setValue] = useState(() => {
    if (actionData?.fields.value) {
      return actionData.fields.value;
    }
    return "";
  });

  return (
    <Form method="post">
      {actionData?.error ? <p>{actionData.error}</p> : null}
      <input
        name="value"
        type="text"
        value={value}
        onChange={(event) => setValue(event.currentTarget.value)}
      />
      <button type="submit">Submit</button>
    </Form>
  );
}

It works better if you already have a State for your input for any reason. The page will reload if there's no JS, and the State will be initialized with the actionData.fields.value. Otherwise, the current step will remain untouched with the same value.

Use session.flash

The second way, which requires more work, is to use session.flash to persist the values of the inputs.

export async function action({ request }: ActionArgs) {
  let session = await sessionStorage.getSession(request.headers.get("Cookie"));
  let formData = await request.formData();

  // do something with formData

  session.flash("error", "Something failed");
  session.flash("fields", { value: formData.get("value") });
  return redirect("/route", {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
  });
}

export async function loader({ request }: LoaderArgs) {
  let session = await sessionStorage.getSession(request.headers.get("Cookie"));
  return json(
    { error: session.get("error"), fields: session.get("fields") ?? "" },
    { headers: { "Set-Cookie": await sessionStorage.commitSession(session) } }
  );
}

export default function Route() {
  let loaderData = useLoaderData<typeof loader>();

  return (
    <Form method="post">
      {loaderData.error ? <p>{loaderData.error}</p> : null}
      <input name="value" type="text" defaultValue={loaderData.fields.value} />
      <button type="submit">Submit</button>
    </Form>
  );
}

And as when returning from the error, you can use the loaderData.fields.value as the initial value of a State.