Avoid using React’s `useFormStatus`

Update: 5/28/2024

React 19 now includes React.useActionState, which updates useFormState to address these concerns by adding a pending return value. I fully endorse useActionState!


Server actions are the new shiny toy in Reactland, and for good reason: they make handling form submissions more straightforward than they’ve even been for front-end developers. That being said, they’re still in an experimental state, so the APIs for handling them shouldn’t be taken for granted.

So far, I’ve found the useFormStatus hook, as it currently works, to be all but useless. While its intended purpose is to return the form’s pending state, this comes with a huge caveat: it only works when called from within a form child. Any examples I can find for its use all copy the React documentation, which disables a submit button. Besides problems in its implementation, its current design is flawed:

  • Placing a submit <button> outside a form is valid HTML, as long as the button references the form’s id. The child-component limitation on useFormStatus makes it harder to progressively enhance a form that works natively using browser defaults.
  • Requiring the calling component to be a child of a <form> means that any other form elements, like inputs, can’t be disabled while the form is submitting unless they’re wrapped in their own components. This undoes much of the simplicity gained by using server actions with native HTML forms.

Here’s how I’m tracking a form’s pending state instead:

useForm.ts
import { SyntheticEvent, useTransition } from "react";
import { useFormState } from "react-dom";

export interface UseFormHook<FormState> {
  formState: FormState;
  isPending: boolean;
  formAction: (payload: FormData) => void;
  onSubmit: (event: SyntheticEvent<HTMLFormElement>) => void;
}

export function useForm<FormState>(
  action: (
    formState: Awaited<FormState>,
    formData: FormData
  ) => Promise<FormState>,
  initialState: Awaited<FormState>
): UseFormHook<FormState> {
  const [isPending, startTransition] = useTransition();
  const [formState, formAction] = useFormState(action, initialState);

  function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    startTransition(async () => {
      await formAction(formData);
    });
  }

  return {
    formState,
    isPending,
    formAction,
    onSubmit,
  };
}

Basically, I avoid useFormStatus entirely in favor of useTransition, which tracks the form action’s pending state without blocking other user interactions. Since the transition calls the function provided by useFormState, the formState value updates with the result of the form action.

With this, here’s what a form could look like:

'use client';

import {myAction} from 'src/actions'
import {useForm} from 'src/hooks/useForm'

export default function MyForm() {
  const {isPending, formState, formAction, onSubmit} = useForm(myAction, {});

  return (
    <>
      <form id="myForm" action={formAction} onSubmit={onSubmit}>
        <input name="field1" disabled={isPending} />
      </form>
      <button form="myForm" type="submit" disabled={isPending}>Submit</button>
    </>
  );
}

The only downside is that now forms override their native functionality with an onSubmit handler. But the form will still work without JavaScript enabled since the server action is still being passed to the form’s action attribute.

Given how I’m basically wrapping useFormState, I’m very surprised that this API doesn’t already provide tracking for the server action’s pending state. As these experimental features continue to evolve, I hope the React team takes this feedback under consideration.