Validated forms with useFetcher in Remix
Tags: remix, react, typescript
Published: 2024-07-10 21:05:15

> 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:

```ts
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:

```ts
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`:

```ts
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.

```diff
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.

```diff
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`:

```diff
- if (response.error) {
-  setError(response.error);
-  return;
- }
+ registeredCallback?.(response.error);
```

## The useFetcherForm hook

The values sent to `FetcherFormContext.Provider` are defined in the `FetcherFormContext`

```ts
const FetcherFormContext = createContext<
  [(formData: FormData) => void, (callback?: () => void) => void, boolean, string?]
>([() => null, () => null, false]);
```

Then the hook exposes them in an object structure:

```ts
export const useFetcherForm = () => {
  const [onChange, submitForm, isSubmitted, error] = useContext(FetcherFormContext);
  return {
    onChange,
    submitForm,
    isSubmitted,
    error,
  };
};
```

An example of how it can be defined:

```ts
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