This episode covers handling forms in React components, client-side validation with React Hook Form and Zod, server-side validation and submission to an API with Server Actions, and form accessibility with good UX feedback.

Forms are the gateway through which users enter an application: registration, login, search, and even payments. A bad form — confusing validation, unclear errors, input lost on failure — frustrates users and drives them away.
Episode 10 covers handling forms in React components, client-side validation with React Hook Form and Zod, server-side validation and data submission with Server Actions, and form accessibility with UX feedback.
Without a library, React forms are managed with state per input. For small forms, this approach is enough:
"use client"
import { useState } from "react"
export default function ContactForm() {
const [email, setEmail] = useState("")
return (
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</form>
)
}The input above is controlled by the email state. For forms with many fields, complex validation, and smooth error handling, React Hook Form offers better control.
React Hook Form manages input registration and avoids excessive re-renders. Combine it with Zod via zodResolver for a clear validation schema:
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
email: z.string().email("Format email tidak valid"),
nama: z.string().min(3, "Nama minimal 3 karakter"),
})
type FormValues = z.infer<typeof schema>
export default function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
})
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Daftar</button>
</form>
)
}The Zod schema above validates the email format and name length. errors.email.message shows the error message below the input that failed validation — immediate feedback without submitting.
Client-side validation is only for UX; server validation is the real line of defense. Users can easily bypass JavaScript, so the server must re-validate all input. Both layers should use the same rules — and Zod allows the schema to be shared between client and server.
Server Actions let a function on the server be called directly from a form without writing an API endpoint. Write the function with the "use server" directive and validate with the same schema:
"use server"
import { z } from "zod"
const schema = z.object({
email: z.string().email(),
})
export async function daftarPengguna(prevState, formData) {
const parsed = schema.safeParse({
email: formData.get("email"),
})
if (!parsed.success) {
return { error: "Data tidak valid" }
}
return { success: "Pendaftaran berhasil" }
}schema.safeParse(formData) validates data from the form without throwing an exception — success or failure is read from the returned object. The Server Action returns state that's displayed in the form.
In the client component, use the useActionState hook to bind the Server Action to form state:
"use client"
import { useActionState } from "react"
import { daftarPengguna } from "./actions"
export default function Form() {
const [state, formAction] = useActionState(daftarPengguna, null)
return (
<form action={formAction}>
<input name="email" type="email" />
{state && <p>{state.error ?? state.success}</p>}
<button type="submit">Kirim</button>
</form>
)
}useActionState(daftarPengguna, null) runs the Server Action and displays its result. The form still works without JavaScript — a progressive enhancement pattern that search engines favor.
An accessible form starts with semantic HTML: labels connected to inputs via htmlFor, error messages linked with the aria-describedby attribute, and required fields marked required. Screen readers use these relationships to read errors correctly. Good UX feedback: show the error right below the relevant field, don't clear user input when a submit fails, and show a loading indicator while submission is in progress.
Here's what to take away:
In the next episode, episode 11, we'll discuss internationalization (i18n) — multi-language support with next-intl, localized routing and content translation, and SEO and metadata for multi-language applications. Your application will be ready to reach a global audience.