loke.dev
Header image for The Day I Deleted My Loading States: How I Finally Mastered React 19 Actions

The Day I Deleted My Loading States: How I Finally Mastered React 19 Actions

A journey from the boilerplate-heavy world of manual loading and error states to the streamlined, native elegance of the new React 19 Action API.

· 4 min read

How many miles of const [isLoading, setIsLoading] = useState(false) have you written in the last three years? If you’re like me, your components were starting to look less like UI logic and more like a collection of toggle switches for spinners. Every time I wanted to save a simple string to a database, I had to orchestrate a delicate dance of try/catch blocks, manual state resets, and error handling that felt more like accounting than engineering.

Then React 19 dropped the "Action" concept, and I realized I could just... stop doing that.

The "Loading State Tax"

We’ve all accepted the "Loading State Tax" as a fact of life. You want to submit a form? You pay the tax. Here is what my typical "Update Profile" component used to look like:

function UpdateProfile() {
  const [name, setName] = useState("");
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsPending(true);
    setError(null);

    try {
      await updateApi(name);
      // Maybe some success toast here?
    } catch (e) {
      setError(e.message);
    } finally {
      setIsPending(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Update"}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

It’s fine. It works. But it’s noisy. The actual "feature" (updating the name) is buried under six lines of state management and a manual event preventer. It’s boilerplate that we’ve just collectively agreed to ignore.

Enter the Action API

In React 19, an "Action" isn't a new library or a complex pattern; it’s just a function that handles an async transition. The real magic happens when you pair it with the new hooks: useActionState and useFormStatus.

When I first refactored my codebase, I deleted about 40% of my component-level state. Here is that same form using the new pattern:

async function updateProfileAction(prevState, formData) {
  const name = formData.get("name");
  try {
    await updateApi(name);
    return { success: true, error: null };
  } catch (e) {
    return { success: false, error: e.message };
  }
}

function UpdateProfile() {
  const [state, formAction, isPending] = useActionState(updateProfileAction, {
    success: false,
    error: null,
  });

  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Update"}
      </button>
      {state.error && <p>{state.error}</p>}
    </form>
  );
}

Why this actually matters (it's not just cleaner)

At first glance, it looks like we just moved the furniture around. But there are three massive shifts happening here that changed how I think about React:

1. Native Form Handling: We're back to using the name attribute on inputs and formData. No more controlled inputs for every single character a user types. This is a huge performance win for large forms.
2. Automatic Transitions: React tracks the isPending state for you. If your action returns a promise, React waits for it. You don't have to flip switches in a finally block anymore.
3. The Button Problem: This was the "Aha!" moment for me. Usually, if you want a loading spinner on a button, you have to pass isLoading as a prop down into the depths of your UI.

Now, we have useFormStatus. It’s like a context provider that’s built into every form.

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

I can put this SubmitButton anywhere inside a <form>, and it *knows* if the parent form is submitting. No props. No context providers. Just "it works."

The "Gotcha" with useActionState

One thing that tripped me up early on: useActionState (formerly useFormState in the canary builds) requires your action function to return a "state." This is why my updateProfileAction returns an object.

The first argument to your action is the prevState. If you're building a counter or something that needs the previous value, it's great. If you're just submitting a form, you’ll probably ignore it, but you still have to account for it in the function signature. Forget that, and your formData will be passed as the first argument, leading to some very confusing undefined errors.

Optimistic UI: The Final Boss

Before React 19, doing optimistic updates (updating the UI before the server responds) was a nightmare. You had to manually update a cache, save the old value, and roll it back if the fetch failed.

Now we have useOptimistic. It’s surprisingly simple. You give it the "real" state, and it gives you a "temporary" state.

const [optimisticName, setOptimisticName] = useOptimistic(
  currentName,
  (state, newName) => newName
);

When you call setOptimisticName inside your action, React immediately updates the UI. Once the action finishes (whether it succeeds or fails), React automatically throws away the optimistic state and reverts to the "real" state from your props or database.

A more peaceful codebase

Deleting manual loading states felt like taking off a heavy backpack at the end of a long hike. My components are more readable, my forms work even if JavaScript is still loading (thanks to the native action prop), and I’m no longer the world's most overqualified boolean-toggler.

If you’re still manually managing isLoading in every component, give the new Action API a try. Your finally blocks won't miss you.