Refactor contact form validation to use useDebounce for improved performance and streamline error handling. Simplify error retrieval logic and ensure touched state is updated on input changes.

This commit is contained in:
jake committed 2025-09-02 19:10:56 -04:00
1 parent 20d8aad41f
commit b06e7bbf4d
3 files changed
+29 -15

No files matched your search

+28 -14
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useState } from "react"; import { useActionState, useState } from "react";
import { useDebounce } from "react-use";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react"; import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
import Link from "@/components/link"; import Link from "@/components/link";
import Input from "@/components/ui/input"; import Input from "@/components/ui/input";
@@ -8,8 +9,8 @@ import Textarea from "@/components/ui/textarea";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { MarkdownIcon } from "@/components/icons"; import { MarkdownIcon } from "@/components/icons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { send, type ContactState } from "@/lib/server/resend"; import { send, type ContactState } from "@/lib/server/contact";
import { ContactSchema, type ContactInput } from "@/lib/validation/contact"; import { ContactSchema, type ContactInput } from "@/lib/schemas/contact";
const ContactForm = () => { const ContactForm = () => {
const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, { const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, {
@@ -24,27 +25,37 @@ const ContactForm = () => {
message: "", message: "",
}); });
// client-side validation using shared schema // keep track of which fields have been touched
const [clientErrors, setClientErrors] = useState<Partial<Record<keyof ContactInput, string[]>>>({}); const [touched, setTouched] = useState<Record<keyof ContactInput, boolean>>({
const [touched, setTouched] = useState<{ name: boolean; email: boolean; message: boolean }>({
name: false, name: false,
email: false, email: false,
message: false, message: false,
}); });
useEffect(() => { // client-side validation using shared schema
const id = setTimeout(() => { const [clientErrors, setClientErrors] = useState<Partial<Record<keyof ContactInput, string[]>>>({});
useDebounce(
() => {
const result = ContactSchema.safeParse(formFields); const result = ContactSchema.safeParse(formFields);
setClientErrors(result.success ? {} : result.error.flatten().fieldErrors); setClientErrors(result.success ? {} : result.error.flatten().fieldErrors);
}, 200); },
return () => clearTimeout(id); 150,
}, [formFields]); [formFields]
);
const hasClientErrors = Object.values(clientErrors).some((errs) => (errs?.length || 0) > 0); const hasClientErrors = Object.values(clientErrors).some((errs) => (errs?.length || 0) > 0);
const nameError = (touched.name ? clientErrors.name?.[0] : undefined) ?? formState.errors?.name?.[0]; const getErrorForField = (field: keyof ContactInput): string | undefined => {
const emailError = (touched.email ? clientErrors.email?.[0] : undefined) ?? formState.errors?.email?.[0]; const ce = clientErrors[field]?.[0];
const messageError = (touched.message ? clientErrors.message?.[0] : undefined) ?? formState.errors?.message?.[0]; if (ce) return ce;
if (!touched[field]) return formState.errors?.[field]?.[0];
return undefined;
};
const nameError = getErrorForField("name");
const emailError = getErrorForField("email");
const messageError = getErrorForField("message");
return ( return (
<form action={formAction} className="my-6 space-y-4"> <form action={formAction} className="my-6 space-y-4">
@@ -56,6 +67,7 @@ const ContactForm = () => {
value={formFields.name} value={formFields.name}
onChange={(e) => { onChange={(e) => {
setFormFields({ ...formFields, name: e.target.value }); setFormFields({ ...formFields, name: e.target.value });
setTouched((t) => ({ ...t, name: true }));
}} }}
onBlur={() => setTouched((t) => ({ ...t, name: true }))} onBlur={() => setTouched((t) => ({ ...t, name: true }))}
disabled={pending || formState.success} disabled={pending || formState.success}
@@ -73,6 +85,7 @@ const ContactForm = () => {
value={formFields.email} value={formFields.email}
onChange={(e) => { onChange={(e) => {
setFormFields({ ...formFields, email: e.target.value }); setFormFields({ ...formFields, email: e.target.value });
setTouched((t) => ({ ...t, email: true }));
}} }}
onBlur={() => setTouched((t) => ({ ...t, email: true }))} onBlur={() => setTouched((t) => ({ ...t, email: true }))}
disabled={pending || formState.success} disabled={pending || formState.success}
@@ -88,6 +101,7 @@ const ContactForm = () => {
value={formFields.message} value={formFields.message}
onChange={(e) => { onChange={(e) => {
setFormFields({ ...formFields, message: e.target.value }); setFormFields({ ...formFields, message: e.target.value });
setTouched((t) => ({ ...t, message: true }));
}} }}
onBlur={() => setTouched((t) => ({ ...t, message: true }))} onBlur={() => setTouched((t) => ({ ...t, message: true }))}
disabled={pending || formState.success} disabled={pending || formState.success}
File renamed without changes.
@@ -2,7 +2,7 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { Resend } from "resend"; import { Resend } from "resend";
import { ContactSchema } from "@/lib/validation/contact"; import { ContactSchema } from "@/lib/schemas/contact";
import siteConfig from "@/lib/config/site"; import siteConfig from "@/lib/config/site";
import { checkBotId } from "botid/server"; import { checkBotId } from "botid/server";