Forms Are Where Product Design Meets System Design
A form that feels good to fill out requires decisions at two levels simultaneously — product and system. They are different decisions and both of them matter.
Forms are where product design and system design collide. Product decides what fields exist, in what order, and when to show errors. System decides how those fields are registered, how API errors map to field-level messages, and how the validation schema connects to the type layer. Get the product decisions wrong and the form feels interrogative. Get the system decisions wrong and the form has race conditions, stale errors, and submit handlers that swallow server validation failures.
Product-Level Form Decisions
The product layer of a form determines the user's experience of filling it out. These are not technical questions but they have technical consequences.
Field order matters. Users scan forms top to bottom. Put blocking fields first — required ones, identity fields. Optional fields belong at the bottom or behind a disclosure. Putting a credit card number before explaining what the user is signing up for is a product problem that no library will fix.
When to validate. The conventional wisdom is "validate on blur, not on change" — but this is actually context-dependent. For a password strength indicator, you want on-change feedback. For a username availability check, you want it after a short debounce. For confirming two email fields match, on-blur is correct. The library gives you mode: "onChange" | "onBlur" | "onSubmit" — the mode is a product decision about how much real-time feedback the user needs.
Error tone. "This field is required" is true but unhelpful. "Enter your email so we can send your receipt" is better. Error messages are product copy, not validation output. Zod's .message() and React Hook Form's rules.required string let you set copy at the schema level — use that.
System-Level Form Decisions
The system layer determines whether the form works correctly under all conditions.
Library choice. React Hook Form uses uncontrolled inputs (fast, less re-rendering). Conform works with server actions and progressive enhancement (good for RSC apps). TanStack Form is newer, with more granular subscription control. For most product forms, React Hook Form is the sensible default: large ecosystem, excellent TypeScript support, first-class Zod integration via @hookform/resolvers.
Server error routing. This is where most form implementations fall apart. Your API returns { errors: { email: "Already in use" } } — how does that map to a field-level error in React Hook Form? The answer is setError(), but it has to be called in the submit handler after the API response, and the error has to be cleared before the next attempt. A good error object makes this routing predictable.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type FormValues = z.infer<typeof schema>;
export function SignUpForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
async function onSubmit(data: FormValues) {
const res = await fetch("/api/auth/signup", {
method: "POST",
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json();
// Route API errors back to their fields
if (body.errors?.email) {
setError("email", { type: "server", message: body.errors.email });
}
if (body.errors?.password) {
setError("password", { type: "server", message: body.errors.password });
}
// Generic fallback
if (body.message) {
setError("root", { type: "server", message: body.message });
}
return;
}
// success path
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</form>
);
}
What Makes a Form Feel Good
At the product level: the form asks for the minimum. Fields are labeled clearly without requiring a placeholder to understand what to enter. Errors appear close to the field they describe. The submit state disables the button to prevent double-submission and shows progress.
At the system level: client validation runs synchronously against the same schema used for server validation. Server errors route back to their fields, not just a generic alert. The schema is the single source of truth — it generates the TypeScript type, drives the resolver, and can be reused on the server side (with Zod, the same schema works in both environments).
The form is finished when both layers are solid: product decisions made deliberately, system implementation that respects them.