Handling Form Actions in React using useActionState

24 min read
📝 1876 words
Handling Form Actions in React using useActionState

Using useActionState of React 19 to build better forms in Next 15

Highlights:

  • React 19 has released a new hook useActionState

React 19 significantly enhances the handling of forms with Server-Side Rendering (SSR) through the introduction of Server Components and Server Actions, along with new hooks designed to streamline server-side interactions.

React 19 introduces the useActionState hook, which is designed to manage the state of actions triggered by forms, such as submissions or resets. This hook simplifies the process of tracking asynchronous operations, providing clear feedback to users during loading or error states.

Key features for Forms with SSR in React 19:

  • Server Components: allows rendering parts of the UI directly on the server, reducing client side javascript and enabling faster initial page loads
  • Server Actions - provide a way to handle form submissions and data mutations on the server withour requiring a seperate API layer or client side javascript for the submission itself. This allows for direct interaction with server-side logic from within your React components.

What does useActionState do ?

Streamlining Form Action Workflows

  • useActionState: This hook connects the state of a component with a server action. It returns the current state of the form (e.g., validation errors, submitted values), a function to trigger the server action, and a boolean indicating if the action is pending. This enables persisting form values and displaying server-side validation errors without forcing the user to re-enter data.

Provides a more general mechanism for managing the state of any asynchronous action, including but not limited to form submissions. It allows for managing both the pending state and the result/error state of an action.

Syntax:

const [state, formAction, isPending] = useActionState(actionfn, initialState, permalink?);

state - is the result or error from the last execution of the action.

formAction - is a function to trigger the action.

isPending - is a boolean indicating whether the action is currently in progress.

  • Best for:
    • Managing form submissions and handling their results (success/error).
    • Tracking the status of other asynchronous operations triggered by user interactions (e.g., button clicks).
    • Implementing optimistic UI updates based on the action's result.

Purpose: Focused on tracking the state of a specific server action, independently of any particular form.

Scope: Scoped to the action itself and can be used anywhere in the component tree.

Usage:

App.js

import {useActionState} from "react";
import { action } from './action.js'

function MyComponent () {
  const [state, formAction, isPending] = useActionState(action , null)
  return (
    <form action={formAction}>
      {/* ... */ }
    </form>
  )
}

action.js

"use server"

export async function action(prevState, formData) {
  // ...
  return nextState
}

The action that you provide will receive a new first argument, namely the current state of the form.

The first time the form is submitted, this will be the initial state you provided, while with subsequent submissions, it will be the return value from the last time the action was called.

The isPending flag that tells you whether there is a pending Transition.

Examples :

  1. Increment Counter using Button

Let's say we have a button that on click uses a action to increment the count and we display the count

import { useActionState } from 'react'

async function incrementAction (prevState, formData) {
    console.log('prevState', prevState) // 0
    return prevState + 1; // 0 + 1 returns the new state
}

function StatefulIncrementForm({}) {
    const [state, formAction] = useActionState(incrementAction, 0);  // action fn, initial value
  
    return (
      <form>
        {state} // 1

        <button formAction={formAction}>Increment </button>
      </form>
    )
}
  1. Display form errors (pending and error message)

Suppose we have a Cart, where if you want to add a product to the cart, we use a Add to Cart button and this button uses a Add to Cart action

import { useActionState, useState} from 'react'
import { addToCart } from './actions.js'

function AddToCartForm({itemID, itemTitle}) {
  const [message, formAction, isPending] = useActionState(addToCart, null);
  return (
    <form action={formAction}>
      <h2>{itemTitle}</h2>
      <input type="hidden" name="itemID" value={itemID} />
      <button type="submit">Add to Cart</button
    
      {isPending ? "Loading..." : message} 
      // isPending - true/false based on loading state
      // message - if any error occurs, the error message will be displayed
    </form>
  );
}

export default function App() {
  return (
    <>
     <AddToCartForm itemID="1" itemTitle="JavaScript: The Definitive Guide" />

    <AddToCartForm itemID="2" itemTitle="JavaScript: The Good Parts" />
    <>
  )
}

actions.js

'use server'

export async function addToCart(prevState, queryData) {
  const itemID = queryData.get('itemID');
  if (itemID === "1") {
    return "Added to cart";
  } else {
    // Add a fake delay to make waiting noticeable.
    await new Promise(resolve => {
      setTimeout(resolve, 2000);
    });
    return "Couldn't add to cart: the item is sold out.";
  }
}

  1. Displayed structured information after submitting a form

The return value from a Server Function can be any serializable value. For example, it could be an object that includes a boolean indicating whether the action was successful, an error message, or updated information.

App.js

import { useActionState, useState } from "react";
import { addToCart } from "./actions.js";

