Contact form

A contact form. Validate the input before sending, and show “sending” and “sent” states on screen.

Contact

Key points

  1. 1

    Disable the button while sending

    While the message is being sent, disable the button and show “Sending…”. If it stays clickable, the same message might arrive twice.

  2. 2

    Switch the whole screen after sending

    Once it’s sent, hide the form and show a “Message sent” screen. Saying when to expect a reply helps people feel at ease.

  3. 3

    Show how much can be written up front

    Show “current characters / limit” below the message, and turn it red when over the limit. That’s kinder than saying “too long” after they hit send.

Tools used

Click to see the comparison in Part 2

Edit the code

Edit the code and the preview on the right updates.

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { CircleCheckIcon } from "lucide-react"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"

// Inquiry types
const kinds = ["Using the site", "Bug report", "Other"]

// Validation rules (Zod)
const schema = z.object({
  name: z.string().min(1, "Please enter your name"),
  email: z.string().email("Please enter a valid email address"),
  kind: z.enum(kinds, { error: "Please choose a type" }),
  message: z.string().min(10, "Please write at least 10 characters").max(500, "Please keep it to 500 characters or fewer"),
})

// A function that pretends to send (takes 1.5 seconds)
function send(values) {
  return new Promise((resolve) => setTimeout(resolve, 1500))
}

// Group a label, an input, and its error message together
function Field({ id, label, error, children }) {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>{label}</Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  )
}

export default function App() {
  // ① Whether sending is done (starts as false), kept in state
  const [sent, setSent] = useState(false)

  const { register, handleSubmit, watch, reset, formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { name: "", email: "", kind: undefined, message: "" },
  })

  // Watch the message so we can count its characters
  const message = watch("message")

  // Runs when Send is clicked and validation passes
  async function onSubmit(values) {
    await send(values) // Wait until sending finishes (1.5 seconds)
    // ② Sending is done, so set sent to true
    setSent(true)
  }

  // ③ Once sent, return the done screen first
  //    The condition: has sending finished?
  if (sent) {
    return (
      <div className="mx-auto my-6 w-full max-w-lg rounded-xl border bg-card p-10 text-center text-card-foreground">
        <CircleCheckIcon className="mx-auto size-12 text-emerald-500" />
        <p className="mt-4 text-lg font-semibold">Message sent</p>
        <p className="mt-2 text-sm text-muted-foreground">
          Thank you for contacting us. We'll reply by email within 2 business days.
        </p>
        {/* ④ On click, clear the inputs (reset) and go back to the form (set sent to false) */}
        <Button variant="outline" className="mt-6" onClick={() => {
          reset()
          setSent(false)
        }}>
          Send another (demo)
        </Button>
      </div >
    )
  }
  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="mx-auto my-6 flex w-full max-w-lg flex-col gap-5 rounded-xl border bg-card p-6 text-card-foreground"
    >
      <div>
        <h1 className="text-lg font-semibold">Contact us</h1>
        <p className="mt-1 text-sm text-muted-foreground">Feel free to send us your questions or feedback.</p>
      </div>

      <Field id="name" label="Name" error={errors.name?.message}>
        <Input id="name" placeholder="John Smith" {...register("name")} />
      </Field>
      <Field id="email" label="Email" error={errors.email?.message}>
        <Input id="email" type="email" placeholder="john@example.com" {...register("email")} />
      </Field>

      <div className="flex flex-col gap-2">
        <Label>Type</Label>
        <div className="flex flex-wrap gap-2">
          {kinds.map((kind) => (
            <label
              key={kind}
              className="cursor-pointer rounded-full border px-3 py-1.5 text-sm has-checked:border-primary has-checked:bg-primary has-checked:text-primary-foreground"
            >
              <input type="radio" value={kind} className="sr-only" {...register("kind")} />
              {kind}
            </label>
          ))}
        </div>
        {errors.kind && <p className="text-sm text-destructive">{errors.kind.message}</p>}
      </div>

      <Field id="message" label="Message" error={errors.message?.message}>
        <textarea
          id="message"
          rows={5}
          placeholder="Please write at least 10 characters"
          className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
          {...register("message")}
        />
        {/* ⑦ Red (text-destructive) over 500 characters, otherwise gray (text-muted-foreground) */}
        <p className={message.length > 500 ? "text-right text-xs text-destructive" : "text-right text-xs text-muted-foreground"}>
          {/* ⑥ The current character count of the message */}
          {message.length}  / 500
        </p>
      </Field>

      {/* ⑤ Disabled while sending (isSubmitting is true). Text is "Sending…" while sending, otherwise "Send" */}
      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending…" : "Send"}
      </Button>
    </form>
  )
}