This episode covers form handling with controlled and uncontrolled components, then React Hook Form with validation rules and schema validation using Yup or Zod. You'll also learn form accessibility and clear input feedback.

Forms are the main gateway for user data, but also the most common source of bugs: inconsistent validation, confusing error messages, and neglected accessibility. Episode 11 makes you master React forms thoroughly, starting with the basics — controlled and uncontrolled components — then moving to React Hook Form with schema validation using Yup or Zod, and finishing with accessibility rules and input feedback that's comfortable for everyone.
As in episode 5, a controlled component puts the input value in state:
import { useState } from "react"
function Form() {
const [nama, setNama] = useState("")
const submit = (e) => {
e.preventDefault()
console.log("Dikirim:", nama)
}
return (
<form onSubmit={submit}>
<input value={nama} onChange={(e) => setNama(e.target.value)} />
<button type="submit">Kirim</button>
</form>
)
}e.preventDefault() stops the default page reload, then handleSubmit processes the data. Controlled components fit best when the input value is used for immediate validation or other UI.
For simple, one-shot forms, uncontrolled components use ref and read the value at submit time, without re-rendering on every keystroke:
import { useRef } from "react"
function Form() {
const namaRef = useRef(null)
const handleSubmit = (e) => {
e.preventDefault()
console.log("Dikirim:", namaRef.current.value)
}
return (
<form onSubmit={handleSubmit}>
<input ref={namaRef} defaultValue="" />
<button type="submit">Kirim</button>
</form>
)
}namaRef.current.value reads the input value at submit time. Without onChange, the component doesn't re-render on every keystroke — more efficient for large forms. React Hook Form in the next section uses this approach internally.
React Hook Form combines uncontrolled speed with easy validation:
npm install react-hook-formimport { useForm } from "react-hook-form"
function Form() {
const { register, handleSubmit, formState } = useForm()
const { errors } = formState
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "Email wajib diisi" })} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Kirim</button>
</form>
)
}register("email", { required: "Email wajib diisi" }) registers the input to the form with validation rules. handleSubmit(onSubmit) validates first — the onSubmit function only runs when all rules pass, and errors are available in formState.errors.
Even without a schema, React Hook Form provides basic rules like min, max, and maxLength written inside register, just like required in the example above. For more complex, reusable rules, the schema validation in the next section is the cleaner choice.
Schema validation defines the rules once, then uses them for both validation and types. Zod is the modern choice:
npm install zod @hookform/resolversimport { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const skema = z.object({
email: z.string().email("Format email salah"),
password: z.string().min(8, "Minimal 8 karakter"),
})
function Form() {
const { register, handleSubmit, formState } = useForm({
resolver: zodResolver(skema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{formState.errors.email && <p>{formState.errors.email.message}</p>}
<input type="password" {...register("password")} />
{formState.errors.password && <p>{formState.errors.password.message}</p>}
<button type="submit">Daftar</button>
</form>
)
}zodResolver(skema) connects the Zod schema to React Hook Form. z.object, z.string().email, and z.string().min define the rules; error messages are pulled automatically from the schema. In TypeScript, the form data type can be inferred directly from the schema. Yup has a similar API with string().email().min(8) — choosing between them is a matter of preference, and both run through the same @hookform/resolvers.
An accessible form needs three things: a label connected to the input, aria-* attributes that describe status, and error messages a screen reader can read:
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
{...register("email", { required: "Email wajib diisi" })}
aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
</form>htmlFor and id connect the label to the input. aria-invalid tells the screen reader that the input has a problem, and role="alert" announces the error message when it appears. noValidate disables the browser's built-in validation so React Hook Form handles it consistently.
Episode 11 made you proficient in React forms: the difference between controlled and uncontrolled components, React Hook Form with validation rules, schema validation with Zod and Yup, plus accessibility and user-friendly input feedback.
Key takeaways:
register; errors appear in formState.errors.htmlFor, aria-invalid, and role="alert" make forms accessible.In the next episode, episode 12, we'll cover secure frontend & auth patterns — authentication patterns for SPAs, JWT and refresh tokens with secure storage, protecting routes and role-based access control, and mitigating CSRF and XSS when handling user input. Your app starts facing real security.