refactor: migrate contact form to TanStack Form

- Replace manual state management with @tanstack/react-form
- Add proper Field/FieldLabel components for accessibility
- Simplify server action (remove useActionState signature)
- Remove use-debounce dependency
- Update PGP key links and minor styling tweaks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.5 committed 2026-01-29 22:08:54 -05:00
1 parent 013311a618
commit bcc595e141
7 files changed
+266 -180

No files matched your search

+2 -2
View File
@@ -13,7 +13,7 @@ const Page = () => {
<> <>
<PageTitle canonical="/contact">Contact</PageTitle> <PageTitle canonical="/contact">Contact</PageTitle>
<div className="w-full md:mx-auto md:w-2/3"> <div className="mx-auto w-full max-w-2xl">
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none"> <div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
<p> <p>
Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "} Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "}
@@ -30,7 +30,7 @@ const Page = () => {
<p> <p>
You can grab my public key here:{" "} You can grab my public key here:{" "}
<a <a
href="https://jrvs.io/pgp" href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39" title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
+1 -1
View File
@@ -119,7 +119,7 @@ const Page = () => {
</Link>{" "} </Link>{" "}
<sup className=""> <sup className="">
<a <a
href="https://jrvs.io/pgp" href="https://keyoxide.org/hkp/3bc6e5776bf379d36f6714802b0c9cf251e69a39"
target="_blank" target="_blank"
rel="noopener pgpkey" rel="noopener pgpkey"
title="Download my PGP key" title="Download my PGP key"
+1 -1
View File
@@ -73,7 +73,7 @@ const Page = async () => {
href={repo!.url} href={repo!.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block text-base leading-relaxed font-semibold" className="inline-block text-base leading-relaxed font-semibold text-[#0969da] hover:underline dark:text-[#4493f8]"
> >
{repo!.name} {repo!.name}
</a> </a>
+177 -142
View File
@@ -1,165 +1,200 @@
"use client"; "use client";
import { useActionState, useState, useEffect } from "react"; import { useState } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useForm } from "@tanstack/react-form";
import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react"; import { SendIcon, Loader2Icon, CheckIcon, XIcon } from "lucide-react";
import Form from "next/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldError } from "@/components/ui/field";
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/contact"; import { sendContactForm, type ContactResult } from "@/lib/server/contact";
import { ContactSchema, type ContactInput } from "@/lib/schemas/contact"; import { ContactSchema } from "@/lib/schemas/contact";
const ContactForm = () => { const ContactForm = () => {
const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, { const [result, setResult] = useState<ContactResult | null>(null);
success: false,
message: "", const form = useForm({
defaultValues: {
name: "",
email: "",
message: "",
},
validators: {
onBlur: ContactSchema,
},
onSubmit: async ({ value }) => {
try {
const formData = new FormData();
formData.append("name", value.name);
formData.append("email", value.email);
formData.append("message", value.message);
const response = await sendContactForm(formData);
setResult(response);
if (response.success) {
form.reset();
}
} catch (error) {
console.error("[contact-form] error:", error);
setResult({
success: false,
message: "Something went wrong. Please try again.",
});
}
},
}); });
// keep track of input so we can repopulate the fields if the form fails
const [formFields, setFormFields] = useState<ContactInput>({
name: "",
email: "",
message: "",
});
// keep track of which fields have been touched
const [touched, setTouched] = useState<Record<keyof ContactInput, boolean>>({
name: false,
email: false,
message: false,
});
// client-side validation using shared schema
const [clientErrors, setClientErrors] = useState<Partial<Record<keyof ContactInput, string[]>>>({});
const debouncedValidate = useDebouncedCallback(() => {
const result = ContactSchema.safeParse(formFields);
setClientErrors(result.success ? {} : result.error.flatten().fieldErrors);
}, 150);
useEffect(() => {
debouncedValidate();
}, [formFields, debouncedValidate]);
const hasClientErrors = Object.values(clientErrors).some((errs) => (errs?.length || 0) > 0);
const getErrorForField = (field: keyof ContactInput): string | undefined => {
if (touched[field]) {
return clientErrors[field]?.[0];
}
return formState.errors?.[field]?.[0];
};
const nameError = getErrorForField("name");
const emailError = getErrorForField("email");
const messageError = getErrorForField("message");
return ( return (
<Form action={formAction} className="my-6 space-y-4"> <form
<div className="not-prose"> onSubmit={(e) => {
<Input e.preventDefault();
type="text" form.handleSubmit();
name="name" }}
placeholder="Name" className="my-5 space-y-4"
value={formFields.name} >
onChange={(e) => { <form.Subscribe selector={(state) => state.isSubmitting || result?.success}>
setFormFields({ ...formFields, name: e.target.value }); {(isDisabled) => (
setTouched((t) => ({ ...t, name: true })); <>
}} <form.Field name="name">
onBlur={() => setTouched((t) => ({ ...t, name: true }))} {(field) => {
disabled={pending || formState.success} const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
aria-invalid={nameError ? "true" : undefined} return (
/> <Field data-invalid={isInvalid || undefined} className="gap-1.5">
{nameError && <span className="text-destructive text-[0.8rem] font-semibold">{nameError}</span>} <FieldLabel htmlFor="name">Name</FieldLabel>
</div> <Input
id="name"
name="name"
type="text"
placeholder="Microsoft Bob"
autoComplete="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
disabled={!!isDisabled}
aria-invalid={isInvalid || undefined}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
<div> <form.Field name="email">
<Input {(field) => {
type="email" const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
name="email" return (
placeholder="Email" <Field data-invalid={isInvalid || undefined} className="gap-1.5">
inputMode="email" <FieldLabel htmlFor="email">Email</FieldLabel>
value={formFields.email} <Input
onChange={(e) => { id="email"
setFormFields({ ...formFields, email: e.target.value }); name="email"
setTouched((t) => ({ ...t, email: true })); type="email"
}} inputMode="email"
onBlur={() => setTouched((t) => ({ ...t, email: true }))} placeholder="robert@hotmail.com"
disabled={pending || formState.success} autoComplete="email"
aria-invalid={emailError ? "true" : undefined} spellCheck={false}
/> value={field.state.value}
{emailError && <span className="text-destructive text-[0.8rem] font-semibold">{emailError}</span>} onBlur={field.handleBlur}
</div> onChange={(e) => field.handleChange(e.target.value)}
disabled={!!isDisabled}
aria-invalid={isInvalid || undefined}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
<div> <form.Field name="message">
<Textarea {(field) => {
name="message" const isInvalid = field.state.meta.isTouched && field.state.meta.errors.length > 0;
placeholder="Write something..." return (
value={formFields.message} <Field data-invalid={isInvalid || undefined} className="gap-1.5">
onChange={(e) => { <FieldLabel htmlFor="message">Message</FieldLabel>
setFormFields({ ...formFields, message: e.target.value }); <Textarea
setTouched((t) => ({ ...t, message: true })); id="message"
}} name="message"
onBlur={() => setTouched((t) => ({ ...t, message: true }))} placeholder="Write something…"
disabled={pending || formState.success} value={field.state.value}
aria-invalid={messageError ? "true" : undefined} onBlur={field.handleBlur}
className="min-h-[6lh] resize-y" onChange={(e) => field.handleChange(e.target.value)}
/> disabled={!!isDisabled}
{messageError && <span className="text-destructive text-[0.8rem] font-semibold">{messageError}</span>} aria-invalid={isInvalid || undefined}
className="min-h-[6lh] resize-y"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
<div className="text-foreground/85 my-2 text-[0.8rem] leading-relaxed"> <p className="text-foreground/85 mt-1.5 text-[0.8rem] leading-relaxed">
<MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" /> <MarkdownIcon className="mr-1.5 inline-block size-4 align-text-top" />
Basic{" "} Basic{" "}
<a <a
href="https://commonmark.org/help/" href="https://commonmark.org/help/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Markdown reference sheet" title="Markdown reference sheet"
className="font-semibold" className="font-semibold"
> >
Markdown syntax Markdown syntax
</a>{" "} </a>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [ is allowed, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline"> <a href="https://jarv.is" target="_blank" rel="noopener" className="hover:no-underline">
links links
</a> </a>
](https://jarv.is), and <code>`code`</code>. ](https://jarv.is), and <code>`code`</code>.
</div> </p>
</div> </Field>
);
}}
</form.Field>
</>
)}
</form.Subscribe>
<div className="flex min-h-16 items-center space-x-4"> <div className="flex min-h-16 items-center space-x-4">
{!formState.success && ( <form.Subscribe selector={(state) => [, state.isSubmitting]}>
<Button type="submit" size="lg" disabled={pending || hasClientErrors}> {([isSubmitting]) => (
{pending ? ( <>
<> {!result?.success && (
<Loader2Icon className="animate-spin" /> <Button type="submit" size="lg" disabled={isSubmitting}>
Sending... {isSubmitting ? (
</> <>
) : ( <Loader2Icon className="animate-spin" aria-hidden="true" />
<> Sending…
<SendIcon /> </>
Send ) : (
</> <>
)} <SendIcon aria-hidden="true" />
</Button> Send
)} </>
)}
</Button>
)}
{!pending && formState.message && ( {!isSubmitting && result?.message && (
<div <div
className={cn( role="status"
"space-x-0.5 text-[0.9rem] font-semibold", aria-live="polite"
formState.success ? "text-green-600 dark:text-green-400" : "text-destructive" className={cn(
)} "space-x-0.5 text-[0.9rem] font-semibold",
> result.success ? "text-green-600 dark:text-green-400" : "text-destructive"
{formState.success ? <CheckIcon className="inline size-4" /> : <XIcon className="inline size-4" />}{" "} )}
<span>{formState.message}</span> >
</div> {result.success ? (
)} <CheckIcon className="inline size-4" aria-hidden="true" />
) : (
<XIcon className="inline size-4" aria-hidden="true" />
)}{" "}
<span>{result.message}</span>
</div>
)}
</>
)}
</form.Subscribe>
</div> </div>
</Form> </form>
); );
}; };
+20 -20
View File
@@ -6,49 +6,49 @@ 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";
// Schema and type now imported from shared validation module export type ContactResult = {
export type ContactState = {
success: boolean; success: boolean;
message: string; message: string;
errors?: Record<string, string[]>; errors?: Record<string, string[]>;
}; };
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => { export const sendContactForm = async (formData: FormData): Promise<ContactResult> => {
// TODO: remove after debugging why automated spam entries are causing 500 errors // TODO: remove after debugging why automated spam entries are causing 500 errors
console.debug("[server/contact] received payload:", payload); console.debug("[server/contact] received payload:", formData);
// BotID server-side verification // BotID server-side verification
const verification = await checkBotId(); const verification = await checkBotId();
if (verification.isBot) { if (verification.isBot) {
console.warn("[server/contact] botid verification failed:", verification); console.warn("[server/contact] botid verification failed:", verification);
throw new Error("Bot check failed"); return {
success: false,
message: "Verification failed. Please try again.",
};
}
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return {
success: false,
message: "Please make sure all fields are filled in correctly.",
errors: parsed.error.flatten().fieldErrors,
};
} }
try { try {
const data = ContactSchema.safeParse(Object.fromEntries(payload));
if (!data.success) {
return {
success: false,
message: "Please make sure all fields are filled in.",
errors: data.error.flatten().fieldErrors,
};
}
if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") { if (env.RESEND_FROM_EMAIL === "onboarding@resend.dev") {
// https://resend.com/docs/api-reference/emails/send-email // https://resend.com/docs/api-reference/emails/send-email
console.warn("[server/contact] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev."); console.warn("[server/contact] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
} }
// send email
const resend = new Resend(env.RESEND_API_KEY); const resend = new Resend(env.RESEND_API_KEY);
await resend.emails.send({ await resend.emails.send({
from: `${data.data.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`, from: `${parsed.data.name} <${env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.data.name} <${data.data.email}>`, replyTo: `${parsed.data.name} <${parsed.data.email}>`,
to: [env.RESEND_TO_EMAIL], to: [env.RESEND_TO_EMAIL],
subject: `[${siteConfig.name}] Contact Form Submission`, subject: `[${siteConfig.name}] Contact Form Submission`,
text: data.data.message, text: parsed.data.message,
}); });
return { success: true, message: "Thanks! You should hear from me soon." }; return { success: true, message: "Thanks! You should hear from me soon." };
+1 -1
View File
@@ -47,6 +47,7 @@
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10", "@t3-oss/env-nextjs": "^0.13.10",
"@tanstack/react-form": "^1.28.0",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/functions": "^3.4.0", "@vercel/functions": "^3.4.0",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
@@ -98,7 +99,6 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"unified": "^11.0.5", "unified": "^11.0.5",
"use-debounce": "^10.1.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
+64 -13
View File
@@ -89,6 +89,9 @@ importers:
'@t3-oss/env-nextjs': '@t3-oss/env-nextjs':
specifier: ^0.13.10 specifier: ^0.13.10
version: 0.13.10(typescript@5.9.3)(zod@4.3.6) version: 0.13.10(typescript@5.9.3)(zod@4.3.6)
'@tanstack/react-form':
specifier: ^1.28.0
version: 1.28.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/analytics': '@vercel/analytics':
specifier: ^1.6.1 specifier: ^1.6.1
version: 1.6.1(next@16.1.6(@babel/core@7.28.6)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) version: 1.6.1(next@16.1.6(@babel/core@7.28.6)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
@@ -242,9 +245,6 @@ importers:
unified: unified:
specifier: ^11.0.5 specifier: ^11.0.5
version: 11.0.5 version: 11.0.5
use-debounce:
specifier: ^10.1.0
version: 10.1.0(react@19.2.4)
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -1970,6 +1970,38 @@ packages:
peerDependencies: peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/devtools-event-client@0.4.0':
resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==}
engines: {node: '>=18'}
'@tanstack/form-core@1.28.0':
resolution: {integrity: sha512-MX3YveB6SKHAJ2yUwp+Ca/PCguub8bVEnLcLUbFLwdkSRMkP0lMGdaZl+F0JuEgZw56c6iFoRyfILhS7OQpydA==}
'@tanstack/pacer-lite@0.1.1':
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
engines: {node: '>=18'}
'@tanstack/react-form@1.28.0':
resolution: {integrity: sha512-ibLcf5QkTogV0Ly944CuqGxWTpHyreNA4Cy8Wtky7zE9wtE3HVapQt4/hUuXo51zihfTkv5URiXpoTSKF5Xosg==}
peerDependencies:
'@tanstack/react-start': '*'
react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@tanstack/react-start':
optional: true
'@tanstack/react-store@0.8.0':
resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/store@0.7.7':
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
'@tanstack/store@0.8.0':
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -5052,12 +5084,6 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
use-debounce@10.1.0:
resolution: {integrity: sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '*'
use-sidecar@1.1.3: use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -6695,6 +6721,35 @@ snapshots:
postcss-selector-parser: 6.0.10 postcss-selector-parser: 6.0.10
tailwindcss: 4.1.18 tailwindcss: 4.1.18
'@tanstack/devtools-event-client@0.4.0': {}
'@tanstack/form-core@1.28.0':
dependencies:
'@tanstack/devtools-event-client': 0.4.0
'@tanstack/pacer-lite': 0.1.1
'@tanstack/store': 0.7.7
'@tanstack/pacer-lite@0.1.1': {}
'@tanstack/react-form@1.28.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/form-core': 1.28.0
'@tanstack/react-store': 0.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
transitivePeerDependencies:
- react-dom
'@tanstack/react-store@0.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/store': 0.8.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
use-sync-external-store: 1.6.0(react@19.2.4)
'@tanstack/store@0.7.7': {}
'@tanstack/store@0.8.0': {}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -10378,10 +10433,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.10 '@types/react': 19.2.10
use-debounce@10.1.0(react@19.2.4):
dependencies:
react: 19.2.4
use-sidecar@1.1.3(@types/react@19.2.10)(react@19.2.4): use-sidecar@1.1.3(@types/react@19.2.10)(react@19.2.4):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0