React.js

React 19 useActionState: Simplifying Forms and Async Actions

React 19 useActionState hook example showing form submission, async action, pending state, validation errors, and successful response.

Learn how React 19's useActionState hook simplifies form submissions, async actions, loading states, validation errors, and server mutations with practical examples you can test yourself.

Introduction

Handling forms is one of the most common tasks in React applications.

Consider a simple profile update form.

A user changes their name and clicks:

Update Profile

Behind the scenes, we normally need to:

  1. Read the form values.

  2. Set a loading state.

  3. Call an API.

  4. Wait for the response.

  5. Handle validation errors.

  6. Handle request failures.

  7. Update the UI.

  8. Disable the button while submitting.

Before React 19, developers often managed these states manually using multiple useState hooks.

For example:

const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [message, setMessage] = useState(null);

This works, but forms can quickly become repetitive.

React 19 introduces a cleaner approach:

useActionState

A basic example looks like:

const [state, formAction, isPending] = useActionState(
  updateProfile,
  initialState
);

From one hook, we get:

state
formAction
isPending

Let's understand how it works and build a real example.


What Problem Does useActionState Solve?

Imagine we have this form:

<form onSubmit={handleSubmit}>
  <input
    value={name}
    onChange={(e) => setName(e.target.value)}
  />

  <button type="submit">
    Update
  </button>
</form>

Our submit function might look like:

async function handleSubmit(event) {
  event.preventDefault();

  setLoading(true);
  setError(null);

  try {
    const response = await updateUser(name);

    setMessage("Profile updated successfully");
  } catch (error) {
    setError("Something went wrong");
  } finally {
    setLoading(false);
  }
}

This is completely valid React.

But we are manually managing:

loading
error
success
form submission
API state

With more complicated forms, the amount of state grows.

React 19 Actions and useActionState help reduce this boilerplate.


What Is useActionState?

Import it from React:

import { useActionState } from "react";

The basic syntax is:

const [state, dispatchAction, isPending] =
  useActionState(actionFunction, initialState);

It returns three values.

1. state

The value returned by your action.

2. dispatchAction

The Action that triggers your function.

It can be passed directly to a form:

<form action={dispatchAction}>

3. isPending

A boolean indicating whether the Action is currently running.

For example:

<button disabled={isPending}>
  {isPending ? "Saving..." : "Save"}
</button>

Our Practical Example

We will build a simple profile form.

The user can update:

Name
Email

Our form will support:

  • Validation

  • Loading state

  • Success message

  • Error message

  • Simulated API delay

  • Failed API simulation

You can run this example in a React 19 project.


Step 1: Create a React Project

Using Vite:

npm create vite@latest react-action-demo

Choose:

React
JavaScript

Enter the project:

cd react-action-demo

Install dependencies:

npm install

Start the application:

npm run dev

Open the URL shown by Vite.

Usually:

http://localhost:5173

Step 2: Confirm React Version

Run:

npm list react

You should be using React 19 or later.

You can also inspect:

package.json

and look for:

{
  "dependencies": {
    "react": "^19.x.x",
    "react-dom": "^19.x.x"
  }
}

Step 3: Create the Action

Create:

src/actions/profileAction.js

Add:

export async function updateProfileAction(
  previousState,
  formData
) {
  const name = formData.get("name");
  const email = formData.get("email");

  await new Promise((resolve) =>
    setTimeout(resolve, 1500)
  );

  if (!name?.trim()) {
    return {
      success: false,
      message: "Name is required.",
    };
  }

  if (!email?.trim()) {
    return {
      success: false,
      message: "Email is required.",
    };
  }

  if (!email.includes("@")) {
    return {
      success: false,
      message: "Please enter a valid email address.",
    };
  }

  return {
    success: true,
    message: "Profile updated successfully.",
  };
}

For now, we are not calling a real backend.

This line simulates API latency:

await new Promise((resolve) =>
  setTimeout(resolve, 1500)
);

This gives us enough time to see the pending state.


Step 4: Create the Form

Create:

