Different ways to handle Forms in React

Handling forms in React can be done in several ways depending on your needs—simplicity, validation, scalability, or integration with modern frameworks like Next.js and React.

In this guide, we’ll walk through the 3 common approaches:

  1. Uncontrolled forms
  2. Controlled forms
  3. Using React Hook Form
  • With optional Server Actions support (Next.js 14+)

LinkIcon1. Uncontrolled Form (FormData approach)

This method accesses the form data directly from the DOM. It's great for simple forms and low-overhead logic.

LinkIconPros:

  • No need to manage state
  • Fewer re-renders
  • Straightforward for small forms

Example:

'use client'
 
import { FormEvent, useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
 
export default function LoginPage() {
  const [error, setError] = useState('')
  const router = useRouter()
 
  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
 
    const res = await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirect: false,
    })
 
    if (res?.error) return setError(res.error)
    if (res?.ok) router.push('/dashboard/profile')
  }
 
  return (
    <form onSubmit={handleSubmit} className="w-96 p-6 bg-neutral-900 text-white rounded">
      {error && <div className="bg-red-600 p-2 mb-2">{error}</div>}
      <h1 className="text-2xl font-bold mb-4">Sign In</h1>
 
      <input name="email" type="email" placeholder="Email" className="input mb-2" />
      <input name="password" type="password" placeholder="Password" className="input mb-4" />
 
      <button className="btn-primary">Log In</button>
    </form>
  )
}

LinkIcon2. Controlled Form (State-Based)

Controlled components rely on React state to manage inputs. Each input field has its own value tied to useState.

Pros:

  • Easier to integrate validations, conditional UI
  • One source of truth (React state)

Cons:

  • More boilerplate
  • Re-renders on every keystroke

Example:

'use client'
 
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
 
export default function LoginControlled() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const router = useRouter()
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
 
    const res = await signIn('credentials', {
      email,
      password,
      redirect: false,
    })
 
    if (res?.error) return setError(res.error)
    if (res?.ok) router.push('/dashboard/profile')
  }
 
  return (
    <form onSubmit={handleSubmit} className="w-96 p-6 bg-neutral-900 text-white rounded">
      {error && <div className="bg-red-600 p-2 mb-2">{error}</div>}
      <h1 className="text-2xl font-bold mb-4">Sign In</h1>
 
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        className="input mb-2"
      />
 
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        className="input mb-4"
      />
 
      <button className="btn-primary">Log In</button>
    </form>
  )
}

LinkIcon3. React Hook Form

React Hook Form is the go-to solution for performance-friendly form management, especially when forms grow complex.

Pros:

  • Less boilerplate than controlled inputs
  • Built-in validation
  • Native integration with Zod, Yup, etc.
  • Excellent performance

Example:

'use client'
 
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
 
const schema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
})
 
type FormValues = z.infer<typeof schema>
 
export default function LoginHookForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  })
 
  const router = useRouter()
 
  async function onSubmit(data: FormValues) {
    const res = await signIn('credentials', {
      ...data,
      redirect: false,
    })
 
    if (res?.error) {
      alert(res.error)
    } else if (res?.ok) {
      router.push('/dashboard/profile')
    }
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="w-96 p-6 bg-neutral-900 text-white rounded">
      <h1 className="text-2xl font-bold mb-4">Sign In</h1>
 
      <input {...register('email')} placeholder="Email" className="input mb-2" />
      {errors.email && <p className="text-red-400 text-sm">{errors.email.message}</p>}
 
      <input {...register('password')} placeholder="Password" type="password" className="input mb-2" />
      {errors.password && <p className="text-red-400 text-sm">{errors.password.message}</p>}
 
      <button className="btn-primary mt-2">Log In</button>
    </form>
  )
}

LinkIcon3.1. React Hook Form with shadcn/ui (with Zod)

Pros:

  • Minimal boilerplate (form control is declarative)
  • Validation stays in sync (thanks to zodResolver)
  • Consistent design (inputs, labels, errors are uniform)
  • Scalable (easily extend to multi-step or async validation)
'use client'
 
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
 
// ShadCN UI
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormControl,
  FormDescription,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
 
const loginSchema = z.object({
  email: z.string().email({ message: 'Enter a valid email' }),
  password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
})
 
type LoginFormData = z.infer<typeof loginSchema>
 
export default function LoginShadcn() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })
 
  const router = useRouter()
 
  async function onSubmit(data: LoginFormData) {
    const res = await signIn('credentials', {
      ...data,
      redirect: false,
    })
 
    if (res?.error) {
      form.setError('password', {
        message: res.error,
      })
    }
 
    if (res?.ok) {
      router.push('/dashboard/profile')
    }
  }
 
  return (
    <div className="w-full max-w-md mx-auto mt-20 p-6 bg-neutral-950 rounded-xl text-white shadow-xl">
      <h2 className="text-2xl font-bold mb-6 text-center">Sign In</h2>
 
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input placeholder="you@example.com" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
 
          <FormField
            control={form.control}
            name="password"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input type="password" placeholder="********" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
 
          <Button type="submit" className="w-full">
            Log In
          </Button>
        </form>
      </Form>
    </div>
  )
}

LinkIcon3.2. React Hook Form with Server Action (Next.js 15+)

If you’re using Next.js 15 with Server Actions, you can integrate react-hook-form with formAction props.

// app/actions/login.ts
'use server'
 
import { redirect } from 'next/navigation'
 
export async function loginAction(prevState: any, formData: FormData) {
  const email = formData.get('email')
  const password = formData.get('password')
 
  // Perform auth logic here (e.g., custom login, external call, etc.)
 
  const isValid = email === 'demo@example.com' && password === '123456'
  if (!isValid) return { error: 'Invalid credentials' }
 
  redirect('/dashboard/profile')
}
// app/login/page.tsx
'use client'
 
import { useFormState } from 'react-dom'
import { loginAction } from '../actions/login'
 
export default function LoginServerAction() {
  const [state, formAction] = useFormState(loginAction, { error: null })
 
  return (
    <form action={formAction} className="w-96 p-6 bg-neutral-900 text-white rounded">
      <h1 className="text-2xl font-bold mb-4">Sign In</h1>
 
      {state?.error && <p className="text-red-400 text-sm mb-2">{state.error}</p>}
 
      <input name="email" placeholder="Email" className="input mb-2" />
      <input name="password" type="password" placeholder="Password" className="input mb-4" />
 
      <button className="btn-primary">Log In</button>
    </form>
  )
}

LinkIconSummary Table

MethodControlledClient-side StateValidation FriendlyBest For
UncontrolledNoSimple, low-boilerplate
ControlledYes✅ (manual)Dynamic UIs, small-medium
React Hook Form✅ (hybrid)Yes✅ (built-in)Complex forms, validation
Server Actions✅/❌No (on server)✅ (centralized)Secure, server-side logic