Skip to main content
ASoc
Tutorial

Skipping React Hook Form: What Four Real Forms Look Like Without One

Login, signup, password reset and contact all run on FormData, a Server Action and useActionState — zero form libraries in package.json. Here's the actual validation code.

The ASoc Team8 min read

React Hook Form solves a client-rendered problem: minimize re-renders with uncontrolled inputs, and validate a schema before a round trip so the user gets feedback instantly. In the Next.js App Router, a Server Action plus useActionState solves the same user-facing problem — instant feedback, no full-page reload — from the server instead, and this codebase ships four real forms (login, signup, password reset, contact) with zero form libraries anywhere in package.json.

Here's what that looks like in the actual code, where React Hook Form is still the better call, and the one security detail a client-validation library can't give you for free.

What React Hook Form is actually good at

It's a genuinely well-built library, and worth naming what it does that a hand-rolled form doesn't:

  • Uncontrolled inputs by default. register() wires a ref instead of a controlled value/onChange pair, so typing in one field doesn't re-render the rest of the form. On a form with dozens of fields, or one embedded inside something that re-renders often, this matters.
  • Resolver-based validation. Pair it with Zod, Yup, or another schema library and the same schema can validate on blur, on change, or on submit, with typed errors, before anything reaches a server.
  • Cross-field, client-only validation. "Confirm password must match" or "end date must be after start date" can be checked the instant a user leaves a field, no round trip, which matters most on long multi-step forms where a server round trip per field would feel sluggish.
  • A form that's part of a bigger client state machine. A multi-step wizard with a progress bar, conditional steps, and a draft saved to localStorage between steps is a client-side problem before it's a validation problem, and React Hook Form composes well with that shape.

None of that is wrong to reach for. It's the right tool when the form itself is the client-side complexity — long, branching, stateful before it's ever submitted.

Why none of our four forms need it

Every form in this codebase is short (2–4 fields), submits once, and the thing that actually varies per submission — is this email taken, is this password correct, did this message pass a honeypot check — can only be answered by the server anyway. Client-side schema validation would still need a server round trip to do anything that matters; it would just add a library in front of that trip rather than replacing it.

So the actual code skips the resolver entirely and validates by hand, in the Server Action, against the raw FormData:

// src/lib/actions/auth.ts
export async function signUpWithPassword(
  _prev: AuthState,
  formData: FormData,
): Promise<AuthState> {
  const email = String(formData.get("email") ?? "").trim().toLowerCase();
  const password = String(formData.get("password") ?? "");
  const consent = formData.get("consent");

  if (!consent) {
    return { ok: false, message: "Please accept the Terms and Privacy Policy" };
  }
  if (!EMAIL_RE.test(email)) {
    return { ok: false, message: "Please enter a valid email address." };
  }
  if (password.length < MIN_PASSWORD_LENGTH) {
    return {
      ok: false,
      message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
    };
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.signUp({ email, password, /* … */ });
  // …
}

EMAIL_RE (src/lib/validation.ts) is a single regex, shared across every form that collects an email — no schema library, no per-field resolver config, one source of truth checked on the server where it actually has to hold.

The detail a resolver can't give you: what the error doesn't say

signInWithPassword in the same file validates the same shape of input, but its failure branch looks different on purpose:

const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
  console.error("auth: signInWithPassword error", error);
  // Always the same message — never let a caller distinguish "wrong
  // password" from "email exists but unconfirmed" (account enumeration).
  return { ok: false, message: "Incorrect email or password." };
}

A client-side validator can tell a user their email is malformed. It cannot tell you whether that email is registered, because that answer lives on the server, and the moment you make a login form's error message reveal it — even inadvertently, by returning a different string for "wrong password" versus "no such account" — you've built an account-enumeration oracle. requestPasswordReset in the same file makes the identical call: it always returns the same "check your email" message whether or not the address exists. This isn't something React Hook Form's validation layer decides one way or the other; it's a server-side response-shape decision that a client library never touches, because the client never learns the real answer either way.

The client half, in ten lines

The form component itself is small precisely because it isn't managing field state — useActionState holds the last server response and a pending flag, and every input stays uncontrolled with plain name attributes:

"use client";
import { useActionState } from "react";
import { signUpWithPassword, type AuthState } from "@/lib/actions/auth";

