Loading Feedback for Nested Forms
Disabling a submit button in a deeply nested form component while the form is submitting has traditionally required prop drilling or external state. useFormStatus makes this state accessible without either.
Table of Contents
- 1. The prop drilling problem in forms
- 2. Server Actions: forms without JavaScript handlers
- 3. useFormStatus: syntax and available fields
- 4. Pending button: the most common use case
- 5. Reading form data during the pending state
- 6. Optimistic UI with useFormStatus
- 7. useFormStatus vs. manual state compared
- 8. Limitations and common mistakes
- 9. TypeScript typing and best practices
- 10. Summary
- 11. FAQ
1. The prop drilling problem in forms
Typical React form implementations face a recurring problem: the submit button sits deep in the component hierarchy, but the pending status is managed in the parent component. To disable the button while the form is submitting, isSubmitting has to be passed down as a prop through every intermediate layer. This prop drilling complicates refactoring, makes components rigid, and couples design decisions to implementation details.
The classic counter-approach was to move the pending state into a global store (Redux, Zustand, Jotai) and subscribe to it in the child component. That solves the prop drilling problem, but introduces unnecessary coupling between UI components and the global state layer. A simple form does not need a global state solution just for a simple loading spinner. useFormStatus solves exactly this problem the native React way: the status of the nearest form ancestor becomes directly accessible in the child, without prop drilling and without global state.
useFormStatus becomes especially relevant in the era of Server Actions. React 19 and Next.js 14+ allow setting a form's action attribute to a server function. The form is submitted, the server function processes the data, React updates the state. While the server function is running, the form is in a pending state, and that is exactly what useFormStatus makes visible to child components.
2. Server Actions: forms without JavaScript handlers
Server Actions are a core feature of React 19 that finds wide adoption in Next.js 14 (App Router). Instead of writing an onSubmit handler that calls an API route, a function is set directly as the form's action prop. This function executes on the server, receives the FormData object, and can access databases directly, without a separate API layer. The form even works without JavaScript, as long as the browser submits forms natively.
The pending state during a Server Action is managed internally by React. The form knows that an action is running and sets an internal pending flag. useFormStatus exposes this flag as pending: boolean. In client components rendered inside the form, this status can be read directly and used for UI feedback, without the form container having to pass this status down as a prop.
It is important to understand that Server Actions also work together with client-side state and useActionState (formerly useFormState). The interplay of these hooks makes up the new form ecosystem of React 19: useActionState manages the result state after the action, useFormStatus provides the status during the action, and useOptimistic enables optimistic updates. All three work together and complement one another.
3. useFormStatus: syntax and available fields
useFormStatus is a hook from the react-dom package (not from react). It returns an object with four fields: pending is a boolean indicating whether the enclosing form is currently being submitted. data is a FormData object with the submitted values, or null when there is no pending state. method is the HTTP method of the form (get or post). action is the form's action prop, which can be a URL string or a function (Server Action).
The interface is deliberately kept simple. In most use cases, only pending is needed. The remaining fields are meant for advanced scenarios, such as when a child component wants to display the submitted data during the pending state (optimistic UI). The hook is a pure "read" hook: it does not change any state and has no side effects.
'use client';
import { useFormStatus } from 'react-dom';
// SubmitButton must be a CHILD of the form element, not in the same component
function SubmitButton({ label = 'Save' }: { label?: string }) {
// Reads status from the nearest parent <form>
const { pending, data, method } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
aria-disabled={pending}
className={`px-4 py-2 rounded font-semibold transition-opacity ${
pending ? 'opacity-60 cursor-not-allowed' : 'opacity-100'
}`}
>
{pending ? (
<span className="flex items-center gap-2">
{/* Inline spinner, no external dependency */}
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8v8z" className="opacity-75" />
</svg>
Submitting...
</span>
) : (
label
)}
</button>
);
}
// Server action, runs on the server
async function saveContactForm(formData: FormData) {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// Database or email dispatch here
await new Promise((r) => setTimeout(r, 1500)); // Simulate latency
}
export function ContactForm() {
return (
// action receives the Server Action function
<form action={saveContactForm} className="space-y-4">
<input name="name" type="text" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
{/* SubmitButton reads pending from this form */}
<SubmitButton label="Send message" />
</form>
);
}
4. Pending button: the most common use case
By far the most common use case for useFormStatus is a submit button that disables itself during the pending state and shows visual feedback. The pattern is simple: a dedicated SubmitButton component reads pending via useFormStatus and sets disabled={pending}. This component can be reused in any number of forms without every form having to manage the pending status itself.
What matters here is that the SubmitButton component must be a child of the <form> element, it cannot live in the same component as the <form> element. This is the fundamental limitation of useFormStatus: the hook can only access the status of a parent form, not a form in the same component. If you call useFormStatus in the component that also renders the <form> element, pending will always return false.
Besides disabled, the button should also set aria-disabled and, where relevant, update aria-label, to make the state understandable for screen reader users. A spinner icon without text is not sufficiently accessible; the text should change from "Save" to "Submitting..." so that assistive technologies also perceive the state change.
5. Reading form data during the pending state
The data field of useFormStatus provides the FormData object with the values currently being submitted. This makes it possible to display the entered data already during the loading process, for example the user's name in the feedback text. This pattern is a simple form of optimistic UI: the UI shows the submitted values before the server response even arrives.
A concrete example: a comment form that immediately shows the new comment in the list right after submission, while the server is still responding. The data field contains the comment text (data.get('comment')), and the child component can display this value directly. Once the server response arrives and React re-renders, the real server state is shown and the optimistic state is discarded. For more elaborate optimistic updates, useOptimistic is a better fit, but for simple cases data is sufficient.
'use client';
import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';
// Reads submitted form data during pending state
function CommentPreview() {
const { pending, data } = useFormStatus();
if (!pending || !data) return null;
const comment = data.get('comment') as string;
const author = data.get('author') as string;
// Show optimistic preview while server processes the request
return (
<div className="border border-sky-200 bg-sky-50 rounded-lg p-4 mt-4 opacity-60">
<p className="text-xs text-sky-600 font-semibold mb-1">Saving...</p>
<p className="font-semibold text-sm">{author}</p>
<p className="text-sm text-slate-600">{comment}</p>
</div>
);
}
// Reusable submit button with pending feedback
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className="btn-primary">
{pending ? 'Saving comment...' : 'Post comment'}
</button>
);
}
// Server action
async function addComment(
previousState: { success: boolean } | null,
formData: FormData
) {
'use server';
const comment = formData.get('comment') as string;
const author = formData.get('author') as string;
if (!comment || !author) return { success: false };
// Save to database
await new Promise((r) => setTimeout(r, 1200));
return { success: true };
}
export function CommentForm() {
const [state, formAction] = useActionState(addComment, null);
return (
<div>
{state?.success && (
<p className="text-green-600 font-semibold">Comment saved!</p>
)}
<form action={formAction} className="space-y-3">
<input name="author" type="text" placeholder="Your name" required />
<textarea name="comment" placeholder="Your comment..." required />
<SubmitButton />
{/* Renders optimistic preview during pending state */}
<CommentPreview />
</form>
</div>
);
}
6. Optimistic UI with useFormStatus
Optimistic UI means that the application acts as if an action has already succeeded, before the server response is actually available. This makes interactions feel instant and noticeably increases perceived performance. useFormStatus and the data field form the basis for simple optimistic updates: during the pending state, the submitted data is read directly from FormData and displayed in the UI.
For more complex scenarios, where the optimistic state can diverge from the actual state and must be rolled back in a targeted way, useOptimistic is the right choice. This React 19 hook allows setting a temporary optimistic state that is automatically replaced by the real state once the action completes. useFormStatus and useOptimistic complement each other: useFormStatus for the pending state, useOptimistic for the provisional content.
A practical example: a todo list where a new todo appears immediately in the list as soon as the user clicks "Add", without waiting for the server response. With useOptimistic, the new todo is added to the state, marked with a "saving" flag, and shown grayed out. If the Server Action succeeds, the real todo is merged into the state. If the action fails, the optimistic todo disappears and an error message appears.
7. useFormStatus vs. manual state compared
Before React 19, the standard approach for form feedback was: manage an isSubmitting state in the form component, set it to true in the onSubmit handler, reset it to false after the await, and pass it down as a prop to the submit button. This pattern is understandable, but requires boilerplate and couples the submit button's style to the form implementation.
| Aspect | Manual isSubmitting state | useFormStatus |
|---|---|---|
| Prop drilling | Required for deep hierarchies | No prop drilling |
| Reusability | Button knows the form implementation | Button is completely independent |
| Server Actions | No native support | Natively integrated |
| Boilerplate | useState, handler, cleanup | A single hook call |
| Form data | Separate state required | Directly available via the data field |
The manual approach remains sensible when React 19 is not available, or when forms work with fetch instead of Server Actions. In these cases useFormStatus cannot be used, because the pending state is only set for native form submissions and Server Actions. For purely client-side forms with onSubmit and fetch, manual state is still the right choice.
8. Limitations and common mistakes
The most important limitation of useFormStatus is the parent-child requirement. The hook must be called in a component that is a child element of the <form> element, not in the component that renders the <form> element itself. This requirement is intuitive but easy to forget. The symptom: pending is always false, no matter how long the action takes. The fix is always to extract the SubmitButton into its own component.
A second limitation: useFormStatus is a react-dom hook and only works in browser environments. It cannot be called in Server Components. The component that uses useFormStatus must therefore be marked with the 'use client' directive. This is usually not an issue, since submit buttons are by definition interactive and therefore client components.
A third common mistake: reading data without a null check. The data field is null when there is no pending state. Calling data.get('field') directly without first checking if (!data) results in a runtime error. Always start with if (!pending || !data) return null before accessing data.
'use client';
import { useFormStatus } from 'react-dom';
// WRONG: useFormStatus in the same component as <form>, pending always false
function WrongExample() {
const { pending } = useFormStatus(); // Always false here!
return (
<form action={someAction}>
<button disabled={pending}>Save</button>
</form>
);
}
// RIGHT: SubmitButton is a separate child component
function SubmitButton() {
const { pending } = useFormStatus(); // Reads from parent <form>
return <button type="submit" disabled={pending}>Save</button>;
}
function RightExample() {
return (
<form action={someAction}>
<SubmitButton /> {/* Child of <form>, works correctly */}
</form>
);
}
// RIGHT: null-check before accessing data
function OptimisticPreview() {
const { pending, data } = useFormStatus();
// Always guard against null before accessing data
if (!pending || !data) return null;
const title = data.get('title') as string | null;
if (!title) return null;
return <p>Saving: <strong>{title}</strong></p>;
}
// RIGHT: 'use client' directive is required
// This file must be a Client Component because useFormStatus is a DOM hook
9. TypeScript typing and best practices
useFormStatus is fully covered by the @types/react-dom type definitions. The return type is { pending: boolean; data: FormData | null; method: string | null; action: string | ((formData: FormData) => void | Promise. In TypeScript projects, it is recommended not to manually annotate the return type, the hook is sufficiently typed and TypeScript infers the types correctly on its own.
For reusable submit button components, it is worth defining clear props. The button should accept a label prop for the default text and a pendingLabel prop for the pending text. This makes the component flexible enough for various forms without duplicating the pending logic at every call site. Icon slots via ReactNode props allow different spinners or icons to be used without branching the component.
A frequently overlooked best practice: the submit button should not just be disabled, it should also explicitly set type="submit". If a form contains multiple buttons and none has type="submit" explicitly set, the browser submits the form via the first button when Enter is pressed, which leads to unexpected behavior. In addition, every component that uses useFormStatus should carry the 'use client' directive, even if the overall form otherwise works with Server Components.
10. Summary
useFormStatus solves a genuinely real problem in React form architecture: making the pending status available in child components without prop drilling. The hook is about as simple as it gets, one call, four fields, and it integrates seamlessly into the new Server Action ecosystem of React 19. Submit buttons, loading indicators and optimistic previews can be implemented as completely independent, reusable client components.
The critical points for correct usage: the component with the hook must be a child of the form element, not live in the same component. It must be marked with 'use client'. The data field must be checked against null before it is accessed. And for purely client-side forms with fetch and onSubmit, manual state remains the right choice, useFormStatus only reacts to native form submissions and Server Actions.
useFormStatus, the essentials at a glance
Child component required
useFormStatus must be called in a child component of the form. In the same component as the form element, pending always returns false.
use client required
useFormStatus is a react-dom hook and only works in client components. The file must be marked with 'use client'.
data null check
data is null when there is no pending state. Always guard with if (!pending || !data) return null before accessing form fields.
Server Actions integration
Seamless integration with Server Actions, pending is set automatically while the server function runs, with no manual state management.