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.