export default function SignupForm() {
  const [state, action, pending] = useActionState<AuthState, FormData>(
    signUpWithPassword,
    null,
  );

  return (
    <form action={action}>
      <input name="email" type="email" required autoComplete="email" />
      <input name="password" type="password" required autoComplete="new-password" />
      {state && !state.ok && <p role="alert">{state.message}</p>}
      <button type="submit" disabled={pending}>
        {pending ? "Creating account…" : "Create account"}
      </button>
    </form>
  );
}

No register(), no controlled value props, no re-render on keystroke to manage — the browser owns every input's value until submit, exactly the re-render profile React Hook Form's uncontrolled-inputs design is built to achieve, just without a library achieving it. The full mechanics of this pattern — honeypots, rate limiting, the useFormState-versus-useActionState import gotcha — are the Server Actions contact form post's territory; this is the same shape applied to authentication instead of a mailbox.

Where the two approaches actually differ

React Hook Form (client)Server Action + useActionState (this codebase)
Where validation runsClient first, server should still re-checkServer only, by design
Feedback latencyInstant, no networkOne round trip (usually imperceptible on a 2–4 field form)
Re-renders on keystrokeNone (uncontrolled + refs)None (uncontrolled + native inputs)
Bundle cost~9 KB + a resolver package0 KB — ships with React 19
Works with JavaScript disabledNo — client validation and submit both need JSYes — <form action={fn}> posts natively
Cross-field client validation (e.g. "passwords match")Built inWould need a client onChange handler layered on top
Best fitLong, branching, stateful formsShort forms whose real answer only the server has anyway

Mistakes and how they show up

MistakeSymptomFix
Trusting client validation as the only checkA crafted POST bypasses every rule the UI enforcedRe-validate every field inside the Server Action, always
Returning a different error for "no account" vs "wrong password"Account enumeration — an attacker can list valid emailsOne generic message for every login failure
Adding React Hook Form to save "two lines" of manual validationA resolver package plus its schema library for a 3-field formHand-validate; a schema library earns its keep past ~6+ interdependent fields
Controlled inputs (useState per field) without a library or a reasonEvery keystroke re-renders the whole form for no benefitUncontrolled inputs with plain name attrs, or useActionState for the submit result
Client-only validation with no server re-checkWorks in the browser, breaks the moment someone calls the endpoint directlyThe client check is a UX nicety; the server check is the actual rule

Frequently asked questions

Should I ever add React Hook Form to a Next.js App Router project? Yes — for a form whose complexity lives on the client before submission: a multi-step wizard, a form with heavy cross-field logic, or one embedded in a component library that isn't wired to Server Actions. None of our four forms are that shape; a settings form with a dozen interdependent fields might be.

Doesn't skipping a validation library mean writing more code per form? A little, and it's mostly one shared regex plus a handful of if checks per action — cheaper than it sounds once the pattern exists, and every check runs where it has to run anyway (the server), so nothing here is extra work, just work that isn't duplicated on the client first.

Can I use React Hook Form's validation and still submit via a Server Action? Yes, they're not mutually exclusive — handleSubmit can call a Server Action instead of fetch. You lose the no-JavaScript progressive-enhancement guarantee either way, since RHF's onSubmit handler requires JavaScript to run in the first place.

What about useFormStatus versus useActionState? useActionState (from react) gives you the last returned state, the action to bind to <form action>, and a pending flag together. useFormStatus (from react-dom) only gives pending status, and only to a component nested inside the form — useful for a submit button component that doesn't have direct access to the action's state.

Templates with real forms in this shape

ASoc Blueprint is an app-development agency site whose project-pricing and contact funnel run on exactly this kind of short, server-validated form. ASoc Brief is a single-page portfolio site with a contact form as its one interactive element. ASoc Byte is an IT/startup engineering-studio site built around service inquiries — another short-form, server-validated funnel.

Browse the full set of Next.js landing page templates or Tailwind landing page templates.

Keep reading

Tutorial10 min read

React Landing Pages: Half the HTML Isn't the Page

This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.

Read more
Tutorial7 min read

React Lazy Loading: This Codebase Uses Zero React.lazy() Calls

This codebase has zero React.lazy() calls. What it actually lazy-loads — an explicit per-post import map and a conditional SDK import — and why that distinction matters.

Read more
Tutorial8 min read

React Modal, Zero Dependencies: The Iframe Focus Bug We Fixed

A real accessible React modal with zero dependencies — focus trap, ARIA dialog role, restore-on-close, and the iframe-focus-escape bug most modal libraries never mention.

Read more