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 :
- 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>
)
}
- 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.";
}
}
- 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 !