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