function AddToCartForm({itemID, itemTitle}) {
  const [formState, formAction] = useActionState(addToCart, {});
  
  return (
    <form action={formAction}>
      <h2>{itemTitle}</h2>

      <input type="hidden" name="itemID" value={itemID} />
      <button type="submit">Add to Cart</button>
      {formState?.success &&
        <div className="toast">
          Added to cart! Your cart now has {formState.cartSize} items.
        </div>
      }
      {formState?.success === false &&
        <div className="error">
          Failed to add to cart: {formState.message}
        </div>
      }
    </form>
  );
}

export default function App() {
  return (
    <>
      <AddToCartForm itemID="1" itemTitle="JavaScript: The Definitive Guide" />
      <AddToCartForm itemID="2" itemTitle="JavaScript: The Good Parts" />
    </>
  )
}

action.js

"use server";

export async function addToCart(prevState, queryData) {
  const itemID = queryData.get('itemID');
  if (itemID === "1") {
    return {
      success: true,
      cartSize: 12,
    };
  } else {
    return {
      success: false,
      message: "The item is sold out.",
    };
  }
}

Lets now modify our existing form to use useActionState

So I have this Subscribe to Newsletter form that uses resend to add to the Broadcasts list.

Before useActionState, it was using

NewsletterForm-v1.tsx

'use client'

import { useState } from "react";
import { subscribe } from "@/actions/subscribe-to-newsletter";
import toast from "react-hot-toast";

export default function SubscribeToNewsletter () {
  const [error, setError] = useState<string | null>(null);
  const [isSuccess, setIsSuccess] = useState<boolean>(false);
  const [isLoading, setIsLoading] = useState(false)

  const [formData, setFormData] = useState<NewsletterFormEmail>({
    email: "",
  });

  
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  function handleSubmit(formData: FormData) {
    const email = formData.get("email");

    // confirm email
    if (!email) {
      toast.error("An error occured, please try again", {
        position: "bottom-center",
        duration: 3000,
        style: {
          border: "1px solid #4F46E5",
          padding: "16px",
          color: "#fff",
          background: "#333",
        },
        iconTheme: {
          primary: "#4F46E5",
          secondary: "#FFFAEE",
        },
      });
      setError("Please enter email");
      setFormData({
        email: "",
      });

      return;
    }

    if (email) {
      setError(null);
       setIsLoading(true)

      subscribe(formData).then((data) => {
        if (!data.success) {

          setError(
            typeof data.error === "string"
              ? data.error
              : "An unknown error occurred"
          );
          toast.error("Error subscribing. Please try again", {
            position: "bottom-center",
            duration: 3000,
            style: {
              border: "1px solid #4F46E5",
              padding: "16px",
              color: "#fff",
              background: "#333",
            },
            iconTheme: {
              primary: "#4F46E5",
              secondary: "#FFFAEE",
            },
          });
          setFormData({
            email: "",
          });

          setIsLoading(false)
          return;
        }

        setIsSuccess(true);
        setIsLoading(false)
        setFormData({
          email: "",
        });

        toast.success("Subscribed successfully", {
          position: "bottom-right",
          duration: 3000,
          style: {
            border: "1px solid #4F46E5",
            padding: "16px",
            color: "#fff",
            background: "#333",
          },
          iconTheme: {
            primary: "#4F46E5",
            secondary: "#FFFAEE",
          },
        });
      });
    }
  }

  
  return (
    <div>
      <h3 className="text-gray-500">Subscribe to my newsletter</h3>
       <form
        onSubmit={(e) => {
          e.preventDefault();
          handleSubmit(new FormData(e.currentTarget));
        }}
        className="flex md:flex-row flex-col  items-center"
      >
          
        <div className="flex md:flex-row flex-col  items-center">
          <label htmlFor="newsletter-email" className="sr-only">
            Email address
          </label>
          <input
            id="newsletter-email"
            type="email"
            name={"email"}
            placeholder="Enter email address"
            value={formData.email}
            onChange={handleChange}
            autoComplete="email"
            className="glass ring-gray-600 w-[300px] text-black dark:text-white p-4 rounded-md m-4 border-1 ring-1 outline-none"
          />
            <button type="submit"> Subscribe </button>
          </form>

               {error && (
        <div className="flex flex-col gap-2">
          <p>
            <span className="text-red-600"> {error}</span>
          </p>
        </div>
      )}
      {isSuccess && (
        <div className="flex flex-col gap-2">
          <p>
            <span className="text-green-600"> Subscribed Successfully </span>
          </p>
        </div>
      )}
    </div>
  
  )
}

actions/subscribe-to-newsletter.js

'use server'
import { Resend } from 'resend';