src/ProfileForm.jsx

Add:

import { useActionState } from "react";

import {
  updateProfileAction,
} from "./actions/profileAction";

const initialState = {
  success: false,
  message: "",
};

export default function ProfileForm() {
  const [
    state,
    formAction,
    isPending,
  ] = useActionState(
    updateProfileAction,
    initialState
  );

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="name">
          Name
        </label>

        <input
          id="name"
          name="name"
          type="text"
        />
      </div>

      <div>
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          name="email"
          type="email"
        />
      </div>

      <button
        type="submit"
        disabled={isPending}
      >
        {isPending
          ? "Updating..."
          : "Update Profile"}
      </button>

      {state.message && (
        <p>
          {state.message}
        </p>
      )}
    </form>
  );
}

Notice:

<form action={formAction}>

We are not using:

onSubmit={handleSubmit}

React's Action handles the form submission.


Step 5: Render the Component

Update:

src/App.jsx

with:

import ProfileForm from "./ProfileForm";

function App() {
  return (
    <main>
      <h1>Update Profile</h1>

      <ProfileForm />
    </main>
  );
}

export default App;

Run:

npm run dev

Open:

http://localhost:5173

You should now see the form.


Step 6: Test Empty Name

Leave the name field empty.

Enter:

developer@example.com

as the email.

Click:

Update Profile

During the simulated request, the button should display:

Updating...

After approximately 1.5 seconds, you should see:

Name is required.

That error came from:

return {
  success: false,
  message: "Name is required.",
};

The returned object automatically becomes the new:

state

Understanding previousState

Our Action receives:

async function updateProfileAction(
  previousState,
  formData
)

The first parameter is:

previousState

The second parameter contains the Action payload—in this case, our form data.

For example:

const name = formData.get("name");

Without useActionState, a normal form action might receive:

async function action(formData)

With useActionState, React adds the previous state before the original arguments.

So it becomes:

async function action(
  previousState,
  formData
)

This is an important detail.


Step 7: Test Invalid Email

Enter:

Name:
Jalish

and:

Email:
wrong-email

Submit the form.

Our Action checks:

if (!email.includes("@")) {
  return {
    success: false,
    message:
      "Please enter a valid email address.",
  };
}

Expected result:

Please enter a valid email address.

Step 8: Test Successful Submission

Enter:

Name:
Jalish

and:

Email:
jalish@example.com

Submit.

You should first see:

Updating...

and then:

Profile updated successfully.

The flow is:

User submits form
        ↓
formAction runs
        ↓
isPending = true
        ↓
Action processes request
        ↓
Action returns state
        ↓
state updates
        ↓
isPending = false

This is the main idea behind useActionState.


Before React 19 vs React 19

Let's compare both approaches.

Traditional Approach

We might need:

const [loading, setLoading] =
  useState(false);

const [error, setError] =
  useState("");

const [success, setSuccess] =
  useState("");

Then:

async function handleSubmit(event) {
  event.preventDefault();

  setLoading(true);
  setError("");

  try {
    await updateProfile();

    setSuccess(
      "Profile updated successfully."
    );
  } catch {
    setError(
      "Something went wrong."
    );
  } finally {
    setLoading(false);
  }
}

Our component is responsible for managing the entire request lifecycle.


React 19 Approach

With useActionState:

const [
  state,
  formAction,
  isPending,
] = useActionState(
  updateProfileAction,
  initialState
);

The Action handles the operation.

The component mainly renders the resulting state.

<form action={formAction}>

Pending state becomes:

isPending

The returned result becomes:

state

This can make action-oriented components much easier to understand.


Returning Field-Level Validation Errors

A real form may need individual errors.

Instead of returning:

{
  message: "Validation failed"
}

we can return:

{
  success: false,
  errors: {
    name: "Name is required",
    email: "Invalid email",
  },
}

Let's implement that.

Update the Action:

