Overview
The application uses React Server Actions combined with the useActionState hook for form handling. This approach provides a modern, progressive enhancement pattern where forms work without JavaScript while providing rich interactivity when JavaScript is available.
All form components are Client Components (marked with "use client") because they use React hooks for state management.
The application has four main form components:
CreateMessageForm Create new scheduled messages
CreatePersonForm Add new contacts to the system
UpdateMessageForm Edit existing messages
UpdatePerson Modify contact information
This form demonstrates the complete pattern for creating resources with validation, error handling, and user feedback.
Component Structure
src/components/CreateMessageForm.tsx
"use client" ;
import { useActionState , useEffect } from "react" ;
import { People } from "./Messages" ;
import { Input } from "./ui/input" ;
import { Textarea } from "./ui/textarea" ;
import {
Select ,
SelectContent ,
SelectItem ,
SelectTrigger ,
SelectValue ,
} from "./ui/select" ;
import { Button } from "./ui/button" ;
import { Label } from "./ui/label" ;
import createMessage from "@/lib/actions/message/createMessage" ;
import { Alert , AlertTitle } from "./ui/alert" ;
import { toast } from "sonner" ;
export default function MessageUpdateForm ({ people } : { people : People [] }) {
const [ createState , createAction , createIsPending ] = useActionState (
createMessage ,
{}
);
useEffect (() => {
if ( createState . success ) {
toast . success ( "Message created successfully" , {
richColors: true ,
});
}
if ( createState . error ) {
toast . error ( "Message creation failed" , {
description: createState . error ,
duration: 3500 ,
richColors: true ,
});
}
}, [ createState ]);
return (
< div className = "space-y-2" >
< form action = { createAction } className = "space-y-3 flex flex-col" >
< div className = "space-y-2" >
< Label htmlFor = "content" className = "font-bold" >
Message
</ Label >
< Textarea name = "content" className = "h-32" />
</ div >
< div className = "space-y-2" >
< Label htmlFor = "sendToPhone" className = "font-bold" >
Send To
</ Label >
< Select name = "sendToPhone" >
< SelectTrigger className = "w-full" >
< SelectValue placeholder = "Send To" />
</ SelectTrigger >
< SelectContent >
{ people . map (( person ) => (
< SelectItem key = { person . id } value = { person . phone } >
{ person . name } ( { person . phone } )
</ SelectItem >
)) }
</ SelectContent >
</ Select >
</ div >
< div className = "space-y-2" >
< Label htmlFor = "sendAfter" className = "font-bold" >
Send After (in days)
</ Label >
< Input type = "number" step = { 0.0001 } min = { 0 } name = "sendAfter" />
</ div >
< Button type = "submit" disabled = { createIsPending } className = "mt-4" >
Add
</ Button >
</ form >
{ createState . error && (
< Alert variant = "destructive" >
< AlertTitle > { createState . error } </ AlertTitle >
</ Alert >
) }
{ createState . success && (
< Alert className = "text-green-600" >
< AlertTitle > Message Created successfully </ AlertTitle >
</ Alert >
) }
</ div >
);
}
Key Features
useActionState Hook
The useActionState hook manages form state and submission: const [ createState , createAction , createIsPending ] = useActionState (
createMessage , // Server Action function
{} // Initial state
);
Returns:
createState - Current state with success/error properties
createAction - Form action handler
createIsPending - Boolean indicating submission in progress
Server Action
The form submits to a Server Action that handles validation and API calls
Toast Notifications
Uses useEffect to show toast notifications when state changes
Inline Alerts
Displays inline alerts for persistent error/success messages
Disabled State
Submit button is disabled during form submission
A simpler form demonstrating the same pattern with fewer fields.
src/components/CreatePersonForm.tsx
"use client" ;
import { useActionState , useEffect } from "react" ;
import { Input } from "./ui/input" ;
import { Button } from "./ui/button" ;
import { Label } from "./ui/label" ;
import createPerson from "@/lib/actions/people/createPerson" ;
import { toast } from "sonner" ;
export default function PersonCreate () {
const [ createState , createAction , createIsPending ] = useActionState (
createPerson ,
{}
);
useEffect (() => {
if ( createState . success ) {
toast . success ( "Person created successfully" , {
richColors: true ,
});
}
if ( createState . error ) {
toast . error ( "Person creation failed" , {
description: createState . error ,
duration: 3500 ,
richColors: true ,
});
}
}, [ createState ]);
return (
< div className = "space-y-2" >
< form action = { createAction } className = "space-y-3 flex flex-col" >
< div className = "space-y-2" >
< Label htmlFor = "name" className = "font-bold" >
Name
</ Label >
< Input name = "name" />
</ div >
< div className = "space-y-2" >
< Label htmlFor = "phone" className = "font-bold" >
Phone
</ Label >
< Input name = "phone" />
</ div >
< Button type = "submit" disabled = { createIsPending } className = "mt-4" >
Add
</ Button >
</ form >
</ div >
);
}
Server Actions
Server Actions are async functions that run on the server. They handle form validation, API calls, and database operations.
Example: createMessage Action
src/lib/actions/message/createMessage.ts
"use server" ;
import { revalidatePath } from "next/cache" ;
import { z } from "zod" ;
interface MessagePrevState {
success ?: boolean ;
error ?: string ;
}
// Define Zod schema for validation
const messageSchema = z . object ({
content: z . string (). min ( 1 , "Content cannot be empty" ),
sendToPhone: z
. string ()
. regex ( / ^ 8801 \d {9} $ / , "Invalid phone number format. Must be 8801XXXXXXXXX" ),
sendAfter: z
. number ()
. min ( 1 , "Send after is required and minimum after 1 day" ),
});
export default async function createMessage (
prevState : MessagePrevState ,
formData : FormData
) : Promise < MessagePrevState > {
try {
// Validate form data with Zod
const parsedData = messageSchema . safeParse ({
content: formData . get ( "content" ),
sendToPhone: formData . get ( "sendToPhone" ),
sendAfter: Number ( formData . get ( "sendAfter" )),
});
if ( ! parsedData . success ) {
return {
error: parsedData . error . errors . map (( err ) => err . message ). join ( ", " ),
};
}
// Make API request to Hono backend
const response = await fetch (
` ${ process . env . BACKEND_URL } /messages/create-one` ,
{
method: "POST" ,
headers: {
Authorization: `Basic ${ Buffer . from (
` ${ process . env . USERNAME } : ${ process . env . PASSWORD } `
). toString ( "base64" ) } ` ,
"Content-Type" : "application/json" ,
},
body: JSON . stringify ( parsedData . data ),
}
);
if ( ! response . ok ) {
return {
error: `Backend Error: ${ response . status } ${ response . statusText } ` ,
};
}
// Revalidate the page to show updated data
revalidatePath ( "/" );
return { success: true };
} catch ( error ) {
return {
error: error instanceof Error ? error . message : "Something went wrong" ,
};
}
}
Server Action Pattern
Mark as Server
Add "use server" directive at the top of the file
Define State Interface
Create TypeScript interface for the state shape interface MessagePrevState {
success ?: boolean ;
error ?: string ;
}
Create Zod Schema
Define validation schema using Zod const messageSchema = z . object ({
content: z . string (). min ( 1 , "Content cannot be empty" ),
sendToPhone: z . string (). regex ( / ^ 8801 \d {9} $ / ),
sendAfter: z . number (). min ( 1 ),
});
Validate Data
Use safeParse to validate form data const parsedData = messageSchema . safeParse ({
content: formData . get ( "content" ),
sendToPhone: formData . get ( "sendToPhone" ),
sendAfter: Number ( formData . get ( "sendAfter" )),
});
Handle Errors
Return validation errors to the client if ( ! parsedData . success ) {
return {
error: parsedData . error . errors . map (( err ) => err . message ). join ( ", " ),
};
}
Make API Call
Send validated data to the Hono backend
Revalidate Path
Call revalidatePath("/") to refresh the page data
The application uses Zod for schema validation:
import { z } from "zod" ;
const messageSchema = z . object ({
content: z . string (). min ( 1 , "Content cannot be empty" ),
sendToPhone: z
. string ()
. regex ( / ^ 8801 \d {9} $ / , "Invalid phone number format. Must be 8801XXXXXXXXX" ),
sendAfter: z
. number ()
. min ( 1 , "Send after is required and minimum after 1 day" ),
});
Validation Features:
Type safety with TypeScript
Custom error messages
Regex pattern matching
Number constraints (min, max)
String length validation
Toast Notifications
The application uses Sonner for toast notifications:
import { toast } from "sonner" ;
useEffect (() => {
if ( createState . success ) {
toast . success ( "Message created successfully" , {
richColors: true ,
});
}
if ( createState . error ) {
toast . error ( "Message creation failed" , {
description: createState . error ,
duration: 3500 ,
richColors: true ,
});
}
}, [ createState ]);
Toast Setup
The Toaster component is added in the root layout:
import { Toaster } from "@/components/ui/sonner"
export default function RootLayout ({ children }) {
return (
< html lang = "en" >
< body >
< Toaster />
{ children }
</ body >
</ html >
);
}
Toast notifications provide temporary feedback, while Alert components show persistent messages within the form.
Text Input
< div className = "space-y-2" >
< Label htmlFor = "name" className = "font-bold" >
Name
</ Label >
< Input name = "name" />
</ div >
Textarea
< div className = "space-y-2" >
< Label htmlFor = "content" className = "font-bold" >
Message
</ Label >
< Textarea name = "content" className = "h-32" />
</ div >
< div className = "space-y-2" >
< Label htmlFor = "sendAfter" className = "font-bold" >
Send After (in days)
</ Label >
< Input type = "number" step = { 0.0001 } min = { 0 } name = "sendAfter" />
</ div >
Select Dropdown
< div className = "space-y-2" >
< Label htmlFor = "sendToPhone" className = "font-bold" >
Send To
</ Label >
< Select name = "sendToPhone" >
< SelectTrigger className = "w-full" >
< SelectValue placeholder = "Send To" />
</ SelectTrigger >
< SelectContent >
{ people . map (( person ) => (
< SelectItem key = { person . id } value = { person . phone } >
{ person . name } ( { person . phone } )
</ SelectItem >
)) }
</ SelectContent >
</ Select >
</ div >
Error Handling
The application handles errors at multiple levels:
1. Validation Errors
if ( ! parsedData . success ) {
return {
error: parsedData . error . errors . map (( err ) => err . message ). join ( ", " ),
};
}
2. API Errors
if ( ! response . ok ) {
return {
error: `Backend Error: ${ response . status } ${ response . statusText } ` ,
};
}
3. Runtime Errors
try {
// ... form handling
} catch ( error ) {
return {
error: error instanceof Error ? error . message : "Something went wrong" ,
};
}
4. Client Display
{ createState . error && (
< Alert variant = "destructive" >
< AlertTitle > { createState . error } </ AlertTitle >
</ Alert >
)}
Progressive Enhancement
Forms work without JavaScript:
Without JS : Form submits traditionally, page reloads, Server Action runs
With JS : Form submits via useActionState, no page reload, optimistic UI updates
Always test forms with JavaScript disabled to ensure they work for all users.
Forms are typically shown inside Dialog components:
< Dialog >
< DialogTrigger asChild >
< Button className = "w-full" >
< MailPlus />
< span > Add Message </ span >
</ Button >
</ DialogTrigger >
< DialogContent >
< DialogTitle className = "text-lg font-semibold text-center" >
Add Message
</ DialogTitle >
< CreateMessageForm people = { people } />
</ DialogContent >
</ Dialog >
Best Practices
Validation First Always validate on the server with Zod before processing
Clear Feedback Show both toast notifications and inline alerts
Disable During Submit Disable submit button when isPending is true
Revalidate Data Call revalidatePath() after successful mutations
Next Steps
Server Actions Deep dive into Server Actions implementation
API Integration Learn about backend API communication