React & Frontend⏱ 12 phút đọc · 7 thg 10, 2026

React Hook Form + Zod: làm form đăng ký chuẩn từ client tới Server Action

Form đăng ký với số điện thoại VN, validate ngay khi gõ, báo lỗi "email đã tồn tại" từ server, dùng chung một schema ở client và Server Action. Hướng dẫn React Hook Form + Zod 4 thực chiến, kèm so sánh với useActionState của React 19.

HOLETEX · POST
FORM
react hook form + zod

Bạn nhận task làm form đăng ký: họ tên, email, số điện thoại, tỉnh/thành, mật khẩu, nhập lại mật khẩu, checkbox đồng ý điều khoản. Bản đầu tiên viết bằng useState, mỗi field một state, mỗi field một hàm validate. Chạy được. Rồi yêu cầu thêm tới: số điện thoại phải chấp nhận cả 0912 345 678 lẫn +84912345678, email trùng phải báo đỏ ngay dưới ô email, tỉnh/thành dùng component Select của shadcn/ui, và backend cũng phải validate lại y hệt. Lúc này component đã dài vài trăm dòng, logic validate nằm ở hai nơi và bắt đầu lệch nhau.

Đây là bài toán mà cặp React Hook Form + Zod giải rất gọn: Zod mô tả dữ liệu hợp lệ trông như thế nào (một lần, dùng ở cả client lẫn server), còn React Hook Form lo phần state, lỗi và trải nghiệm nhập liệu. Bài này làm trọn một form đăng ký kiểu Việt Nam trên Next.js App Router với Server Action, rồi trả lời câu hỏi: có React 19 rồi thì còn cần React Hook Form không.

Phiên bản dùng trong bài (kiểm tra trên npm ngày 2026-10-08): react-hook-form 7.89, @hookform/resolvers 5.9, zod 4.6, React 19.3, Next.js 16.4. React Hook Form v8 đang ở giai đoạn beta, bài này dùng bản v7 ổn định.

Vì sao không tự viết bằng useState

Nếu bạn đã quen useState và useEffect, cách "tự nhiên" là biến mỗi input thành controlled component. Cách đó có ba vấn đề khi form lớn dần:

  • Re-render nhiều. Mỗi phím gõ cập nhật state, cả form render lại. Form vài field thì không sao, form dài kèm danh sách động thì có thể bắt đầu thấy giật, nhất là trên máy yếu.
  • Logic lỗi rải rác. Lúc nào hiện lỗi (khi gõ, khi rời ô, hay khi submit), field nào đã được chạm vào, đang submit hay chưa: tự quản hết những thứ này là nơi bug sinh ra.
  • Validate hai lần, hai kiểu. Client viết if/else, server viết lại một bộ khác. Sửa rule ở một bên, quên bên kia.

React Hook Form xử lý hai vấn đề đầu: mặc định nó làm việc với input uncontrolled qua ref, nên gõ phím không làm cả form render lại, và nó có sẵn formState (errors, isSubmitting, touchedFields...) cùng các chế độ validate. Zod xử lý vấn đề thứ ba: schema là một object JavaScript thường, import được ở cả client và server, và sinh luôn type TypeScript.

Cài đặt

bash
pnpm add react-hook-form @hookform/resolvers zod

@hookform/resolvers là cầu nối giữa React Hook Form và thư viện schema. Bản 5.x hỗ trợ cả Zod 3.25+ lẫn Zod 4.

Bước 1: Viết schema một lần, dùng ở mọi nơi

Đặt schema ở một file không có 'use client' hay 'use server', để cả component lẫn Server Action đều import được:

ts
// src/lib/validations/signup.ts
import { z } from 'zod'

// Mobile VN: 0 hoặc +84, theo sau là đầu 3/5/7/8/9 và 8 chữ số
const VN_PHONE = /^(?:\+84|0)[35789]\d{8}$/

