DEV Community

Cover image for React 19's useActionState Showed Me Why Disabling My Submit Button Was Never Enough
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

React 19's useActionState Showed Me Why Disabling My Submit Button Was Never Enough

Why Disabling My Submit Button Was Never Enough: A useActionState Epiphany

In the world of web development, we often find ourselves building forms. Lots of forms. Login forms, signup forms, checkout forms, comment forms—you name it. And a common pattern, almost a reflex, is to disable the submit button once a user clicks it, right? It feels like good practice. It looks like good practice. It gives a visual cue that something is happening and, crucially, prevents accidental double submissions.

But what if I told you that disabling that button, while a good start, was never truly enough? I've been there, piecing together useState for isLoading, setError, setSuccess, juggling race conditions, and crossing my fingers that a user's fast fingers or a flaky network wouldn't mess up my carefully constructed logic. My "aha!" moment came with React 19's useActionState, and it fundamentally shifted how I think about form submissions and client-server interactions.

The Illusion of Safety: Why disabled={isSubmitting} Falls Short

Let's rewind a bit. For years, our typical approach to form submission in React might look something like this:

import React from 'react';

function OldSchoolForm() {
  const [isSubmitting, setIsSubmitting] = React.useState(false);
  const [error, setError] = React.useState<string | null>(null);
  const [message, setMessage] = React.useState<string | null>(null);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setIsSubmitting(true);
    setError(null);
    setMessage(null);

    const formData = new FormData(event.currentTarget);
    const email = formData.get('email');
    const password = formData.get('password');

    try {
      // Simulate an API call
      console.log('Sending data:', { email, password });
      await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate network delay

      if (email === 'fail@example.com') {
        throw new Error('Authentication failed for this email.');
      }

      setMessage('Login successful!');
      // Often, you'd navigate or clear the form here
    } catch (err: any) {
      setError(err.message || 'An unexpected error occurred.');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '300px', margin: '20px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <label>
        Email:
        <input type="email" name="email" required disabled={isSubmitting} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} />
      </label>
      <label>
        Password:
        <input type="password" name="password" required disabled={isSubmitting} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} />
      </label>
      <button type="submit" disabled={isSubmitting} style={{ padding: '10px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: isSubmitting ? 'not-allowed' : 'pointer' }}>
        {isSubmitting ? 'Logging in...' : 'Log In'}
      </button>

      {error && <p style={{ color: 'red', marginTop: '10px' }}>Error: {error}</p>}
      {message && <p style={{ color: 'green', marginTop: '10px' }}>{message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

This code works... most of the time. But it's fragile.

  1. Race Conditions: A user with a quick double-click might fire two onSubmit events before setIsSubmitting(true) can render and disable the button. This is especially true for complex forms with render-blocking updates.
  2. Browser Retries: If the network request fails temporarily (e.g., a timeout), some browsers might attempt to resubmit the form, regardless of your isSubmitting state.
  3. Complex Error/Success Handling: Managing error and message states, ensuring they clear correctly, and handling different types of server responses often leads to boilerplate and potential bugs.
  4. Optimistic Updates: Implementing optimistic UI updates (where you show the change immediately and revert if the server fails) becomes significantly more complex to coordinate with your manual isSubmitting state.

In my experience, these aren't theoretical edge cases. They lead to corrupted data, confused users, and frantic debugging sessions trying to figure out why we have duplicate entries or inconsistent states. The core issue is that our client-side isSubmitting state is a local interpretation of a global, asynchronous process that fundamentally involves the server.

Enter useActionState: A Declarative Leap Forward

React 19, especially in conjunction with the direction of Server Components and form actions, introduces useActionState. This hook is a game-changer because it provides a first-class, declarative way to manage the state of an asynchronous action, bridging the client and server more elegantly than ever before.

Its signature looks like this:

const [state, formAction, isPending] = useActionState(action, initialState, permalink?);
Enter fullscreen mode Exit fullscreen mode

Let's break down its power:

  • action: This is your asynchronous function that performs the actual server interaction (e.g., saving data, logging in). It receives the previous state and the FormData object.
  • initialState: The initial value of state before any action has run. This is crucial for displaying initial error messages or pre-filling form feedback.
  • permalink (optional): For server components, this can be used to reference a server action.
  • state: This is where the magic happens. It holds the return value of your action function. This can be an error message, a success message, a data object, or anything else your server operation needs to communicate back to the client. It gets updated after the action completes.
  • formAction: This is a direct reference to your action function, but wrapped in a way that React understands and can manage. You can pass it directly to a <form action={formAction}> prop or trigger it manually.
  • isPending: This is the killer feature. Unlike our manual isSubmitting state, isPending is automatically managed by React. It becomes true when the action starts and false when it completes (whether successfully or with an error). Critically, React guarantees that only one action is pending at a time for a given formAction. This means no more accidental double submissions, even with rapid clicks!

Let's refactor our OldSchoolForm to use useActionState:

import React from 'react';
import { useActionState } from 'react'; // In React 19, useActionState is available

// This function can be defined outside the component, or even in a separate file (for server actions)
async function loginAction(
  previousState: string | undefined, // The state returned by the previous action call
  formData: FormData
): Promise<string> { // The return type is the new state
  const email = formData.get('email');
  const password = formData.get('password');

  // Simulate an API call
  console.log('Sending data via action:', { email, password });
  await new Promise(resolve => setTimeout(resolve, 1500));

  if (email === 'fail@example.com') {
    return `Error: Authentication failed for '${email}'.`; // This becomes the new state
  }

  // Simulate server-side validation error
  if (!email || !password) {
      return "Error: Email and password are required.";
  }

  // Simulate success
  return `Success: Logged in as '${email}'.`; // This becomes the new state
}

function NewSchoolForm() {
  const [state, formAction, isPending] = useActionState(loginAction, undefined);
  // 'state' will hold the string returned by loginAction (e.g., "Error: ...", "Success: ...")

  return (
    <form action={formAction} style={{ display: 'flex', flexDirection: 'column', gap: '10px', maxWidth: '300px', margin: '20px auto', padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <label>
        Email:
        <input type="email" name="email" required disabled={isPending} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} />
      </label>
      <label>
        Password:
        <input type="password" name="password" required disabled={isPending} style={{ width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} />
      </label>
      <button type="submit" disabled={isPending} style={{ padding: '10px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: isPending ? 'not-allowed' : 'pointer' }}>
        {isPending ? 'Logging in...' : 'Log In'}
      </button>

      {state && state.startsWith('Error:') && <p style={{ color: 'red', marginTop: '10px' }}>{state}</p>}
      {state && state.startsWith('Success:') && <p style={{ color: 'green', marginTop: '10px' }}>{state}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice the immediate benefits:

  • No Manual isSubmitting State: isPending handles it automatically and robustly.
  • Built-in Debouncing/Throttling: React manages simultaneous submissions, ensuring only one action is active at a time.
  • Direct Server Feedback: The state variable directly receives the outcome of the loginAction, simplifying error and success message display. The action function's return value is the new state!
  • Cleaner Code: Less boilerplate for state management, more focus on the actual business logic of the form.

Insights Beyond the Basics

Here's the thing that most initial tutorials might miss:

  • Data Integrity by Design: isPending is not just a UI flag; it's a guarantee from React that the underlying action is being processed. This is huge for preventing accidental data corruption or duplicate entries.
  • Powerful state Handling: The state isn't just for simple strings. It can be a complex object containing validation errors, user session data, or even partial results that guide the next UI interaction. This flexibility allows for rich, server-driven UI feedback.
  • Future of Optimistic Updates: While useActionState handles the pending state, its sibling useOptimistic is designed to work hand-in-hand to provide immediate UI feedback that can be seamlessly reverted if the server action fails. This combination is incredibly powerful for a smooth user experience.
  • Server Actions Integration: When used with frameworks like Next.js or Remix that support React Server Components and Server Actions, useActionState becomes even more potent. Your action function can directly be a server action, enabling direct database mutations without needing explicit API endpoints for every form. This truly blurs the client-server boundary for mutations.

Common Pitfalls and Best Practices

  • name Attributes are Crucial: Remember that formData relies on the name attribute of your form inputs. Without them, formData.get('myInput') will return null.
  • Consider Clearing state: After a successful submission or after a user acknowledges an error, you might want to reset the state to initialState (e.g., by navigating away, clearing the form, or using a separate reset function if useActionState is called within a component that also manages other internal states).
  • Not Just for Forms: While its name implies forms, useActionState can manage the state of any asynchronous action. Think delete buttons, upvoting mechanisms, or any UI element that triggers a server-side mutation.
  • Error Handling in action: Ensure your action function always returns a state value, even in error scenarios. This returned value becomes available client-side for displaying feedback. Don't rely solely on throwing errors from the action itself unless you have an ErrorBoundary that can catch async action errors.

The Shift in Mindset

useActionState isn't just another hook; it represents a more declarative, server-aware way of handling UI mutations. It brings the server and client closer, giving developers powerful primitives to build more resilient, performant, and user-friendly applications. We're moving away from manually orchestrating every detail of an asynchronous interaction and towards letting React manage the lifecycle for us. This frees us up to focus on the actual user experience and business logic, rather than wrestling with low-level state management complexities.

Embrace useActionState. Your forms will be more robust, your users will be happier, and your debugging sessions will thank you.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai


Top comments (0)