Validated forms with useFetcher in Remix

Build a custom 'useFetcherForm' hook to easily handle fetcher requests.

Posted 7/10/20245 minute read

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:

  • 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 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: https://github.com/sjdonado/remix-dashboard/blob/da9445646392626cea065442f7758230b3d8d1fa/app/components/dialog/AssignmentUpdateStatusDialog.tsx#L32C56-L32C70


Read Next