When users are in the middle of a workflow, like filling out an important form, you may want to prevent them from navigating away from the page.
This example will show:
Add a route with the form, we'll use a "contact" route for this example:
import {
type RouteConfig,
index,
route,
} from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("contact", "routes/contact.tsx"),
] satisfies RouteConfig;
Add the form to the contact route module:
import { useFetcher } from "react-router";
import type { Route } from "./+types/contact";
export async function action({
request,
}: Route.ActionArgs) {
let formData = await request.formData();
let email = formData.get("email");
let message = formData.get("message");
console.log(email, message);
return { ok: true };
}
export default function Contact() {
let fetcher = useFetcher();
return (
<fetcher.Form method="post">
<p>
<label>
Email: <input name="email" type="email" />
</label>
</p>
<p>
<textarea name="message" />
</p>
<p>
<button type="submit">
{fetcher.state === "idle" ? "Send" : "Sending..."}
</button>
</p>
</fetcher.Form>
);
}
To track the dirty state of the form, we'll use a single boolean and a quick form onChange handler. You may want to track the dirty state differently but this works for this guide.
export default function Contact() {
let [isDirty, setIsDirty] = useState(false);
let fetcher = useFetcher();
return (
<fetcher.Form
method="post"
onChange={(event) => {
let email = event.currentTarget.email.value;
let message = event.currentTarget.message.value;
setIsDirty(Boolean(email || message));
}}
>
{/* existing code */}
</fetcher.Form>
);
}
import { useBlocker } from "react-router";
export default function Contact() {
let [isDirty, setIsDirty] = useState(false);
let fetcher = useFetcher();
let blocker = useBlocker(
useCallback(() => isDirty, [isDirty])
);
// ... existing code
}
While this will now block a navigation, there's no way for the user to confirm it.
This uses a simple div, but you may want to use a modal dialog.
export default function Contact() {
let [isDirty, setIsDirty] = useState(false);
let fetcher = useFetcher();
let blocker = useBlocker(
useCallback(() => isDirty, [isDirty])
);
return (
<fetcher.Form
method="post"
onChange={(event) => {
let email = event.currentTarget.email.value;
let message = event.currentTarget.message.value;
setIsDirty(Boolean(email || message));
}}
>
{/* existing code */}
{blocker.state === "blocked" && (
<div>
<p>Wait! You didn't send the message yet:</p>
<p>
<button
type="button"
onClick={() => blocker.proceed()}
>
Leave
</button>{" "}
<button
type="button"
onClick={() => blocker.reset()}
>
Stay here
</button>
</p>
</div>
)}
</fetcher.Form>
);
}
If the user clicks "leave" then blocker.proceed()
will proceed with the navigation. If they click "stay here" then blocker.reset()
will clear the blocker and keep them on the current page.
If the user doesn't click either "leave" or "stay here", then then submits the form, the blocker will still be active. Let's reset the blocker when the action resolves with an effect.
useEffect(() => {
if (fetcher.data?.ok) {
if (blocker.state === "blocked") {
blocker.reset();
}
}
}, [fetcher.data]);
While unrelated to navigation blocking, let's clear the form when the action resolves with a ref.
let formRef = useRef<HTMLFormElement>(null);
// put it on the form
<fetcher.Form
ref={formRef}
method="post"
onChange={(event) => {
// ... existing code
}}
>
{/* existing code */}
</fetcher.Form>;
useEffect(() => {
if (fetcher.data?.ok) {
// clear the form in the effect
formRef.current?.reset();
if (blocker.state === "blocked") {
blocker.reset();
}
}
}, [fetcher.data]);
Alternatively, if a navigation is currently blocked, instead of resetting the blocker, you can proceed through to the blocked navigation.
useEffect(() => {
if (fetcher.data?.ok) {
if (blocker.state === "blocked") {
// proceed with the blocked navigation
blocker.proceed();
} else {
formRef.current?.reset();
}
}
}, [fetcher.data]);
In this case the user flow is: