Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 051 / 100

useFormStatus: React & Next.js best practices explained

Pending state for any submit button inside a form.

Expona Admin9 min read

Understanding useFormStatus in React & Next.js

Forms are everywhere in modern web applications, but handling submission states across nested components has always been friction. The useFormStatus hook changes that by letting any child component read the pending state of its parent form—no prop drilling, no context setup overhead. In this guide, we'll explore useFormStatus: React & Next.js best practices explained through production patterns we've refined across dozens of projects.

Read Pending from the Parent Form

The core power of useFormStatus is its simplicity: it directly reads the pending state of the nearest enclosing <form> element. When you call the hook, you get an object with a pending boolean that flips to true while the form is submitting.

Here's the fundamental pattern:

tsx
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

export default function CheckoutForm() {
  return (
    <form action={submitOrder}>
      <input type="text" name="cardNumber" required />
      <SubmitButton />
    </form>
  );
}

The pending state automatically becomes true when the form begins submission and false when it completes. This works whether you're using Server Actions, traditional form submission, or JavaScript handlers. The hook introspects the DOM tree and finds the closest form ancestor, making it work across deeply nested component trees without any manual wiring.

No Prop Drilling of Loading Flags

Before useFormStatus, teams typically managed form submission states through one of two painful patterns: either prop drilling boolean flags down multiple levels, or creating a context provider that wrapped the form. Both approaches added boilerplate and made refactoring risky.

Consider a typical e-commerce checkout flow where you have:

  • A CheckoutPage that manages the form state
  • A BillingSection that renders billing fields
  • A PaymentMethod component inside billing
  • A SubmitButton nested even deeper in the component tree

Without useFormStatus, you'd pass isLoading through every level:

tsx
// The old way - prop drilling nightmare
function CheckoutPage() {
  const [isLoading, setIsLoading] = useState(false);
  
  async function handleSubmit(formData) {
    setIsLoading(true);
    try {
      await processPayment(formData);
    } finally {
      setIsLoading(false);
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <BillingSection isLoading={isLoading} />
    </form>
  );
}

function BillingSection({ isLoading }) {
  return (
    <div>
      <PaymentMethod isLoading={isLoading} />
    </div>
  );
}

function PaymentMethod({ isLoading }) {
  return <SubmitButton isLoading={isLoading} />;
}

function SubmitButton({ isLoading }) {
  return <button disabled={isLoading}>Pay</button>;
}

With useFormStatus, each component that needs to know about form submission can read it directly:

tsx
// The modern way - no prop drilling
function CheckoutPage() {
  async function handleSubmit(formData) {
    await processPayment(formData);
  }
  
  return (
    <form action={handleSubmit}>
      <BillingSection />
    </form>
  );
}

function BillingSection() {
  return (
    <div>
      <PaymentMethod />
    </div>
  );
}

function PaymentMethod() {
  return <SubmitButton />;
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>Pay</button>;
}

This approach scales much better. When you need to show a loading spinner in the billing section, add a loader component that also uses the hook. When you need to disable input fields during submission, apply it directly where needed. Each component becomes self-contained and doesn't require parent coordination.

Must Be Used in a Child of the Form

A critical constraint: useFormStatus only works inside a component that is a descendant of a <form> element. If you try to use it in a sibling component, a parent of the form, or anywhere outside the form tree, it will throw an error or return undefined pending state.

This is by design. The hook uses React's internal context to track the form, which means it needs to be rendered as part of the form's component hierarchy. Here's what works and what doesn't:

tsx
// ✅ Works - SubmitButton is inside the form tree
function ValidForm() {
  return (
    <form action={handleSubmit}>
      <input name="email" />
      <SubmitButton />
    </form>
  );
}

// ✅ Also works - nested deeply but still a descendant
function ValidFormNested() {
  return (
    <form action={handleSubmit}>
      <fieldset>
        <legend>User Info</legend>
        <div>
          <div>
            <SubmitButton />
          </div>
        </div>
      </fieldset>
    </form>
  );
}

// ❌ Doesn't work - SubmitButton is outside the form
function InvalidFormSibling() {
  return (
    <>
      <form action={handleSubmit}>
        <input name="email" />
      </form>
      <SubmitButton />
    </>
  );
}

// ❌ Doesn't work - trying to use in the form itself
function InvalidFormParent() {
  const { pending } = useFormStatus(); // Error!
  
  return (
    <form action={handleSubmit}>
      <input name="email" />
    </form>
  );
}

If you need to show loading state outside the form (like a progress bar in a modal or a floating notification), create a wrapper component inside the form that controls external state, or use a portal to render the UI while keeping the hook call inside the form tree.

Pairs Naturally with Server Actions

useFormStatus was designed hand-in-hand with Next.js Server Actions. When you use a Server Action as the form's action prop, the hook automatically detects submission and updates pending state. This eliminates the need for manual loading state management in most cases.

Here's a real-world example from a SaaS settings page:

tsx
// app/settings/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function updateUserProfile(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  
  // Validate and update in database
  await db.users.update({ name, email });
  
  revalidatePath('/settings');
  
  return { success: true };
}

