Skip to main content

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.

Form Architecture

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

CreateMessageForm

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

1

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
2

Server Action

The form submits to a Server Action that handles validation and API calls
3

Toast Notifications

Uses useEffect to show toast notifications when state changes
4

Inline Alerts

Displays inline alerts for persistent error/success messages
5

Disabled State

Submit button is disabled during form submission

CreatePersonForm

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

1

Mark as Server

Add "use server" directive at the top of the file
2

Define State Interface

Create TypeScript interface for the state shape
interface MessagePrevState {
  success?: boolean;
  error?: string;
}
3

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),
});
4

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")),
});
5

Handle Errors

Return validation errors to the client
if (!parsedData.success) {
  return {
    error: parsedData.error.errors.map((err) => err.message).join(", "),
  };
}
6

Make API Call

Send validated data to the Hono backend
7

Revalidate Path

Call revalidatePath("/") to refresh the page data

Form Validation with Zod

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:
src/app/layout.tsx
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.

Form Field Patterns

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>

Number Input

<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:
  1. Without JS: Form submits traditionally, page reloads, Server Action runs
  2. 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.

Form Dialog Integration

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

Build docs developers (and LLMs) love