Validated forms with useFetcher in Remix
Build a custom 'useFetcherForm' hook to easily handle fetcher requests.
Interacting with the server without using window.navigation
significantly improves the user experience. E.g: Login forms within a dialog box or modal, optimistic UI forms or submitting multiple forms within a complex view.
If you are not familiar with Remix or the useFetcher hook, please refer to:
- https://remix.run/docs/en/main/hooks/use-fetcher
- https://remix.run/docs/en/main/discussion/form-vs-fetcher
The FetcherForm Provider
The Remix fetcher
object contains three primary attributes: fetcher.state
, fetcher.data
and the method fetcher.submit
. To interact with them we will use React.useEffect
.
This post will show how to create a provider that manages the state of a fetcher submitted form, along with a minimal custom hook:
const { onChange, submitForm, isSubmitted, error } = useFetcherForm();
Let’s break down the FetcherFormProvider props one by one.
1) onChange
External inputs will notify through this method to change the internal state of the provider, it receives a FormData
argument:
export default function FetcherFormProvider({
action,
method,
children,
}: {
action: string;
method: SubmitOptions['method'];
children: React.ReactNode;
}) {
const [formData, setFormData] = useState<FormData>();
[...]
return (
<FetcherFormContext.Provider
value={[
(formData: FormData) => {
setFormData(formData);
},
[...]
]}
>
{children}
</FetcherFormContext.Provider>
);
}
2) submitForm
Since the formData
was already captured by the onChange
method, the request can be send by calling fetcher.submit
:
export default function FetcherFormProvider({
action,
method,
children,
}: {
action: string;
method: SubmitOptions['method'];
children: React.ReactNode;
}) {
const fetcher = useFetcher();
const [formData, setFormData] = useState<FormData>();
[...]
return (
<FetcherFormContext.Provider
value={[
(formData: FormData) => {
setFormData(formData);
},
() => {
if (formData) {
fetcher.submit(formData, {
method,
action,
});
}
},
[...]
]}
>
{children}
</FetcherFormContext.Provider>
);
}
3) isSubmitted
Submitting the form is an asynchronous operation. There is a separate variable to listen to the form submitted event: isSubmitted
. This is helpful in the following cases:
- To close the Dialog or Modal when the form is successfully submitted.
- To check from outside that the form was submitted and/or the request returned an OK status.
export default function FetcherFormProvider({
action,
method,
children,
}: {
action: string;
method: SubmitOptions['method'];
children: React.ReactNode;
}) {
const fetcher = useFetcher();
+ const [isSubmitted, setIsSubmitted] = useState(false);
[...]
const [formData, setFormData] = useState<FormData>();
useEffect(() => {
const response = fetcher.data as { error: string } | undefined;
+ if (isSubmitted || error) return;
if (fetcher.state === 'loading' && response) {
[...]
+ setIsSubmitted(true);
}
}, [fetcher, action, formData, isSubmitted, error]);
return (
<FetcherFormContext.Provider
value={[
(formData: FormData) => {
setFormData(formData);
},
() => {
if (formData) {
fetcher.submit(formData, {
method,
action,
});
}
},
+ isSubmitted,
[...]
]}
>
{children}
</FetcherFormContext.Provider>
);
}
4) error
One drawback of using Remix useFetcher
is the lack of a straightforward error handling method. There are proposals in progress to provide a more streamlined error handling approach:
- https://github.com/remix-run/remix/discussions/4645
- https://github.com/remix-run/react-router/discussions/10013
As a workaround we can rely on fetcher.state
to check if the request is complete and get the message with fetcher.data
by defining a common structure between the client and server actions.
The highlights
const response = fetcher.data as { error: string } | undefined;
-> defines the JSON response structure to be received form the serverreturn json({ error: new Error() }, { status: 400 } );
if (fetcher.state === 'loading' && response) {
-> checks if the request is completed and a response is available.
export default function FetcherFormProvider({
action,
method,
children,
}: {
action: string;
method: SubmitOptions['method'];
children: React.ReactNode;
}) {
const fetcher = useFetcher();
const [isSubmitted, setIsSubmitted] = useState(false);
+ const [error, setError] = useState<string>();
const [formData, setFormData] = useState<FormData>();
useEffect(() => {
const response = fetcher.data as { error: string } | undefined;
if (isSubmitted || error) return;
if (fetcher.state === 'loading' && response) {
+ if (response.error) {
+ setError(response.error);
+ return;
+ }
setIsSubmitted(true);
}
}, [fetcher, action, formData, isSubmitted, error]);
return (
<FetcherFormContext.Provider
value={[
(formData: FormData) => {
setFormData(formData);
+ setError(undefined);
},
() => {
if (formData) {
fetcher.submit(formData, {
method,
action,
});
}
},
isSubmitted,
+ error,
]}
>
{children}
</FetcherFormContext.Provider>
);
}
Optionally, the error state management can be sent as a callback. This can be received in the onSubmit
function and registered as state. For example, using a registeredCallback
:
- if (response.error) {
- setError(response.error);
- return;
- }
+ registeredCallback?.(response.error);
The useFetcherForm hook
The values sent to FetcherFormContext.Provider
are defined in the FetcherFormContext
const FetcherFormContext = createContext<
[(formData: FormData) => void, (callback?: () => void) => void, boolean, string?]
>([() => null, () => null, false]);
Then the hook exposes them in an object structure:
export const useFetcherForm = () => {
const [onChange, submitForm, isSubmitted, error] = useContext(FetcherFormContext);
return {
onChange,
submitForm,
isSubmitted,
error,
};
};
An example of how it can be defined:
export function AssignmentUpdateStatusDialogButton({
assignmentId,
status,
}: {
assignmentId: string;
status: AssignmentStatus;
}) {
const [isAttached, setIsAttached] = useState(false);
const dialog = useRef<HTMLDialogElement>();
useEffect(() => {
if (isAttached) {
dialog.current?.showModal();
}
}, [isAttached, dialog]);
return (
<ClientOnly>
{() => (
<>
{isAttached &&
createPortal(
<FetcherFormProvider
action={`/assignments/${assignmentId}/status`}
method="post"
>
<AssignmentUpdateStatusDialog
ref={dialog}
[...]
setIsAttached={setIsAttached}
/>
</FetcherFormProvider>,
document.body
)}
<button
type="button"
className="cursor-pointer leading-none"
onClick={() => setIsAttached(true)}
>
<AssignmentStatusBadge status={status} />
</button>
</>
)}
</ClientOnly>
);
}
Demo
Real-world example available on Github: https://github.com/sjdonado/remix-dashboard/blob/da9445646392626cea065442f7758230b3d8d1fa/app/components/dialog/AssignmentUpdateStatusDialog.tsx#L32C56-L32C70