export const signupSchema = z
  .object({
    fullName: z
      .string()
      .trim()
      .min(2, 'Họ tên tối thiểu 2 ký tự')
      .max(60, 'Họ tên tối đa 60 ký tự'),
    email: z
      .string()
      .trim()
      .toLowerCase()
      .pipe(z.email('Email không hợp lệ')),
    phone: z
      .string()
      .transform((v) => v.replace(/[\s.-]/g, '')) // bỏ khoảng trắng, dấu chấm, gạch
      .pipe(z.string().regex(VN_PHONE, 'Số điện thoại di động không hợp lệ'))
      .transform((v) => (v.startsWith('0') ? `+84${v.slice(1)}` : v)), // chuẩn hóa về +84
    province: z.string().min(1, 'Chọn tỉnh/thành'),
    password: z.string().min(8, 'Mật khẩu tối thiểu 8 ký tự'),
    confirmPassword: z.string(),
    acceptTerms: z.boolean().refine((v) => v, { error: 'Bạn cần đồng ý điều khoản' }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: 'Mật khẩu nhập lại không khớp',
    path: ['confirmPassword'],
  })

export type SignupInput = z.input<typeof signupSchema>
export type SignupValues = z.output<typeof signupSchema>

Vài điểm của Zod 4 đáng để ý nếu bạn từng dùng Zod 3:

  • Định dạng chuỗi giờ là hàm cấp cao nhất: z.email(), z.url(), z.httpUrl(), z.uuid(), z.e164(). Ở đây mình dùng .pipe(z.email()) để trim và lowercase trước, rồi mới kiểm tra định dạng.
  • Tùy chỉnh lỗi dùng key error thay cho message. Truyền thẳng một chuỗi như .min(2, 'Họ tên tối thiểu 2 ký tự') vẫn được.
  • path trong refine gắn lỗi "mật khẩu không khớp" vào đúng ô confirmPassword, thay vì lỗi chung của cả form.

Còn chuyện input khác output: người dùng gõ 0912.345.678, schema trả về +84912345678. Đó là lý do có hai type: SignupInput là thứ form giữ trong lúc nhập, SignupValues là dữ liệu sạch sau khi parse. Nếu bạn chưa rõ vì sao type quan trọng tới vậy, xem bài TypeScript là gì.

Regex ở trên phủ các đầu số di động 03, 05, 07, 08, 09 hiện tại. Nếu sản phẩm nhận cả số nước ngoài, cân nhắc z.e164() (chuẩn quốc tế dạng +84912345678) thay vì regex riêng.

Bước 2: Nối schema vào form với zodResolver

tsx
// src/app/signup/signup-form.tsx
'use client'

import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
  signupSchema,
  type SignupInput,
  type SignupValues,
} from '@/lib/validations/signup'
import { signup } from './actions'

const PROVINCES = ['Hà Nội', 'TP. Hồ Chí Minh', 'Đà Nẵng', 'Hải Phòng', 'Cần Thơ']

