Different ways to handle Forms in React (Uncontrolled vs Controlled)
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:
- Uncontrolled forms
- Controlled forms
- Using React Hook Form
- With optional Server Actions support (Next.js 14+)
1. Uncontrolled Form (FormData approach)
This method accesses the form data directly from the DOM, not form React. It's great for simple forms and low-overhead logic.
Good to know
The form data FormData is a key-value pair that stores each input's data. It must use the input's attribute name as the key and the input's data as the value.
Pros:
- 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 form = e.currentTarget
const formData = new FormData(form)
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}>
{error && <div>{error}</div>}
<h1>Sign In</h1>
<input name="email" type="email" placeholder="Email"/>
<input name="password" type="password" placeholder="Password"/>
<button>Log In</button>
</form>
)
}2. Controlled Form (State-Based)
Controlled components rely on React state to manage inputs and its data. Each input field has its own value tied to useState. React is the source of truth.
Pros:
- Live validations
- Live formatting
- Conditional UI
- Disable buttons while typing
- 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>
)
}3. 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>
)
}3.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>
)
}3.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>
)
}Summary Table
| Method | Controlled | Client-side State | Validation Friendly | Best For |
|---|---|---|---|---|
| Uncontrolled | ❌ | No | ❌ | Simple, low-boilerplate |
| Controlled | ✅ | Yes | ✅ (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 |