How toProgressively Enhance the useFetcher Hook in React Router

If you're using multiple forms on the same route, you may use the useFetcher hook, which also gives you a Form component.

This component works the same way as the global Form from React Router but uses the fetcher, which allows you to render one Form per item in a list and have each one keep its result.

But what happens if you want to support no-JS users? The fetcher.Form will still work because it renders an actual <form> tag, but our action will most likely return a data response which the user will not know how to use.

So we need to return a redirect from the action instead. But now our list will not work as before.

There's a way thought to support both scenarios. By adding a hidden input, we can let the server know whether the user has JS enabled.

import { useHydrated } from "remix-utils/use-hydrated";
import { useFetcher } from "react-router";

function Route() {
  let fetcher = useFetcher();
  let isHydrated = useHydrated();

  return (
    <fetcher.Form method="post">
      <input type="hidden" name="no-js" value={String(!isHydrated)} />
      <button type="submit">Submit</button>
    </fetcher.Form>
  );
}

Now, our form data will travel with a no-js=true or no-js=false. The value will change after the app is hydrated, meaning our useFetcher is working correctly and not just when the user has JS enabled.

Unlike <noscript> which only detects JavaScript being disabled, useHydrated detects if React has successfully hydrated, catching scenarios like JavaScript failing to load, JavaScript errors preventing hydration, or slow-loading JavaScript assets.

Finally, in our actions, we need to handle this.

import type { ActionFunctionArgs } from "react-router";
import { redirectBack } from "remix-utils/redirect-back";
import { z } from "zod";

export async function action({ request }: ActionFunctionArgs) {
  let formData = await request.formData();

  // you can replace Zod with any other parsing library, or your checks
  let noJS = z
    .string()
    // convert "true" to boolean, treat any other value as false
    .transform((v) => v === "true")
    .pipe(z.boolean())
    .nullable() // allow it to be null
    .default(true) // default to true (support the worst scenario)
    .parse(formData.get("no-js")); // read from formData

  let result = await doSomething();

  if (noJS) {
    let session = await sessionStorage.getSession(
      request.headers.get("Cookie"),
    );
    // save anything you want to send to the user with session.flash
    session.flash("someKey", result);
    // redirect the user where it was before
    return redirectBack(request, {
      // provide a fallback if it's not possible to detect where the user was
      fallback: "/where/the/user/may/have/been/before",
      headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
    });
  }

  // return your
  return data(result, { status: 201 });
}

By doing this, we can still support no-JS users on our app and provide an improved experience for most users with JS.