DEV Community

sjdonado
sjdonado

Posted on • Originally published at blog.sjdonado.com

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:

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();
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Then the hook exposes them in an object structure:

export const useFetcherForm = () => {
  const [onChange, submitForm, isSubmitted, error] = useContext(FetcherFormContext);
  return {
    onChange,
    submitForm,
    isSubmitted,
    error,
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Demo

Real-world example available on Github: https://github.com/sjdonado/remix-dashboard/blob/da9445646392626cea065442f7758230b3d8d1fa/app/components/dialog/AssignmentUpdateStatusDialog.tsx#L32C56-L32C70

Top comments (0)