export async function updateProfileAction(
  previousState,
  formData
) {
  const name = formData.get("name");
  const email = formData.get("email");

  const errors = {};

  if (!name?.trim()) {
    errors.name =
      "Name is required.";
  }

  if (!email?.trim()) {
    errors.email =
      "Email is required.";
  } else if (!email.includes("@")) {
    errors.email =
      "Enter a valid email.";
  }

  if (Object.keys(errors).length > 0) {
    return {
      success: false,
      message:
        "Please fix the errors below.",
      errors,
    };
  }

  await new Promise((resolve) =>
    setTimeout(resolve, 1500)
  );

  return {
    success: true,
    message:
      "Profile updated successfully.",
    errors: {},
  };
}

Update:

const initialState = {
  success: false,
  message: "",
  errors: {},
};

Now show the error below each field.

<input
  id="name"
  name="name"
/>

{state.errors?.name && (
  <p>
    {state.errors.name}
  </p>
)}

And:

<input
  id="email"
  name="email"
  type="email"
/>

{state.errors?.email && (
  <p>
    {state.errors.email}
  </p>
)}

Now the form provides much better feedback.


Connecting It to a Real API

Our current example only simulates an API.

Let's replace it with a real HTTP request.

Suppose our backend provides:

PUT /api/profile

Our Action can call it using:

export async function updateProfileAction(
  previousState,
  formData
) {
  const name = formData.get("name");
  const email = formData.get("email");

  try {
    const response = await fetch(
      "http://localhost:3000/api/profile",
      {
        method: "PUT",

        headers: {
          "Content-Type":
            "application/json",
        },

        body: JSON.stringify({
          name,
          email,
        }),
      }
    );

    const data =
      await response.json();

    if (!response.ok) {
      return {
        success: false,
        message:
          data.message ||
          "Profile update failed.",
      };
    }

    return {
      success: true,
      message:
        "Profile updated successfully.",
    };
  } catch (error) {
    return {
      success: false,
      message:
        "Unable to connect to the server.",
    };
  }
}

The React component does not need to change.

This is useful because the UI is separated from the mutation logic.


Handling Pending State

One of my favorite parts of this hook is:

isPending

Instead of manually writing:

const [loading, setLoading] =
  useState(false);

we can simply use:

<button disabled={isPending}>

and:

{isPending
  ? "Updating..."
  : "Update Profile"}

This prevents users from repeatedly clicking the submit button.

For example:

Click
Click
Click
Click

could otherwise create multiple requests.

Disabling the button helps reduce accidental duplicate submissions.


Using useFormStatus

React 19 also improves form handling through:

useFormStatus

from:

react-dom

Imagine our submit button is a separate reusable component.

Create:

SubmitButton.jsx

Add:

import {
  useFormStatus,
} from "react-dom";

export default function SubmitButton() {
  const { pending } =
    useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending
        ? "Updating..."
        : "Update Profile"}
    </button>
  );
}

Then your form becomes:

<form action={formAction}>
  <input
    name="name"
  />

  <input
    name="email"
  />

  <SubmitButton />
</form>

The button automatically understands the status of its parent form.

You do not need to pass:

isPending

through props.


useActionState vs useFormStatus

These hooks solve related but different problems.

useActionState

Use it when you need:

Action result
Validation result
Success state
Error state
Pending state
Previous action state

Example:

const [
  state,
  action,
  isPending,
] = useActionState(
  updateProfile,
  initialState
);

useFormStatus

Use it inside a child of a form when that component only needs information about the form submission.

Example:

const { pending } =
  useFormStatus();

A common combination is:

Form Component
     ↓
useActionState
     ↓
Submit Button
     ↓
useFormStatus

Combining useActionState with useOptimistic

Another powerful React 19 combination is:

useActionState
+
useOptimistic

Imagine a shopping cart.

Current quantity:

1

The user clicks:

+

Without optimistic UI:

Click
↓
Wait for server
↓
Server returns
↓
Quantity becomes 2

With optimistic UI:

Click
↓
Quantity immediately becomes 2
↓
Server request runs
↓
React confirms or reconciles state

Conceptually:

