Nikolai
Nikolai

Reputation: 672

React Router 6 action not subscribing to isSubmitting from React Hook Form + not working with RTK Query Mutation either

Simplified React component with form:

const itemSchema = z.object({
  title: z.string().max(50),
});

type ItemFormFields = z.infer<typeof itemSchema>;
const {
  register,
  handleSubmit,
  reset,
  setError,
  formState: { isSubmitting, errors }
} = useForm<ItemFormFields>({
  defaultValues: {
    title: "title placehnolder"
  },
  resolver: zodResolver(itemSchema)
});

// RTK's isLoading and error remain not updated for mutation hooks
// but the same work as expected with query hooks done through .initialize() in RRv6 loader
// const [_createItem, { isLoading, error }] = useCreateItemMutation();

const submit = useSubmit();
const actionData = useActionData<typeof itemsAction>();

const hasError = actionData && 'error' in actionData && isString(actionData.error);

useEffect(() => {
  if (hasError) {
    const { error } = actionData;
    setError('root', {
      message: error
    });
  }
}, [actionData, hasError, setError]);

return (
  <div className="new-item">
    <Form
      onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
        handleSubmit((data: ItemFormFields) => {
          submit(data, { method: 'post' });
          reset();
        })(event);
      }}
    >
      <div className="new-item__form-body">
        <div>
          <div>
            <label htmlFor="title">Title:</label>
          </div>
          {errors.title && <div>{errors.title.message}</div>}
        </div>

        <div>
          <div>
            <input
              id="title"
              type="text"
              required
              {...register('title')}
            />
          </div>
        </div>
      </div>

      <div className="new-item__form-footer">
        <Button
          disabled={isSubmitting}
          type="submit"
        >
          {isSubmitting ? 'Loading...' : 'Submit'}
        </Button>
      </div>

      {errors.root && <div>{errors.root.message}</div>}
    </Form>
  </div>
);

React Router Action function:

import { ActionFunction, ActionFunctionArgs, redirect } from 'react-router-dom';
// ...
const fromEntries = <T extends object>(data: FormData) => Object.fromEntries<string | number | File>(data) as T;
    
export const itemsAction = (async ({ request }: ActionFunctionArgs) => {
  const data: FormData = await request.formData();
  const formData: ItemFormFields = fromEntries(data);
  const mutation = store.dispatch(api.endpoints.createItem.initiate(formData));

  try {
    const { id } = await mutation.unwrap();
    return redirect(`/items/${id}`);
  } catch (error) {
    return {
      error: 'Post-Validation error message placeholder' 
    };
  }
}) satisfies ActionFunction;

Everything works as except for React Hook isSubmitting which remains unsubscribed and false/undefined. Same with isLoading, error from const [_createItem, { isLoading, error }] = useCreateItemMutation(); if I try with RTK instead.

The useForm data successfully gets submitted through handleSubmit and RRv6 submit - the action successfully fetches the formData with the correct Zod schema type ItemFormFields. The RTK mutation initiate successfully POSTs a new entity to the database which then returns the id in the response and the RRv6 redirect happens where it successfully fetches through a loader and everything goes smoothly... except for the isSubmitting formState in useForm that remains unsubscribed and RTK's isLoading also remains undefined. isSubmitting works as expected in a regular async example, but once it goes to the action through the void submit from useSubmit from React Router 6 - it never subscribes and never updates to true. I would assume it needs a Promise but RRv6's useSubmit provides with a regular void and does the whole flow behind the scene.

EDIT: Just regular React Router 6 (browser data router) with a route for /new:

{
  path: 'new',
  element: <NewItem />,
  action: itemsAction
}

Upvotes: 1

Views: 172

Answers (1)

Drew Reese
Drew Reese

Reputation: 203218

The useForm's formState.isSubmitting value is only true while the submit handler function is executing, and since it's a synchronous function and only executes two lines it's only submitting for a very short duration. Since the route action is what is doing the work you'll need to queue off the action processing to render any loading UI. You can access this via the useNavigation hook from React-Router-DOM.

See navigation.state:

  • idle - There is no navigation pending.
  • submitting - A route action is being called due to a form submission using POST, PUT, PATCH, or DELETE
  • loading - The loaders for the next routes are being called to render the next page

Form submissions with POST, PUT, PATCH, or DELETE transition through these states:

idle → submitting → loading → idle

You can check that the action is currently not idle:

const navigation = useNavigation();
const isSubmitting = navigation.state !== "idle";

...

<Button disabled={isSubmitting} type="submit">
  {isSubmitting ? "Loading..." : "Submit"}
</Button>

Example:

import {
  useActionData,
  useSubmit,
  useNavigation,
} from "react-router-dom";

...

const {
  register,
  handleSubmit,
  reset,
  setError,
  formState: { errors }
} = useForm<ItemFormFields>({
  defaultValues: {
    title: "title placehnolder"
  }
  resolver: zodResolver(itemSchema)
});

const submit = useSubmit();
const actionData = useActionData<typeof itemsAction>();
const navigation = useNavigation();

const isSubmitting = navigation.state !== "idle";

...

return (
  <div className="new-item">
    <Form
      onSubmit={handleSubmit((data) => {
        submit(data, { method: "post" });
        reset();
      })}
    >
      <div className="new-item__form-body">
        <div>
          <div>
            <label htmlFor="title">Title:</label>
          </div>
          {errors.title && <div>{errors.title.message}</div>}
        </div>

        <div>
          <div>
            <input id="title" type="text" required {...register("title")} />
          </div>
        </div>
      </div>

      <div className="new-item__form-footer">
        <Button disabled={isSubmitting} type="submit">
          {navigation.state !== "idle" ? "Loading..." : "Submit"}
        </Button>
      </div>

      {errors.root && <div>{errors.root.message}</div>}
    </Form>
  </div>
);

Upvotes: 3

Related Questions