Building better form experiences in React
V0 - AI-First Development
ChatGPT - Conversational AI
Cursor - AI-Powered IDE
We need to make these experiences better!
We're going to build something like this
<form action={handleSignIn}>
<div>
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
name="email"
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
required
/>
</div>
<button type="submit">Continue</button>
</form>
'use server';
export async function handleSignIn(formData: FormData) {
// Simulate server delay
await new Promise(resolve => setTimeout(resolve, 1500));
// Extract form data
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// Simple validation
if (!email || !password) {
return { error: 'Email and password are required' };
}
// Authentication logic would go here
console.log('Authentication attempt:', { email });
// Redirect to dashboard
redirect('/dashboard');
}
[Live Demo: Basic Sign In Form]
✅ Works without JavaScript!
✅ Simple and functional
❌ But creates a poor user experience...
[Live Demo: Showing multiple form submissions]
Check the network tab - multiple requests!
<form action={handleSignIn}>
<fieldset disabled={isPending}>
<div>
<label htmlFor="email">Email address</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit">
{isPending ? 'Signing in...' : 'Continue'}
</button>
</fieldset>
</form>
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full"
>
{pending ? 'Signing in...' : 'Continue'}
</button>
);}
[Live Demo: Form with fieldset prevention]
✅ Prevents multiple submissions
✅ Provides visual feedback
✅ Works with progressive enhancement
import { z } from 'zod';
export const signInSchema = z.object({
email: z
.string()
.email('Please enter a valid email address')
.min(1, 'Email is required'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*$/,
'Password must contain at least one uppercase letter, one lowercase letter, and one number'
),
});
'use server';
async function handleSignIn(formData: FormData) {
// Simulate server delay
await new Promise(resolve => setTimeout(resolve, 1500));
const formValues = {
email: formData.get('email'),
password: formData.get('password'),
};
try {
const validatedData = signInSchema.parse(formValues);
// Authentication logic would go here
return { success: true };
} catch (error) {
if (error instanceof z.ZodError) {
const errors = error.errors.reduce((acc, curr) => {
const path = curr.path[0] as string;
acc[path] = curr.message;
return acc;
}, {} as Record);
return { errors, success: false };
}
return { error: 'Authentication failed', success: false };
}
}
'use client';
import { useFormState } from 'react-dom';
// Initial state for the form
const initialState = { errors: {}, success: false };
export default function SignInForm() {
const [state, formAction] = useFormState(handleSignIn, initialState);
return (
<form action={formAction}>
<fieldset disabled={isPending}>
<div>
<label htmlFor="email">Email address</label>
<input
type="email"
id="email"
name="email"
className={state.errors?.email ? 'border-red-500' : ''}
/>
{state.errors?.email && (
<p className="text-red-600 text-sm mt-1">{state.errors.email}</p>
)}
</div>
{/* Password field with similar validation display */}
<SubmitButton />
</fieldset>
</form>
);
}
[Live Demo: Sign In Form with Zod Validation]
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signInSchema, SignInValues } from './schema';
export default function SignInForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<SignInValues>({
resolver: zodResolver(signInSchema),
mode: 'onChange' // Validate on change for better UX
});
const onSubmit = async (data: SignInValues) => {
// Sign in logic here
try {
await signIn(data.email, data.password);
router.push('/dashboard');
} catch (error) {
setError('root', {
type: 'manual',
message: 'Invalid email or password'
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields with {register} */}
</form>
);
}
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
className={errors.email ? 'border-red-500' : ''}
{...register('email')}
/>
{errors.email && (
<p className="text-red-600 text-sm mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
className={errors.password ? 'border-red-500' : ''}
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{errors.password && (
<p className="text-red-600 text-sm mt-1">
{errors.password.message}
</p>
)}
</div>
[Live Demo: Sign In with React Hook Form]
Notice the improved user experience!
[Live Demo: ShadCN Form]
import { motion, AnimatePresence } from 'motion/react';
{/* Animated error message */}
<AnimatePresence mode="wait">
{errors.email && (
<motion.p
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="text-red-500 text-sm mt-1"
>
{errors.email.message}
</motion.p>
)}
</AnimatePresence>
<motion.button
type="submit"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md"
disabled={isSubmitting}
>
{isSubmitting ? (
<motion.div
className="flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</motion.div>
) : (
'Continue'
)}
</motion.button>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
className={errors.password ? 'border-red-500' : ''}
{...register('password')}
/>
<motion.button
type="button"
onClick={() => setShowPassword(!showPassword)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
<AnimatePresence mode="wait" initial={false}>
{showPassword ? (
<motion.div
key="eyeOff"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.15 }}
>
<EyeOff size={18} />
</motion.div>
) : (
<motion.div
key="eye"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.15 }}
>
<Eye size={18} />
</motion.div>
)}
</AnimatePresence>
</motion.button>
</div>
const formVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 }
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
<motion.form
variants={formVariants}
initial="hidden"
animate="visible"
onSubmit={handleSubmit(onSubmit)}
>
<motion.div variants={itemVariants}>
{/* Email field */}
</motion.div>
<motion.div variants={itemVariants}>
{/* Password field */}
</motion.div>
<motion.div variants={itemVariants}>
{/* Submit button */}
</motion.div>
</motion.form>
[Live Demo: Fully Animated Sign-In Form]
The small details make a huge difference!
A full account creation requires:
[Insert multi-step form flow diagram]
How do we preserve state across steps?
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type FormState = {
currentStep: number;
formData: {
email: string;
password: string;
name: string;
// Other fields
};
// Actions
setField: (field: string, value: any) => void;
nextStep: () => void;
prevStep: () => void;
resetForm: () => void;
};
export const useFormStore = create<FormState>()(
persist(
(set) => ({
currentStep: 0,
formData: {
email: '',
password: '',
name: '',
},
setField: (field, value) =>
set((state) => ({
formData: { ...state.formData, [field]: value }
})),
nextStep: () =>
set((state) => ({
currentStep: state.currentStep + 1
})),
prevStep: () =>
set((state) => ({
currentStep: state.currentStep - 1
})),
resetForm: () =>
set(() => ({
currentStep: 0,
formData: { email: '', password: '', name: '' }
})),
}),
{
name: 'registration-form',
// Using localStorage for persistence
}
)
);
export default function RegistrationFlow() {
// Use our Zustand store for persistent state
const [
currentStep,
formData,
nextStep,
prevStep
] = useFormStore(state => [
state.currentStep,
state.formData,
state.nextStep,
state.prevStep
]);
// Define steps with components
const steps = [
<EmailPasswordStep />,
<UserInfoStep />,
<PreferencesStep />,
<ReviewStep />
];
return (
<div className="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<FormProgress currentStep={currentStep} totalSteps={steps.length} />
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{steps[currentStep]}
</motion.div>
</AnimatePresence>
</div>
);
}
function EmailPasswordStep() {
const [formData, setField, nextStep] = useFormStore(
state => [state.formData, state.setField, state.nextStep]
);
const form = useForm({
resolver: zodResolver(emailPasswordSchema),
defaultValues: {
email: formData.email,
password: formData.password
}
});
const onSubmit = (data) => {
// Save to Zustand store
setField('email', data.email);
setField('password', data.password);
// Move to next step
nextStep();
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
<Button type="submit">Continue</Button>
</form>
);
}
[Live Demo: Multi-step Registration with Zustand]
Try refreshing the page - your data is still there!
"A form is a conversation with your user, not an interrogation."
Make every form interaction a delight, not a chore.