Validated forms with useFetcher in Remix

Jul 10, 2024
5 min read

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:

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:

  1. To close the Dialog or Modal when the form is successfully submitted.
  2. 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:

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 server return 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

Back to all posts