export async function subscribe(formData: FormData) {
   const resend = new Resend(process.env.RESEND_API_KEY);

      try {
        const email = formData.get('email');
        if (typeof email !== 'string' || !email) {
            throw new Error('Email is required');
        }

        // Get contact by email
        const { data: existingEmail, error: errorFetchingEmail } = await resend.contacts.get({
            email: email,
            audienceId: process.env.RESEND_AUDIENCE_ID as string
        });

        if (errorFetchingEmail) {
            return {
                error: 'Failed to fetch email',
                success: false
            }
        }

        if (existingEmail && existingEmail.email === email) {
            // throw new Error('Email already exists')
             return {error: 'Failed to subscribe - Email already exists', success: false}
        }

        const { data, error } = await resend.contacts.create({
            email: email,
            audienceId: process.env.NEXT_PUBLIC_RESEND_AUDIENCE_ID as string
        });
        if (!data || error) {
            // throw new Error('Failed to subscribe')
            return {error: 'Failed to subscribe', success: false}
        }

        return {
            success: true,
        }
    } catch (error) {
        return { error }
    }
}

So let's now convert this form to use

NewsletterForm-v2.tsx

"use client";
import { useActionState, useState } from "react";
import { subscribe } from "@/app/actions/subscribe-to-newsletter";

interface FormState {
  message: string;
  error: string;
}
const initialState: FormState = {
  message: "",
  error: "",
};
type FormAction = (
  prevState: FormState,
  formData: FormData
) => Promise<FormState>;

export default function NewsletterForm() {
  const [isEmail, setEmail] = useState("");
  // action state
  const subscribeToNewsletterAction: FormAction = async (
    prevState,
    formData
  ): Promise<FormState> => {
    if (!formData) {
      // console.error("FormData is required");
      return {
        message: "Failed to subscribe",
        error: "FormData is required",
      };
    }
    const emailId = formData.get("email") as string | null;

    console.log("emailId", emailId);

    if (!emailId) {
      // console.error("Email is required");

      return {
        message: "Failed to subscribe",
        error: "Email is required",
      };
    }
    setEmail(emailId);

    const error = await subscribe(emailId);
    // console.log("error of subscribe", error);
    if (error && error.error) {
      //   return error;
      const errMsg = error ? (error?.error as string) : "Failed to subscribe";

      // console.log("errMsg", errMsg);
      setEmail("");
      return {
        message: errMsg,
        error: errMsg,
      };
    }

    setEmail("");

    // send confirmation email to user

    // send telegram notification to me using Telegram bot

    return {
      message: "Subscribed successfully",
      error: "",
    };
  };

  const [response, formAction, isPending] = useActionState<FormState, FormData>(
    subscribeToNewsletterAction,
    initialState
  ); //actionState, initialState

  const btnDisabled = isPending || !isEmail;

  return (
    <div className="w-full md:w-1/2 py-4 mx-auto">
      <h3 className="font-semibold">Subscribe to my newsletter </h3>

      <form action={formAction} className="flex flex-col items-center">
        <label htmlFor="newsletter-email" className="sr-only">
          Email address
        </label>

        <input
          id="newsletter-email"
          type="email"
          name={"email"}
          placeholder="Enter Email Address"
          value={isEmail}
          onChange={(e) => setEmail(e.target.value)}
          autoComplete="email"
          className="glass ring-gray-600 text-black dark:text-white p-4 rounded-md m-4 border-1 ring-1 outline-none w-full"
        />
        <button
          aria-label="button"
          type="submit"
          tabIndex={0}
          className={`group ${
            btnDisabled ? "disabled:opacity-20" : null
          } inline-flex items-center rounded-full p-4 md:px-16 md:py-3 font-semibold transition bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-pink-500 hover:to-orange-500  text-white dark:bg-gray-200 hover:bg-slate-700`}
          disabled={btnDisabled}
        >
          {isPending ? (
            "Submitting..."
          ) : (
            <>
              <span>Subscribe</span>
              <span className="sr-only">Subscribe</span>
                {/** Loading icon */}
              <svg
                className={`mt-0.5 ml-4 -mr-1 stroke-1 stroke-white`}
                fill="none"
                width="20"
                height="20"
                viewBox="0 0 10 10"
                aria-hidden="true"
              >
                <path
                  className="transition opacity-0 group-hover:opacity-100"
                  d="M0 5h7"
                ></path>
                <path
                  className="transition group-hover:translate-x-[3px]"
                  d="M1 1l4 4-4 4"
                ></path>
              </svg>
            </>
          )}
        </button>
        {response && response.error === "" ? (
          <p className="text-green-500 py-4"> {response.message} </p>
        ) : null}
        {response && response.error !== "" ? (
          <p className="text-red-500 py-4"> {response.message} </p>
        ) : null}
      </form>
    </div>
  );
}

So, as you can see we have updated our Newsletter Form component, and now it works like a charm.

That's it folks !