const [
  cart,
  updateCart,
  isPending,
] = useActionState(
  updateCartAction,
  initialCart
);

const [
  optimisticCart,
  setOptimisticCart,
] = useOptimistic(cart);

useActionState manages the actual Action result.

useOptimistic provides immediate visual feedback.

These hooks solve different parts of the same user experience.


Practical Use Cases for useActionState

The hook is especially useful for operations such as:

Login Form

Submit credentials
↓
Authenticate
↓
Return validation/error state

Registration

Create account
↓
Validate server response
↓
Show success/error

Profile Update

Edit profile
↓
Save
↓
Display result

Password Change

Submit password
↓
Validate
↓
Update
↓
Display result

Comment Form

Write comment
↓
Submit
↓
Server saves comment
↓
Update UI

Contact Form

Enter message
↓
Send
↓
Show confirmation

Shopping Cart

Add product
↓
Update backend
↓
Update action state

Checkout

Submit order
↓
Process request
↓
Return order status

When Should You Use useActionState?

Use it when your UI is centered around an Action that changes some state.

Examples:

Submit form
Save settings
Update profile
Delete item
Add comment
Create order
Update cart

It is especially useful when you need:

Action result
+
Pending state
+
Validation feedback

When You May Not Need It

Not every state belongs in useActionState.

For example:

const [isOpen, setIsOpen] =
  useState(false);

is still perfectly appropriate for:

Modal open/close
Dropdown state
Tabs
Theme selection
Local UI toggles

useActionState is not a replacement for:

useState

It solves a different problem.

Think of it like this:

Local UI State
→ useState

Async Action State
→ useActionState

Important Mistake to Avoid

One common mistake is forgetting that your Action receives the previous state first.

Wrong:

async function updateProfile(
  formData
) {
  const name =
    formData.get("name");
}

When used through useActionState, write:

async function updateProfile(
  previousState,
  formData
) {
  const name =
    formData.get("name");
}

The first argument is now:

previousState

This is important when migrating existing form Actions.


Test Checklist

After building the example, test these scenarios.

Test 1 — Empty Name

Expected:

Name is required.

Test 2 — Empty Email

Expected:

Email is required.

Test 3 — Invalid Email

Expected:

Enter a valid email.

Test 4 — Valid Form

Expected:

Updating...

followed by:

Profile updated successfully.

Test 5 — Pending State

During submission:

Button disabled = true

Test 6 — API Failure

Stop your backend and submit.

Expected:

Unable to connect to the server.

Testing each state helps you understand exactly what useActionState is doing.


Final Flow

Our complete flow is:

User
  ↓
Form
  ↓
formAction
  ↓
useActionState
  ↓
Async Action
  ↓
API / Server
  ↓
Return State
  ↓
React Updates UI

During processing:

isPending = true

After processing:

isPending = false

The returned Action value becomes:

state

This gives us a clean way to manage the complete Action lifecycle.


Before and After

Traditional form handling often looks like:

useState
  ↓
loading

useState
  ↓
error

useState
  ↓
success

handleSubmit
  ↓
try/catch/finally

With React 19:

useActionState
      ↓
state
action
pending

It does not mean the traditional approach is wrong.

Instead, React 19 gives developers another abstraction specifically designed around Actions.


Conclusion

React 19's useActionState is particularly useful when an interface needs to perform an asynchronous mutation and display the result of that operation.

The core API is simple:

const [
  state,
  action,
  isPending,
] = useActionState(
  actionFunction,
  initialState
);

From this, we get:

state
→ result of the Action

action
→ function used to trigger it

isPending
→ whether it is processing

For form-heavy applications, this can reduce repetitive code around:

Loading states
Validation messages
Success messages
Errors
Async requests

More importantly, it encourages us to think about form submissions as Actions rather than manually coordinating many unrelated pieces of state.

If you are learning React 19, I recommend building the example in this article yourself.

Try:

Successful submission
Validation failure
Network failure
Slow API
Multiple submissions

Once those cases make sense, the purpose of useActionState becomes much clearer.