export function SignupForm() {
  const [done, setDone] = useState(false)
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<SignupInput, unknown, SignupValues>({
    resolver: zodResolver(signupSchema),
    mode: 'onTouched',
    defaultValues: {
      fullName: '',
      email: '',
      phone: '',
      province: '',
      password: '',
      confirmPassword: '',
      acceptTerms: false,
    },
  })

  // Docs RHF: reset sau khi submit thành công trong useEffect, không gọi thẳng trong onSubmit
  useEffect(() => {
    if (isSubmitSuccessful) reset()
  }, [isSubmitSuccessful, reset])

  async function onSubmit(values: SignupValues) {
    setDone(false)
    const result = await signup(values)

    if (!result.ok) {
      for (const [field, messages] of Object.entries(result.fieldErrors)) {
        if (messages?.[0]) {
          setError(field as keyof SignupInput, { type: 'server', message: messages[0] })
        }
      }
      if (result.message) {
        setError('root.serverError', { type: 'server', message: result.message })
      }
      return
    }

    setDone(true)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
      <div>
        <label htmlFor="fullName">Họ tên</label>
        <input id="fullName" {...register('fullName')} aria-invalid={!!errors.fullName} />
        {errors.fullName && <p role="alert">{errors.fullName.message}</p>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} aria-invalid={!!errors.email} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="phone">Số điện thoại</label>
        <input
          id="phone"
          type="tel"
          inputMode="tel"
          placeholder="0912 345 678"
          {...register('phone')}
          aria-invalid={!!errors.phone}
        />
        {errors.phone && <p role="alert">{errors.phone.message}</p>}
      </div>

      <div>
        <label htmlFor="province">Tỉnh/thành</label>
        <select id="province" {...register('province')} aria-invalid={!!errors.province}>
          <option value="">-- Chọn --</option>
          {PROVINCES.map((p) => (
            <option key={p} value={p}>
              {p}
            </option>
          ))}
        </select>
        {errors.province && <p role="alert">{errors.province.message}</p>}
      </div>

      <div>
        <label htmlFor="password">Mật khẩu</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          aria-invalid={!!errors.password}
        />
        {errors.password && <p role="alert">{errors.password.message}</p>}
      </div>

      <div>
        <label htmlFor="confirmPassword">Nhập lại mật khẩu</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword')}
          aria-invalid={!!errors.confirmPassword}
        />
        {errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}
      </div>

      <label>
        <input type="checkbox" {...register('acceptTerms')} /> Tôi đồng ý điều khoản
      </label>
      {errors.acceptTerms && <p role="alert">{errors.acceptTerms.message}</p>}

      {errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}
      {done && <p>Đăng ký thành công!</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Đang gửi...' : 'Đăng ký'}
      </button>
    </form>
  )
}

Giải thích những chỗ hay bị hỏi:

  • useForm<SignupInput, unknown, SignupValues>: ba generic lần lượt là kiểu dữ liệu trong form, context, và kiểu dữ liệu sau khi resolver xử lý. README của @hookform/resolvers khuyên khai báo đủ ba khi input và output của schema khác nhau (do transform hay .default()). Nhờ vậy onSubmit nhận đúng SignupValues, số điện thoại đã ở dạng +84....
  • mode: 'onTouched': mặc định React Hook Form chỉ validate khi submit (onSubmit), sau đó validate lại mỗi lần đổi giá trị. onTouched hiện lỗi ngay khi người dùng rời ô lần đầu, rồi cập nhật theo từng phím gõ. Với form đăng ký, đây thường là lựa chọn dễ chịu nhất cho người dùng.
  • defaultValues đầy đủ: khai báo giá trị mặc định cho mọi field giúp reset() đưa form về đúng trạng thái ban đầu, và component dùng Controller không nhận value là undefined ở lần render đầu (React sẽ cảnh báo chuyển từ uncontrolled sang controlled).
  • reset() đặt trong useEffect: docs reset khuyên reset sau khi submit trong useEffect theo isSubmitSuccessful, không gọi thẳng trong onSubmit, vì thứ tự cập nhật state có ảnh hưởng. reset() cũng đưa isSubmitSuccessful về false, nên thông báo thành công dùng state riêng (done).
  • noValidate: tắt bong bóng validate của trình duyệt để chỉ hiện lỗi từ Zod, tránh hai lớp thông báo chồng nhau.
  • role="alert" và aria-invalid: screen reader đọc được lỗi. Mặc định shouldFocusError là true, nên khi submit lỗi, con trỏ tự nhảy vào ô sai đầu tiên.

Bước 3: register hay Controller

register trả về name, ref, onChange, onBlur để bạn spread thẳng vào <input>, <select>, <textarea> gốc. Nó dựa vào ref tới phần tử DOM thật, nên chỉ hợp với input native hoặc component chuyển tiếp ref xuống input native.

Nhiều component UI thì không như vậy. Select của shadcn/ui (xây trên Radix) không phải <select> thật, nó nhận value và onValueChange. Date picker, combobox, rich text editor cũng thế. Với những component này, dùng Controller:

tsx
import { Controller } from 'react-hook-form'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

// bên trong SignupForm, lấy thêm `control` từ useForm
<Controller
  name="province"
  control={control}
  render={({ field, fieldState }) => (
    <Field data-invalid={fieldState.invalid}>
      <FieldLabel htmlFor="province">Tỉnh/thành</FieldLabel>
      <Select name={field.name} value={field.value} onValueChange={field.onChange}>
        <SelectTrigger id="province" aria-invalid={fieldState.invalid}>
          <SelectValue placeholder="Chọn tỉnh/thành" />
        </SelectTrigger>
        <SelectContent>
          {PROVINCES.map((p) => (
            <SelectItem key={p} value={p}>
              {p}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
    </Field>
  )}
/>

Docs forms hiện tại của shadcn/ui đi theo đúng hướng này: Controller của React Hook Form kết hợp bộ component Field, FieldLabel, FieldError, thay vì wrapper <Form>/<FormField> kiểu cũ. Đoạn này theo đúng mẫu trong docs shadcn/ui. Lưu ý Select không phải input gốc nên sự kiện blur không giống <select>; lỗi của ô này sẽ hiện khi submit, sau đó cập nhật ngay khi người dùng chọn giá trị (reValidateMode mặc định là onChange).

Quy tắc chọn nhanh:

Tình huốngDùng
<input>, <select>, <textarea> gốc, Input của shadcn (chuyển ref xuống input)register
Component chỉ có value + onChange/onValueChange (Select, Checkbox, Switch, DatePicker của shadcn/Radix, MUI...)Controller
Bạn tự viết component input dùng lại nhiều nơiuseController bên trong component đó

Bước 4: Dùng lại schema trong Server Action

Validate ở client chỉ phục vụ trải nghiệm người dùng, không phải lớp bảo vệ. Docs Next.js nói rõ: Server Action chạy như một POST request, ai gửi được request đó là gọi được action, nên phải coi mọi action là điểm vào không đáng tin. Trong action phải validate input và kiểm tra quyền, dù form chỉ hiện trên trang đã đăng nhập.

ts
// src/app/signup/actions.ts
'use server'

import { z } from 'zod'
import { signupSchema, type SignupValues } from '@/lib/validations/signup'
import { createUser, isEmailTaken } from '@/lib/users'

export type SignupResult =
  | { ok: true }
  | {
      ok: false
      fieldErrors: Partial<Record<keyof SignupValues, string[]>>
      message?: string
    }

export async function signup(input: unknown): Promise<SignupResult> {
  // Không tin dữ liệu từ client, kể cả khi client đã validate
  const parsed = await signupSchema.safeParseAsync(input)
  if (!parsed.success) {
    return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors }
  }

  const { fullName, email, phone, province, password } = parsed.data

  if (await isEmailTaken(email)) {
    return { ok: false, fieldErrors: { email: ['Email này đã được đăng ký'] } }
  }

  try {
    await createUser({ fullName, email, phone, province, password })
  } catch {
    return { ok: false, fieldErrors: {}, message: 'Có lỗi xảy ra, vui lòng thử lại sau' }
  }

  return { ok: true }
}

Để demo chạy được ngay, lib/users.ts có thể là một store tạm trong bộ nhớ:

ts
// src/lib/users.ts (chỉ để demo, production dùng database thật)
type User = { fullName: string; email: string; phone: string; province: string; password: string }

const users = new Map<string, User>()

export async function isEmailTaken(email: string) {
  return users.has(email)
}

export async function createUser(user: User) {
  // Production: hash mật khẩu (argon2/bcrypt) trước khi lưu, không bao giờ lưu plain text
  users.set(user.email, user)
}

Luồng hoàn chỉnh như sau: React Hook Form validate ở client bằng schema, gọi signup(values) khi hợp lệ, server parse lại bằng cùng schema đó, kiểm tra email trùng, rồi trả về object lỗi có cùng hình dạng fieldErrors. Client lặp qua và gọi setError để lỗi server hiện ngay dưới đúng ô, giống hệt lỗi client.

Ba điểm cần biết:

  • z.flattenError() là API của Zod 4 để biến lỗi thành { formErrors, fieldErrors }. Một số ví dụ cũ (kể cả trong docs Next.js) vẫn dùng error.flatten() kiểu Zod 3. Với form lồng nhiều cấp, z.treeifyError() hợp hơn. Nếu form có field array như phần links bên dưới, flattenError gom lỗi links.0.url thành key links; muốn gắn lỗi server vào đúng dòng thì duyệt parsed.error.issues và gọi setError(issue.path.join('.'), ...).
  • Lỗi từ setError không bám mãi. Theo docs React Hook Form, lỗi đặt thủ công trên một field sẽ mất khi field đó validate lại và đạt rule. Tức là người dùng sửa email thì lỗi "đã được đăng ký" biến mất, đúng hành vi mong muốn. Lỗi ở root.serverError cũng không tồn tại qua lần submit tiếp theo.
  • Chỉ trả về những gì UI cần. Kết quả của Server Action được serialize gửi về client. Đừng trả cả bản ghi user từ database.

Nếu bạn chưa quen cách Server Component, Client Component và Server Action phối hợp trong App Router, đọc thêm bài Next.js App Router.

Validate bất đồng bộ: đặt ở đâu cho đúng

Kiểm tra "email đã tồn tại" là ví dụ kinh điển của validate async. Zod hỗ trợ refine với hàm async, và zodResolver mặc định chạy parseAsync, nên viết thẳng vào schema là chạy được:

ts
// Chạy được, nhưng cân nhắc kỹ trước khi dùng cho form có nhiều field
const emailField = z
  .string()
  .trim()
  .toLowerCase()
  .pipe(z.email('Email không hợp lệ'))
  .refine(
    async (email) => {
      const res = await fetch(`/api/email-available?email=${encodeURIComponent(email)}`)
      const data: { available: boolean } = await res.json()
      return data.available
    },
    { error: 'Email này đã được đăng ký' },
  )

Vấn đề là resolver nhận toàn bộ giá trị form và parse cả schema, nên khi bất kỳ field nào được validate lại, request kiểm tra email cũng có thể chạy theo. Với mode: 'onChange', đó là một request mỗi lần gõ phím ở bất kỳ ô nào. Ngoài ra, schema này dùng fetch tới API route nên không còn dùng lại được nguyên vẹn trên server.

Cách mình hay chọn:

  1. Schema dùng chung chỉ chứa rule đồng bộ (định dạng, độ dài, khớp mật khẩu).
  2. Check cần database đặt trong Server Action, trả lỗi về qua setError như ở Bước 4. Đây là nơi bắt buộc phải có check này dù client làm gì.
  3. Nếu thật sự cần báo sớm, check riêng ô email khi onBlur hoặc kèm debounce, không đưa vào resolver.

Thêm một cân nhắc về bảo mật: endpoint "email này đã tồn tại chưa" cho phép người ngoài dò xem email nào có tài khoản. Với sản phẩm nhạy cảm, nhiều team chọn thông báo chung chung và gửi email xác nhận thay vì báo trùng ngay trên form.

Field array: danh sách động

Giả sử form có thêm phần "Link portfolio" (GitHub, website cá nhân), tối đa 3 link. Thêm vào schema:

ts
links: z
  .array(z.object({ url: z.httpUrl('Link phải bắt đầu bằng http:// hoặc https://') }))
  .max(3, 'Tối đa 3 link'),

Thêm links: [] vào defaultValues, rồi dùng useFieldArray:

tsx
import { useFieldArray } from 'react-hook-form'

// trong SignupForm
const { fields, append, remove } = useFieldArray({ control, name: 'links' })

<fieldset>
  <legend>Link portfolio</legend>
  {fields.map((field, index) => (
    <div key={field.id}>
      <input
        {...register(`links.${index}.url`)}
        placeholder="https://github.com/ten-ban"
        aria-invalid={!!errors.links?.[index]?.url}
      />
      <button type="button" onClick={() => remove(index)}>
        Xóa
      </button>
      {errors.links?.[index]?.url && <p role="alert">{errors.links[index].url.message}</p>}
    </div>
  ))}
  <button type="button" onClick={() => append({ url: '' })} disabled={fields.length >= 3}>
    + Thêm link
  </button>
</fieldset>

Hai lỗi phổ biến nhất với field array, cả hai đều được docs nhấn mạnh:

  • Dùng field.id làm key, không dùng index. Dùng index thì khi xóa phần tử giữa danh sách, giá trị và lỗi sẽ nhảy sai dòng.
  • append phải truyền object đầy đủ, ví dụ append({ url: '' }), không phải append({}).

Nhớ thêm type="button" cho các nút trong form, nếu không trình duyệt coi chúng là nút submit.

React 19 có useActionState rồi, còn cần React Hook Form không

React 19 đưa form vào lõi: <form action={fn}>, useActionState trả về [state, formAction, isPending], useFormStatus để biết form đang gửi. Với một form gửi thẳng Server Action, bạn có thể không cần thư viện nào:

tsx
'use client'

import { useActionState } from 'react'
import { subscribe } from './actions' // (prevState, formData) => Promise<State>

export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(subscribe, { error: '', email: '' })

  return (
    <form action={formAction}>
      <input name="email" type="email" defaultValue={state.email} required />
      {state.error && <p role="alert">{state.error}</p>}
      <button disabled={isPending}>{isPending ? 'Đang gửi...' : 'Đăng ký'}</button>
    </form>
  )
}

Cách này có lợi thế thật: form vẫn submit được khi JavaScript chưa tải xong (progressive enhancement), ít code, không thêm dependency. Nhưng có vài điều cần biết. Docs React ghi rằng sau khi action chạy thành công, các field uncontrolled trong form sẽ bị reset. Vì vậy, nếu action trả lỗi validate, bạn phải trả kèm giá trị người dùng đã nhập và gán lại qua defaultValue như ví dụ trên, nếu không người dùng phải gõ lại từ đầu. Lỗi cũng chỉ hiện sau khi đi một vòng lên server.

Nhu cầuuseActionState + Server ActionReact Hook Form + Zod
Form ngắn (1-4 field): newsletter, tìm kiếm, đổi tênĐủ dùng, gọnHơi thừa
Cần chạy khi JS chưa tải (progressive enhancement)Có sẵnKhông phải mục tiêu chính
Hiện lỗi ngay khi gõ hoặc khi rời ôPhải tự làmCó sẵn (mode)
Nhiều field, field phụ thuộc nhau, wizard nhiều bướcTự quản state, dễ rốiPhù hợp
Danh sách động (thêm/xóa dòng)Tự làmuseFieldArray
Component UI controlled (shadcn Select, DatePicker)Phải đồng bộ qua input ẩnController
Validate ở serverCó (Zod trong action)Có (cùng schema)

Hai hướng không loại trừ nhau. Chính form đăng ký ở trên đã kết hợp: React Hook Form lo trải nghiệm phía client, Server Action lo ghi dữ liệu, Zod là hợp đồng chung giữa hai bên. Quy tắc của mình: form ngắn, ít tương tác thì dùng React 19 thuần; form mà người dùng phải điền nghiêm túc (đăng ký, checkout, hồ sơ, form admin) thì dùng React Hook Form.

Những lỗi hay gặp

  • Nhầm type input với output: z.infer là type output. Khi schema có transform hay .default() làm input khác output, khai báo đủ ba generic cho useForm để defaultValues và onSubmit mỗi bên nhận đúng type.
  • Đặt field tên root, type, message...: docs setError liệt kê đây là tên dành riêng, tránh dùng cho field.
  • Copy ví dụ Zod 3 cho Zod 4: dạng method z.string().email() đã deprecated, dùng z.email(); key message vẫn chạy nhưng nên chuyển sang error; error.flatten() đã deprecated, dùng z.flattenError() cho form phẳng hoặc z.treeifyError() cho dữ liệu lồng nhau.

Tóm lại

React Hook Form lo state và trải nghiệm nhập liệu, Zod lo định nghĩa dữ liệu hợp lệ, và việc dùng chung một schema ở client lẫn Server Action giúp hai bên không bao giờ lệch nhau. Nắm zodResolver, register vs Controller, setError cho lỗi server và useFieldArray là đủ cho phần lớn form trong dự án thực tế. Với form đơn giản, đừng ngại dùng useActionState của React 19 mà không cần thư viện.

Form là nơi junior và mid-level hay bị hỏi kỹ khi phỏng vấn hoặc khi review code, vì nó gom đủ state, validate, async và bảo mật vào một chỗ. Nếu bạn muốn luyện React bài bản tới mức tự thiết kế được những luồng như thế này cho dự án thật, khóa React PRO của HoleTex đi từ nền tảng tới các pattern production như trên.

Bài liên quan

Nguồn tham khảo: useForm (react-hook-form.com), register (react-hook-form.com), setError (react-hook-form.com), reset (react-hook-form.com), useFieldArray (react-hook-form.com), @hookform/resolvers (github.com), Zod API (zod.dev), Zod error formatting (zod.dev), Zod 4 changelog (zod.dev), useActionState (react.dev), form (react.dev), Forms with Server Actions (nextjs.org), Server Actions and Mutations (nextjs.org), React Hook Form với shadcn/ui (ui.shadcn.com), react-hook-form (npmjs.com), zod (npmjs.com). Cập nhật 2026-10-08.

Thấy hay? Chia sẻ