React|Built-in Hook Mastery

useActionState & useFormStatus: The Revolution of Form Management

4
useActionState & useFormStatus: The Revolution of Form Management

Chris is building a nickname change page.

It is a very simple feature. You type a new nickname in the input field, click the 'Change' button, and send it to the server.

However, to implement this simple feature, Chris is declaring as many as three states (useState) and writing a handler function covered in try-catch-finally.

typescript
// ❌ Chris's exhausting daily routine (Boilerplate)
function NicknameForm() {
  const [nickname, setNickname] = useState('');
  const [isLoading, setIsLoading] = useState(false); // Loading state
  const [error, setError] = useState<string | null>(null); // Error state
  const [success, setSuccess] = useState(false); // Success state

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
    setSuccess(false);

    try {
      await updateNickname(nickname);
      setSuccess(true);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false); // Loading finished
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={nickname} onChange={e => setNickname(e.target.value)} />
      <button disabled={isLoading}>
        {isLoading ? 'Changing...' : 'Change'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}

"Is this mess of code really necessary just to make one form? The logic for handling loading and catching errors is the same every time..."

The React team was aware of this pain. React 19 introduces useActionState and useFormStatus, which automate form submission and asynchronous state management. It is time to escape the hell of useState and try-catch.

1. useActionState: Automatically Managing Action Results

useActionState is a hook that wraps an asynchronous function (action) and automatically manages its execution result (state) and progress status (pending).

(Note: It was called useFormState in the early Canary versions, but was renamed to useActionState to indicate it can be used for general actions beyond just forms.)

Usage

typescript
const [state, formAction, isPending] = useActionState(actionFn, initialState);
  • state: The latest value returned by the action function (e.g., success message or error object).
  • formAction: The function to connect to <form action={...}> or a button.
  • isPending: A true/false value indicating if the action is running. (Automatic loading handling!)
  • Refactoring: Deleting useState

    Let's change Chris's code. First, separate the asynchronous logic into a pure function.

    typescript
    // actions.ts
    // Receives previous state (prevState) and form data (formData).
    export async function updateNicknameAction(prevState: any, formData: FormData) {
      const nickname = formData.get('nickname') as string;
      
      try {
        await updateNickname(nickname); // API Call
        return { success: true, message: "Change complete!" };
      } catch (err) {
        return { success: false, message: "Error occurred: " + err.message };
      }
    }

    Now, in the component, you can wipe out useState and use just useActionState.

    typescript
    // NicknameForm.tsx
    import { useActionState } from 'react';
    import { updateNicknameAction } from './actions';
    
    function NicknameForm() {
      // ✅ Manage loading, error, and result states all at once
      const [state, formAction, isPending] = useActionState(updateNicknameAction, null);
    
      return (
        <form action={formAction}>
          <input name="nickname" required />
          
          {/* isPending automatically toggles true/false */}
          <button disabled={isPending}>
            {isPending ? 'Changing...' : 'Change'}
          </button>
    
          {state?.message && <p>{state.message}</p>}
        </form>
      );
    }

    The try-catch is gone, and so is setIsLoading(true). React detects the start and end of the action, toggles isPending, and puts the returned value into state. The code has been cut in half.

    2. useFormStatus: Stop Props Drilling!

    When building forms, you often want to separate the 'Submit Button' into a separate component (SubmitButton) to apply a design system or for reuse.

    However, if you separate the button, it loses access to the loading state (isPending). Eventually, you have to pass it down via props from the parent form.

    typescript
    // ❌ Props Drilling occurs
    function NicknameForm() {
      const [state, formAction, isPending] = useActionState(...);
      
      return (
        <form action={formAction}>
          {/* Must pass loading state to child button manually */}
          <SubmitButton isLoading={isPending} />
        </form>
      );
    }

    useFormStatus solves this annoyance. This hook acts like Context.

    If called from any child component inside a <form>, it can access the state of "whether the parent form I belong to is currently submitting."

    SubmitButton Component

    typescript
    // SubmitButton.tsx
    import { useFormStatus } from 'react-dom';
    
    export function SubmitButton() {
      // Subscribe to parent form's status. (No Props needed!)
      const { pending } = useFormStatus();
    
      return (
        <button type="submit" disabled={pending}>
          {pending ? 'Processing...' : 'Submit'}
        </button>
      );
    }

    Cleaned Parent Component

    typescript
    // NicknameForm.tsx
    function NicknameForm() {
      const [state, formAction] = useActionState(updateNicknameAction, null);
    
      return (
        <form action={formAction}>
          <input name="nickname" />
          {/* Just place it without Props, and you're done */}
          <SubmitButton />
          <p>{state?.message}</p>
        </form>
      );
    }

    Caveat: useFormStatus only works when rendered inside (as a child of) the <form>. It cannot be used within the component that defines the form itself (NicknameForm).

    3. Why is this a Revolution? (Progressive Enhancement)

    The reason these hooks are revolutionary isn't just because they reduce code. It's because they support "Progressive Enhancement."

    When used with frameworks like Next.js, you can make forms work even before JavaScript is loaded in the browser, or in environments where JS is disabled.

  • User clicks button -> Form submitted via browser default behavior (POST request).
  • Server processes action -> Returns HTML with the result.
  • Once JS loads -> React intercepts (Hydration) to provide a smooth SPA experience.
  • useActionState serves as a bridge connecting these two worlds (MPA and SPA). Of course, even in pure client-side React (Vite, etc.), the benefit of simplifying logic remains powerful.

    Key Takeaways

  • useActionState: Automatically manages the result (state) and loading status (isPending) of asynchronous action functions. It is a powerful tool that eliminates useState and try-catch boilerplate.
  • useFormStatus: Allows child components inside a form to access the loading status (pending) of the parent form. It removes Props Drilling and lowers component coupling.
  • Paradigm Shift: Form management has evolved from an event handler (onSubmit) centric mindset to data and action (action) centric declarative code.

  • Now form state management is automated. But we are not done yet.

    When a user posts a comment, waiting for the server response to refresh the list creates a 0.5-second delay.

    We tried implementing Optimistic Updates with TanStack Query in Part 3, but now we can perform this magic using only React's built-in hooks without external libraries.

    Continuing in: "useOptimistic: Implementing Optimistic Updates Without useState."

    🔗 References

  • React 19 Beta - Actions
  • React Docs - useActionState
  • React Docs - useFormStatus
  • Comments (0)

    0/1000 characters
    Loading comments...