// app/settings/profile-form.tsx
'use client';

import { useFormStatus } from 'react-dom';
import { updateUserProfile } from './actions';

function SaveButton() {
  const { pending } = useFormStatus();
  
  return (
    <button 
      type="submit" 
      disabled={pending}
      className={pending ? 'opacity-50' : ''}
    >
      {pending ? 'Saving...' : 'Save Profile'}
    </button>
  );
}

export default function ProfileForm({ user }) {
  return (
    <form action={updateUserProfile}>
      <div>
        <label htmlFor="name">Name</label>
        <input 
          id="name"
          type="text" 
          name="name" 
          defaultValue={user.name} 
          required 
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input 
          id="email"
          type="email" 
          name="email" 
          defaultValue={user.email} 
          required 
        />
      </div>
      <SaveButton />
    </form>
  );
}

The flow is seamless: user clicks save, pending becomes true instantly, the Server Action runs, and pending returns to false when complete. No useState hooks, no manual state toggles, no race conditions.

You can also use useFormStatus with traditional form submission or JavaScript event handlers by triggering form submission programmatically, though Server Actions are the most natural fit.

Practical Real-World Scenarios

Let's look at three patterns from production applications:

Pattern 1: Multi-Step Form with Progressive Submission

tsx
'use client';

import { useFormStatus } from 'react-dom';
import { submitStep } from './actions';

function StepIndicator() {
  const { pending } = useFormStatus();
  
  return (
    <div className="step-indicator">
      {pending && <span>Processing...</span>}
    </div>
  );
}

function FormFields({ step }) {
  const { pending } = useFormStatus();
  
  return (
    <fieldset disabled={pending}>
      {step === 1 && (
        <>
          <input name="firstName" />
          <input name="lastName" />
        </>
      )}
      {step === 2 && (
        <>
          <input name="address" />
          <input name="city" />
        </>
      )}
    </fieldset>
  );
}

export default function MultiStepForm() {
  return (
    <form action={submitStep}>
      <StepIndicator />
      <FormFields step={1} />
      <SubmitButton />
    </form>
  );
}

Pattern 2: Disabling All Inputs During Submission

tsx
function FormFieldset({ children }) {
  const { pending } = useFormStatus();
  
  return (
    <fieldset disabled={pending}>
      {children}
    </fieldset>
  );
}

export default function ContactForm() {
  return (
    <form action={submitContact}>
      <FormFieldset>
        <input type="text" name="subject" />
        <textarea name="message" />
        <button type="submit">Send</button>
      </FormFieldset>
    </form>
  );
}

Pattern 3: Loading Spinner in a Dedicated Component

tsx
function FormSpinner() {
  const { pending } = useFormStatus();
  
  if (!pending) return null;
  
  return (
    <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50">
      <div className="spinner" />
    </div>
  );
}

export default function CheckoutForm() {
  return (
    <form action={processCheckout}>
      <input name="cardNumber" />
      <button type="submit">Complete Purchase</button>
      <FormSpinner />
    </form>
  );
}

Common Mistakes

  • Calling the hook outside the form tree: The most common error. If you get "useFormStatus must be called from a component that is a descendant of a form," verify that your component using the hook is actually rendered inside the <form> element.
  • Mixing with client-side event handlers: If you use onSubmit with JavaScript event handlers instead of Server Actions, the hook still works, but you need to actually trigger form submission for pending to update. Calling a function without form submission won't trigger the state change.
  • Expecting pending to work with multiple forms: useFormStatus only reads from the nearest enclosing form. If you have nested forms (rare but possible), each child will only track its immediate parent form.
  • Forgetting the 'use client' directive: useFormStatus is a client hook, so any component using it must be in a client component. If you forget the directive, Next.js will error.
  • Setting loading state manually when using Server Actions: Resist the urge to add useState for loading flags when you already have useFormStatus. You'll create duplicate state and race conditions. Trust the hook.

Key Takeaways

  • useFormStatus provides automatic pending state for any form submit operation without requiring props or context setup.
  • The hook must be called in a component rendered as a descendant of the <form> element it tracks.
  • It eliminates prop drilling for loading flags across nested component hierarchies, making forms more maintainable.
  • Pairing useFormStatus with Server Actions creates a frictionless submission UX with minimal boilerplate.
  • Use fieldset disabled={pending} to easily prevent input during submission rather than disabling individual fields.
  • The hook handles both Server Actions and traditional form submission, but shines with Server Actions in Next.js projects.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles