How to build a contact form using the useActionState and useFormStatus hook?
How to build a contact form using the useActionState and useFormStatus hook?

Nextjs + Contact Form

How to build a contact form using the useActionState and useFormStatus hook?

Build a contact form with next.js using reactjs server actions.

--

Most important for every website is collecting data from users, such as phone number, name, email, age, etc. There is only one way to collect data using HTML form.

Last tutorial, I built a contact form from scratch using TypeScript, Shadcn UI, Zod, React hook form, and Nextjs. However, I do not used the React server actions.

Most of the setup is similar to the previous tutorial in this article; we can not use React hook form; instead, to use reactjs server action to build a contact form.

All the code is available on GitHub.

Demo

Steps:

  1. Requirements
  2. Create nextjs app
  3. Install additional package
    Install Shadcn UI
    Add shadcn component
    Install Zod
    Sonner package
  4. Learn about the useActionState and useFormStatus hooks.
  5. Build contact form UI
    Built Action for form
    Ignore
    Save the data in the database.
    How to use the useFormStatus hook?
  6. Conclusion

Requirements

Before starting this article, You know about the following libraries:

  1. Shadcn UI
  2. Zod
  3. Reactjs Server Actions
  4. useActionState hook
  5. useFormStatus hook

Create nextjs app

The first step is to create a fresh new nextjs application. To use the React serve actions, you need to create the next app with Release Candidate (RC).

pnpm create next-app@rc  use-action-state

The command output looks like this.

➜  medium pnpm create next-app@rc  use-action-state                   
.../share/pnpm/store/v3/tmp/dlx-187287 | +1 +
.../share/pnpm/store/v3/tmp/dlx-187287 | Progress: resolved 1, reused 1, downloaded 0, added 1, done
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for next dev? … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /home/officialrajdeepsingh/medium/use-action-state.

Using pnpm.

Initializing project with template: app-tw


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next

WARN 5 deprecated subdependencies found: @humanwhocodes/config-array@0.11.14, @humanwhocodes/object-schema@2.0.3, glob@7.2.3, inflight@1.0.6, rimraf@3.0.2
Packages: +358
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 382, reused 359, downloaded 0, added 358, done
node_modules/.pnpm/sharp@0.33.4/node_modules/sharp: Running install script, done in 126ms

dependencies:
+ next 15.0.0-rc.0
+ react 19.0.0-rc-f994737d14-20240522
+ react-dom 19.0.0-rc-f994737d14-20240522

devDependencies:
+ @types/node 20.14.5
+ @types/react 18.3.3
+ @types/react-dom 18.3.0
+ eslint 8.57.0 (9.5.0 is available)
+ eslint-config-next 15.0.0-rc.0
+ postcss 8.4.38
+ tailwindcss 3.4.4
+ typescript 5.4.5

Done in 33.1s
Initialized a git repository.

Success! Created use-action-state at /home/officialrajdeepsingh/medium/use-action-state

Install additional package

To build a contact form, you need to install additional dependencies:

  1. Install Shadcn UI
  2. Add shadcn component
  3. Install Zod
  4. Sonner package

Install Shadcn UI

➜  use-action-state git:(main) ✗ npx shadcn-ui@latest init

Need to install the following packages:
shadcn-ui@0.8.0
Ok to proceed? (y) y
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Would you like to use CSS variables for colors? … no / yes

✔ Writing components.json...
✔ Initializing project...
✔ Installing dependencies...

Success! Project initialization completed. You may now add components.

Add shadcn component

To build contacts from UI, we need to add some components from shadcn UI such as label, input, button, textarea, select, and sonner.

pnpm dlx shadcn-ui@latest add label input button textarea select form sonner

Command output looks like this.

➜  use-action-state git:(main) ✗ pnpm dlx shadcn-ui@latest add label input button textarea select form sonner

Packages: +175
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 175, reused 175, downloaded 0, added 175, done
✔ Done.

Install Zod

To validate the form input in react serve actions, we need to install the Zod library.

➜  use-action-state git:(main) ✗ pnpm add zod          
Already up to date
Progress: resolved 405, reused 397, downloaded 0, added 0, done
Done in 7.3s

Sonner package

The Sonner package is a toast component built for React and shows notifications on the screen. We show a success or error notification on the screen when the form is submitted.

➜  use-action-state git:(main) ✗ pnpm add sonner                   
Packages: +3
+
Progress: resolved 406, reused 397, downloaded 1, added 1, done

dependencies:
+ validator 13.12.0

Done in 4.5s

Learn about the useActionState and useFormStatus hooks.

Before diving deep into the tutorial, learn about the useActionState and useFormStatus hooks.

Both hooks are part of reactjs 19.

Build contact form UI

First, check out what looks like the contact form UI.

Contact form UI look like this.
The contact form UI looks like this.

Similar to the previous tutorial. First, build schemas for forms using the Zod library. The form schema helps to validate the form field.

We have eight forms filed, such as first name, last name, username, email, message, phone number, and services.

Our schema looks like this: if you learn more about the Zod library, you will watch tutorials on YouTube or read documentation.

// components/Form/schema.ts

import { z } from "zod";

export const formSchema = z.object({
username: z
.string()
.min(2, {
message: "Username must be at least 2 characters.",
})
.max(12, {
message: "Username does not extend more than 12 characters.",
}),
firstName: z.string().trim().min(2, {
message: "First name must be at least 2 characters.",
}),
lastName: z.string().min(2, {
message: "last name must be at least 2 characters.",
}),
email: z
.string()
.email({
message: "Enter the email",
})
.trim()
.toLowerCase(),
phoneNumber: z
.string({
message: "Enter mobile number",
})
.min(10, {
message: "Phone number is short.",
})
.max(10, {
message: "Phone number is big.",
}), // You can validate the number using isMobilePhone(phone) or any other method.
message: z.string({ message: "Enter message" }).min(12, {
message: "message must be at least 20 characters.",
}),
service: z.enum(
["web-design", "web-development", "seo", "social-media", "branding"],
{
message: "Select a service",
},
),
});

The next step is to build a contact form UI using Shadcn UI and tailwind CSS.

// components/Form/form.tsx

"use client";

import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { SubmitButton } from "./button";

export function ContactForm() {

return (
<div className="mt-12 max-w-2xl mx-auto p-6 sm:p-8 bg-white dark:bg-gray-950 rounded-lg shadow-lg">
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-bold">Get in Touch</h2>
<p className="text-gray-500 dark:text-gray-400">
Fill out the form below and well get back to you as soon as
possible.
</p>
</div>

<form action={formAction} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first-name">First Name</Label>
<Input
name="first-name"
id="first-name"
placeholder="Enter your first name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="last-name">Last Name</Label>
<Input
name="last-name"
id="last-name"
placeholder="Enter your last name"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
name="email"
id="email"
type="email"
placeholder="Enter your email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
name="username"
id="username"
placeholder="Enter your username"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
name="message"
id="message"
placeholder="Enter your message"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
name="phone"
id="phone"
type="tel"
placeholder="Enter your phone number"
/>
</div>
<div className="space-y-2">
<Label htmlFor="services">Services</Label>
<Select name="service">
<SelectTrigger>
<SelectValue id="services" placeholder="Select services" />
</SelectTrigger>
<SelectContent>
<SelectItem value="web-design">Web Design</SelectItem>
<SelectItem value="web-development">Web Development</SelectItem>
<SelectItem value="seo">SEO</SelectItem>
<SelectItem value="social-media">Social Media</SelectItem>
<SelectItem value="branding">Branding</SelectItem>
</SelectContent>{" "}
</Select>
</div>
<SubmitButton />
</form>
</div>
</div>
);
}

The Button component was built using the React useFormStatus hook, which shows messages based on pending.

import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";

export function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button disabled={pending} type="submit" className="w-full">
{pending ? "Submitting..." : "Submit"}
</Button>
);
}

Now, I import the form component on the home page.

// app/page.tsx

import { ContactForm } from "@/components/form/form";
export default function Home() {
return <ContactForm />;
}

Built Action for form

The next part is to build an action for the contact form using the Zod schema we created.

You need to validate the form data in serve side using Zod safeParseAsync API. To get data from specific data, you use the get() Method provided by FormData.

If form data is validated successfully, you return a state and show an error. The useActionState hook reads your state, which we return in actions.

// components/form/action.ts

"use server";

import { formSchema } from "./schema";
import { toast } from "sonner";
import { z } from "zod";
import { FormDataReturn } from "@/types";

export default async function contactAction(_prevState: any, params: FormData) {

// Validate the form data using Zod safeParseAsync API.
const validation = await formSchema.safeParseAsync({
firstName: params.get("first-name"), // get first name
lastName: params.get("last-name"), // get last name
email: params.get("email"), // get email
username: params.get("username"), // get user name
phoneNumber: params.get("phone"), // get phone
message: params.get("message"), // get message
service: params.get("service"), // get service
});


if (validation.success) {
// return a success state.
return {
success: validation.success,
data: validation.data,
errors: false,
};

} else {
// return an error state.
return {
success: validation.success,
errors: validation.error.issues,
data: validation.data,
};

}
}

To use action, first Import the contactAction in the form component, and now pass contactAction as the first argument in useActionState(contactAction, undefined).

If you pass the initial value or state, you can pass in the second argument. In our case, it is undefined.

"use client";

import { useActionState } from "react";
import { toast } from "sonner";
import contactAction from "./action";
// ...

export function ContactForm() {

const [state, formAction] = useActionState(contactAction, undefined);

if (state?.success === true) {
toast.success("The form is successfully submit.");
} else {
state?.errors.every((error) => {
toast.error(error.message);
});
}

return (
<div className="mt-12 max-w-2xl mx-auto p-6 sm:p-8 bg-white dark:bg-gray-950 rounded-lg shadow-lg">
<div className="space-y-6">

<form action={formAction} className="space-y-4">
{...}
</form>

</div>
</div>
);
}

Now, You pass formAction as action in the form. The state contains the return data from the action.

I return an object that has three properties:

// components/form/action.ts

if (validation.success) {
return {
success: validation.success,
data: validation.data,
errors: false,
};
} else {
return {
success: validation.success,
errors: validation.error.issues,
data: validation.data,
};
}

Based on the state (useActionState) result, I show a successful or error message on the screen using the sonner package.

// components/form/action.ts

if (state?.success === true) {
toast.success("The form is successfully submit.");
} else {
state?.errors.every((error) => {
toast.error(error.message);
});
}

Ignore

If you encounter the following error, it is caused by Shadcn UI, as Shadcn UI currently does not support React 19. Please disregard it.

Warning: Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.

Save the data in the database.

In the previous article, we used the submitJSON API to submit data. But You can also use submitJSON or any other services to save form data in React.js server action.

For a better understanding, check out the following example:

// components/form/action.ts

"use server";
import { formSchema } from "./schema";
import { toast } from "sonner";
import { z } from "zod";
import { FormDataReturn } from "@/types";
import SubmitJSON from "submitjson";

const sj = new SubmitJSON({
apiKey: process.env.SUBMIT_JSON_API_KEY as string,
endpoint: process.env.ENDPOINT,
});

export default async function contactAction(_prevState: any, params: FormData) {


// validate the form data using Zod safeParseAsync API.

const validation = await formSchema.safeParseAsync({
firstName: params.get("first-name"), // get first name
lastName: params.get("last-name"), // get last name
email: params.get("email"), // get email
username: params.get("username"), // get user name
phoneNumber: params.get("phone"), // get phone
message: params.get("message"), // get message
service: params.get("service"), // get service
});

if (validation.success) {

const response = await sj.submit(validation.data); // submit data from database

// return a success state.
return {
success: validation.success,
data: response,
errors: false,
};

} else {

// return an error state.
return {
success: validation.success,
errors: validation.error.issues,
data: validation.data,
};

}
}

How to use the useFormStatus hook?

The useFormStatus hook gives you status information such as pending, data, method, and action when the form is submitted.

import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";

export function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button disabled={pending} type="submit" className="w-full">
{pending ? "Submitting..." : "Submit"}
</Button>
);
}

The useFormStatus hook only works inside the form element.

"use client";

import { SubmitButton } from "./button";

export function ContactForm() {


return (
<div className="mt-12 max-w-2xl mx-auto p-6 sm:p-8 bg-white dark:bg-gray-950 rounded-lg shadow-lg">
<div className="space-y-6">

<form action={formAction} className="space-y-4">
<SubmitButton /> {/* use inside form element */}
</form>

</div>
</div>
);
}

Conclusion

The React server action is best for building a React form using the useActionState hook, but not every form does need the useActionState hook.

Using the useActionState hook is sometimes a disadvantage. For example, sometimes you write misspelled, and when you submit your form, your form data is lost. You need to fill out your form again.

I prefer the React hook form package to build any form. The React hook form package gives you far more features as compared to react server actions.

To learn more about frontend developers, reactjs, nextjs, and Linux stuff, follow the frontend web publication on Medium and other updates. You can also follow me on Twitter (X) and Medium.

--

--

Rajdeep Singh
FrontEnd web

JavaScript | TypeScript | Reactjs | Nextjs | Rust | Biotechnology | Bioinformatic | Frontend Developer | Author | https://linktr.ee/officialrajdeepsingh