Episode 2 — React Frontend Architecture NextJS / 2.5 — Event Handling and Conditional Rendering
2.5.e — Practical Build: Dynamic Form with Validation
In one sentence: This practical builds a complete multi-step registration form from scratch — with controlled inputs, dynamic field rendering, real-time validation, error display, and submit handling — applying every concept from this topic.
Navigation: ← Dynamic UI Rendering · Next → 2.6 Component Architecture
Table of Contents
- What We're Building
- Project Setup — Form Architecture
- Step 1: The Form State Model
- Step 2: Validation Rules Engine
- Step 3: Reusable Form Field Components
- Step 4: Multi-Step Form Navigation
- Step 5: Step 1 — Personal Information
- Step 6: Step 2 — Account Details
- Step 7: Step 3 — Preferences
- Step 8: Step 4 — Review & Submit
- Step 9: Complete App Assembly
- Step 10: Adding Polish — Loading, Success, Error States
- Custom Hook: useForm
- Extending the Form — Adding/Removing Fields Dynamically
- Key Takeaways
1. What We're Building
A 4-step registration form with:
┌─────────────────────────────────────────────────┐
│ Step 1 Step 2 Step 3 Step 4│
│ ●───────────────○───────────────○──────────○ │
│ Personal Info Account Prefs Review│
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ │ │
│ │ First Name: [_______________] ✓ │ │
│ │ Last Name: [_______________] │ │
│ │ Email: [_______________] ✗ Invalid │ │
│ │ Phone: [(___) ___-____] │ │
│ │ │ │
│ │ Errors shown in real-time │ │
│ │ Fields validated on blur │ │
│ │ Submit validates all fields │ │
│ │ │ │
│ │ [Back] [Next →] │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Features:
- Controlled inputs for every field type (text, email, password, select, checkbox, radio)
- Real-time validation with error messages
- "Touched" tracking — errors only show after field interaction
- Multi-step navigation with per-step validation
- Form state persists across steps
- Review step showing all data before submission
- Loading, success, and error states on submit
- Phone number formatting as you type
2. Project Setup — Form Architecture
src/
├── components/
│ ├── FormField.jsx # Reusable input wrapper
│ ├── StepIndicator.jsx # Progress bar
│ ├── PersonalInfoStep.jsx # Step 1
│ ├── AccountStep.jsx # Step 2
│ ├── PreferencesStep.jsx # Step 3
│ └── ReviewStep.jsx # Step 4
├── hooks/
│ └── useForm.js # Form state management hook
├── utils/
│ └── validators.js # Validation rules
└── App.jsx # Main assembly
3. Step 1: The Form State Model
// Initial form state — covers all 4 steps
const initialFormState = {
// Step 1: Personal Info
firstName: '',
lastName: '',
email: '',
phone: '',
// Step 2: Account Details
username: '',
password: '',
confirmPassword: '',
// Step 3: Preferences
role: '',
experience: 'beginner',
interests: [],
newsletter: false,
// Meta (not submitted)
agreeToTerms: false,
};
Tracking Field State
// We need to track more than just values
const [values, setValues] = useState(initialFormState);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
┌─────────────── Form State ───────────────┐
│ │
│ values: { email: "alice@" } │
│ errors: { email: "Invalid email" } │
│ touched: { email: true, phone: false } │
│ │
│ Show error for email? → YES │
│ (touched AND has error) │
│ Show error for phone? → NO │
│ (not touched yet) │
└───────────────────────────────────────────┘
4. Step 2: Validation Rules Engine
// utils/validators.js
export const validators = {
required: (value) => {
if (typeof value === 'boolean') return value ? '' : 'This field is required';
if (Array.isArray(value)) return value.length > 0 ? '' : 'Select at least one option';
return value?.trim() ? '' : 'This field is required';
},
email: (value) => {
if (!value) return '';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value) ? '' : 'Please enter a valid email address';
},
minLength: (min) => (value) => {
if (!value) return '';
return value.length >= min ? '' : `Must be at least ${min} characters`;
},
maxLength: (max) => (value) => {
if (!value) return '';
return value.length <= max ? '' : `Must be no more than ${max} characters`;
},
matches: (fieldName, fieldLabel) => (value, allValues) => {
if (!value) return '';
return value === allValues[fieldName] ? '' : `Must match ${fieldLabel}`;
},
phone: (value) => {
if (!value) return '';
const digits = value.replace(/\D/g, '');
return digits.length === 10 ? '' : 'Please enter a 10-digit phone number';
},
password: (value) => {
if (!value) return '';
const checks = [];
if (value.length < 8) checks.push('at least 8 characters');
if (!/[A-Z]/.test(value)) checks.push('an uppercase letter');
if (!/[a-z]/.test(value)) checks.push('a lowercase letter');
if (!/[0-9]/.test(value)) checks.push('a number');
return checks.length === 0 ? '' : `Password needs ${checks.join(', ')}`;
},
username: (value) => {
if (!value) return '';
if (value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only letters, numbers, and underscores';
return '';
},
};
// Field validation config — which validators apply to each field
export const fieldValidation = {
firstName: [validators.required, validators.minLength(2)],
lastName: [validators.required, validators.minLength(2)],
email: [validators.required, validators.email],
phone: [validators.phone],
username: [validators.required, validators.username],
password: [validators.required, validators.password],
confirmPassword: [validators.required, validators.matches('password', 'password')],
role: [validators.required],
interests: [validators.required],
agreeToTerms: [validators.required],
};
// Validate a single field
export function validateField(name, value, allValues) {
const rules = fieldValidation[name] || [];
for (const rule of rules) {
const error = rule(value, allValues);
if (error) return error; // Return first error
}
return '';
}
// Validate all fields in a step
export function validateStep(stepFields, values) {
const errors = {};
let isValid = true;
for (const field of stepFields) {
const error = validateField(field, values[field], values);
if (error) {
errors[field] = error;
isValid = false;
}
}
return { errors, isValid };
}
5. Step 3: Reusable Form Field Components
// components/FormField.jsx
function FormField({
label,
name,
type = 'text',
value,
error,
touched,
onChange,
onBlur,
placeholder,
options, // For select/radio
children, // For custom content
...rest
}) {
const showError = touched && error;
const showSuccess = touched && !error && value;
const inputStyle = {
width: '100%',
padding: '10px 12px',
border: `2px solid ${showError ? '#dc3545' : showSuccess ? '#28a745' : '#dee2e6'}`,
borderRadius: 8,
fontSize: 14,
outline: 'none',
transition: 'border-color 0.2s',
boxSizing: 'border-box',
};
function renderInput() {
switch (type) {
case 'textarea':
return (
<textarea
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }}
{...rest}
/>
);
case 'select':
return (
<select
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
style={inputStyle}
{...rest}
>
<option value="">{placeholder || '-- Select --'}</option>
{options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
case 'checkbox':
return (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
name={name}
checked={value}
onChange={onChange}
onBlur={onBlur}
{...rest}
/>
<span>{label}</span>
</label>
);
case 'radio':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{options?.map(opt => (
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="radio"
name={name}
value={opt.value}
checked={value === opt.value}
onChange={onChange}
onBlur={onBlur}
/>
<span>{opt.label}</span>
</label>
))}
</div>
);
default:
return (
<input
type={type}
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
style={inputStyle}
{...rest}
/>
);
}
}
return (
<div style={{ marginBottom: 16 }}>
{type !== 'checkbox' && (
<label
htmlFor={name}
style={{ display: 'block', marginBottom: 6, fontWeight: 500, fontSize: 14 }}
>
{label}
</label>
)}
{renderInput()}
{showError && (
<p style={{ color: '#dc3545', fontSize: 12, margin: '4px 0 0', display: 'flex', alignItems: 'center', gap: 4 }}>
⚠ {error}
</p>
)}
{children}
</div>
);
}
6. Step 4: Multi-Step Form Navigation
// components/StepIndicator.jsx
function StepIndicator({ steps, currentStep }) {
return (
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 32 }}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isUpcoming = index > currentStep;
return (
<div key={index} style={{ display: 'flex', alignItems: 'center', flex: index < steps.length - 1 ? 1 : 'none' }}>
{/* Step Circle */}
<div style={{
width: 36,
height: 36,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: 14,
background: isCompleted ? '#28a745' : isCurrent ? '#0070f3' : '#e9ecef',
color: isCompleted || isCurrent ? 'white' : '#6c757d',
transition: 'all 0.3s',
}}>
{isCompleted ? '✓' : index + 1}
</div>
{/* Step Label */}
<span style={{
marginLeft: 8,
fontSize: 13,
fontWeight: isCurrent ? 600 : 400,
color: isUpcoming ? '#adb5bd' : '#212529',
}}>
{step}
</span>
{/* Connector Line */}
{index < steps.length - 1 && (
<div style={{
flex: 1,
height: 2,
margin: '0 12px',
background: isCompleted ? '#28a745' : '#e9ecef',
transition: 'background 0.3s',
}} />
)}
</div>
);
})}
</div>
);
}
7. Step 5: Step 1 — Personal Information
// components/PersonalInfoStep.jsx
function PersonalInfoStep({ values, errors, touched, onChange, onBlur }) {
// Phone formatter
function handlePhoneChange(event) {
let digits = event.target.value.replace(/\D/g, '').slice(0, 10);
let formatted = '';
if (digits.length > 0) formatted = '(' + digits.slice(0, 3);
if (digits.length >= 3) formatted += ') ' + digits.slice(3, 6);
if (digits.length >= 6) formatted += '-' + digits.slice(6);
// Create synthetic event with formatted value
onChange({
target: { name: 'phone', value: formatted, type: 'text' }
});
}
return (
<div>
<h2 style={{ marginBottom: 4 }}>Personal Information</h2>
<p style={{ color: '#6c757d', marginBottom: 24 }}>Tell us about yourself</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<FormField
label="First Name"
name="firstName"
value={values.firstName}
error={errors.firstName}
touched={touched.firstName}
onChange={onChange}
onBlur={onBlur}
placeholder="John"
/>
<FormField
label="Last Name"
name="lastName"
value={values.lastName}
error={errors.lastName}
touched={touched.lastName}
onChange={onChange}
onBlur={onBlur}
placeholder="Doe"
/>
</div>
<FormField
label="Email Address"
name="email"
type="email"
value={values.email}
error={errors.email}
touched={touched.email}
onChange={onChange}
onBlur={onBlur}
placeholder="john@example.com"
/>
<FormField
label="Phone Number (optional)"
name="phone"
type="tel"
value={values.phone}
error={errors.phone}
touched={touched.phone}
onChange={handlePhoneChange}
onBlur={onBlur}
placeholder="(555) 123-4567"
/>
</div>
);
}
8. Step 6: Step 2 — Account Details
// components/AccountStep.jsx
function AccountStep({ values, errors, touched, onChange, onBlur }) {
// Password strength indicator
function getPasswordStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
return score;
}
const strength = getPasswordStrength(values.password);
const strengthLabels = ['', 'Very Weak', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong'];
const strengthColors = ['#ccc', '#dc3545', '#fd7e14', '#ffc107', '#20c997', '#28a745', '#198754'];
return (
<div>
<h2 style={{ marginBottom: 4 }}>Account Details</h2>
<p style={{ color: '#6c757d', marginBottom: 24 }}>Set up your login credentials</p>
<FormField
label="Username"
name="username"
value={values.username}
error={errors.username}
touched={touched.username}
onChange={onChange}
onBlur={onBlur}
placeholder="johndoe"
/>
<FormField
label="Password"
name="password"
type="password"
value={values.password}
error={errors.password}
touched={touched.password}
onChange={onChange}
onBlur={onBlur}
placeholder="At least 8 characters"
>
{/* Password strength bar */}
{values.password && (
<div style={{ marginTop: 8 }}>
<div style={{ height: 4, borderRadius: 2, background: '#e9ecef' }}>
<div style={{
height: '100%',
width: `${(strength / 6) * 100}%`,
background: strengthColors[strength],
borderRadius: 2,
transition: 'all 0.3s',
}} />
</div>
<span style={{ fontSize: 12, color: strengthColors[strength] }}>
{strengthLabels[strength]}
</span>
</div>
)}
</FormField>
<FormField
label="Confirm Password"
name="confirmPassword"
type="password"
value={values.confirmPassword}
error={errors.confirmPassword}
touched={touched.confirmPassword}
onChange={onChange}
onBlur={onBlur}
placeholder="Re-enter your password"
/>
</div>
);
}
9. Step 7: Step 3 — Preferences
// components/PreferencesStep.jsx
function PreferencesStep({ values, errors, touched, onChange, onBlur }) {
const roleOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'designer', label: 'Designer' },
{ value: 'manager', label: 'Product Manager' },
{ value: 'student', label: 'Student' },
{ value: 'other', label: 'Other' },
];
const experienceOptions = [
{ value: 'beginner', label: 'Beginner (< 1 year)' },
{ value: 'intermediate', label: 'Intermediate (1-3 years)' },
{ value: 'advanced', label: 'Advanced (3-5 years)' },
{ value: 'expert', label: 'Expert (5+ years)' },
];
const interestOptions = ['React', 'Vue', 'Angular', 'Svelte', 'Next.js', 'Node.js', 'Python', 'Go'];
function handleInterestChange(event) {
const { value, checked } = event.target;
const newInterests = checked
? [...values.interests, value]
: values.interests.filter(i => i !== value);
onChange({
target: { name: 'interests', value: newInterests, type: 'checkbox-group' }
});
}
return (
<div>
<h2 style={{ marginBottom: 4 }}>Preferences</h2>
<p style={{ color: '#6c757d', marginBottom: 24 }}>Help us personalize your experience</p>
<FormField
label="Your Role"
name="role"
type="select"
value={values.role}
error={errors.role}
touched={touched.role}
onChange={onChange}
onBlur={onBlur}
placeholder="Select your role"
options={roleOptions}
/>
<FormField
label="Experience Level"
name="experience"
type="radio"
value={values.experience}
error={errors.experience}
touched={touched.experience}
onChange={onChange}
onBlur={onBlur}
options={experienceOptions}
/>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500, fontSize: 14 }}>
Interests (select at least one)
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{interestOptions.map(interest => (
<label key={interest} style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
value={interest}
checked={values.interests.includes(interest)}
onChange={handleInterestChange}
/>
{interest}
</label>
))}
</div>
{touched.interests && errors.interests && (
<p style={{ color: '#dc3545', fontSize: 12, marginTop: 4 }}>⚠ {errors.interests}</p>
)}
</div>
<FormField
label="Subscribe to our newsletter"
name="newsletter"
type="checkbox"
value={values.newsletter}
onChange={onChange}
onBlur={onBlur}
/>
</div>
);
}
10. Step 8: Step 4 — Review & Submit
// components/ReviewStep.jsx
function ReviewStep({ values, errors, touched, onChange, onBlur }) {
const sections = [
{
title: 'Personal Information',
fields: [
{ label: 'Name', value: `${values.firstName} ${values.lastName}` },
{ label: 'Email', value: values.email },
{ label: 'Phone', value: values.phone || 'Not provided' },
],
},
{
title: 'Account Details',
fields: [
{ label: 'Username', value: values.username },
{ label: 'Password', value: '•'.repeat(values.password.length) },
],
},
{
title: 'Preferences',
fields: [
{ label: 'Role', value: values.role },
{ label: 'Experience', value: values.experience },
{ label: 'Interests', value: values.interests.join(', ') || 'None' },
{ label: 'Newsletter', value: values.newsletter ? 'Yes' : 'No' },
],
},
];
return (
<div>
<h2 style={{ marginBottom: 4 }}>Review Your Information</h2>
<p style={{ color: '#6c757d', marginBottom: 24 }}>
Please verify everything looks correct before submitting.
</p>
{sections.map(section => (
<div
key={section.title}
style={{
border: '1px solid #e9ecef',
borderRadius: 8,
padding: 16,
marginBottom: 16,
}}
>
<h3 style={{ margin: '0 0 12px', fontSize: 16, color: '#495057' }}>
{section.title}
</h3>
{section.fields.map(field => (
<div
key={field.label}
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #f8f9fa',
}}
>
<span style={{ color: '#6c757d', fontSize: 14 }}>{field.label}</span>
<span style={{ fontWeight: 500, fontSize: 14 }}>{field.value}</span>
</div>
))}
</div>
))}
<FormField
label="I agree to the Terms of Service and Privacy Policy"
name="agreeToTerms"
type="checkbox"
value={values.agreeToTerms}
error={errors.agreeToTerms}
touched={touched.agreeToTerms}
onChange={onChange}
onBlur={onBlur}
/>
</div>
);
}
11. Step 9: Complete App Assembly
// App.jsx
import { useState } from 'react';
import { validateField, validateStep } from './utils/validators';
// Step field mapping
const stepFields = {
0: ['firstName', 'lastName', 'email', 'phone'],
1: ['username', 'password', 'confirmPassword'],
2: ['role', 'interests'],
3: ['agreeToTerms'],
};
const stepLabels = ['Personal', 'Account', 'Preferences', 'Review'];
function App() {
const [currentStep, setCurrentStep] = useState(0);
const [values, setValues] = useState(initialFormState);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitStatus, setSubmitStatus] = useState('idle'); // idle | loading | success | error
// ─── Handlers ───
function handleChange(event) {
const { name, value, type, checked } = event.target;
const newValue = type === 'checkbox' ? checked : value;
setValues(prev => ({ ...prev, [name]: newValue }));
// Validate on change if already touched
if (touched[name]) {
const error = validateField(name, newValue, { ...values, [name]: newValue });
setErrors(prev => ({ ...prev, [name]: error }));
}
}
function handleBlur(event) {
const { name } = event.target;
setTouched(prev => ({ ...prev, [name]: true }));
// Validate on blur
const error = validateField(name, values[name], values);
setErrors(prev => ({ ...prev, [name]: error }));
}
function handleNext() {
const fields = stepFields[currentStep];
const { errors: stepErrors, isValid } = validateStep(fields, values);
// Mark all step fields as touched
const newTouched = {};
fields.forEach(f => { newTouched[f] = true; });
setTouched(prev => ({ ...prev, ...newTouched }));
setErrors(prev => ({ ...prev, ...stepErrors }));
if (isValid) {
setCurrentStep(prev => prev + 1);
}
}
function handleBack() {
setCurrentStep(prev => prev - 1);
}
async function handleSubmit() {
// Validate final step
const fields = stepFields[currentStep];
const { errors: stepErrors, isValid } = validateStep(fields, values);
const newTouched = {};
fields.forEach(f => { newTouched[f] = true; });
setTouched(prev => ({ ...prev, ...newTouched }));
setErrors(prev => ({ ...prev, ...stepErrors }));
if (!isValid) return;
setSubmitStatus('loading');
try {
// Simulate API call
await new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.1) resolve();
else reject(new Error('Server error'));
}, 2000);
});
setSubmitStatus('success');
} catch (err) {
setSubmitStatus('error');
}
}
// ─── Common props passed to all step components ───
const formProps = { values, errors, touched, onChange: handleChange, onBlur: handleBlur };
// ─── Render ───
if (submitStatus === 'success') {
return (
<div style={{ textAlign: 'center', padding: 60 }}>
<div style={{ fontSize: 64, marginBottom: 16 }}>🎉</div>
<h2>Registration Complete!</h2>
<p style={{ color: '#6c757d' }}>Welcome, {values.firstName}! Check your email for next steps.</p>
</div>
);
}
const stepComponents = [
<PersonalInfoStep {...formProps} />,
<AccountStep {...formProps} />,
<PreferencesStep {...formProps} />,
<ReviewStep {...formProps} />,
];
const isLastStep = currentStep === stepComponents.length - 1;
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 20px' }}>
<h1 style={{ textAlign: 'center', marginBottom: 32 }}>Create Account</h1>
<StepIndicator steps={stepLabels} currentStep={currentStep} />
<div style={{
background: 'white',
border: '1px solid #e9ecef',
borderRadius: 12,
padding: 32,
}}>
{stepComponents[currentStep]}
{submitStatus === 'error' && (
<div style={{
background: '#f8d7da',
color: '#721c24',
padding: 12,
borderRadius: 8,
marginBottom: 16,
}}>
❌ Something went wrong. Please try again.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 24 }}>
<button
onClick={handleBack}
disabled={currentStep === 0}
style={{
padding: '10px 24px',
border: '1px solid #dee2e6',
borderRadius: 8,
background: 'white',
cursor: currentStep === 0 ? 'not-allowed' : 'pointer',
opacity: currentStep === 0 ? 0.5 : 1,
}}
>
← Back
</button>
<button
onClick={isLastStep ? handleSubmit : handleNext}
disabled={submitStatus === 'loading'}
style={{
padding: '10px 24px',
border: 'none',
borderRadius: 8,
background: '#0070f3',
color: 'white',
cursor: 'pointer',
fontWeight: 600,
opacity: submitStatus === 'loading' ? 0.7 : 1,
}}
>
{submitStatus === 'loading'
? 'Submitting...'
: isLastStep
? 'Submit'
: 'Next →'
}
</button>
</div>
</div>
</div>
);
}
12. Step 10: Adding Polish — Loading, Success, Error States
Submit Button with Loading Spinner
function SubmitButton({ isLoading, isLastStep, onClick }) {
return (
<button
onClick={onClick}
disabled={isLoading}
style={{
padding: '10px 24px',
border: 'none',
borderRadius: 8,
background: '#0070f3',
color: 'white',
cursor: isLoading ? 'wait' : 'pointer',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{isLoading && (
<span style={{
width: 16,
height: 16,
border: '2px solid rgba(255,255,255,0.3)',
borderTopColor: 'white',
borderRadius: '50%',
display: 'inline-block',
animation: 'spin 0.8s linear infinite',
}} />
)}
{isLoading ? 'Submitting...' : isLastStep ? 'Submit' : 'Next →'}
</button>
);
}
13. Custom Hook: useForm
Extract all form logic into a reusable hook:
// hooks/useForm.js
import { useState, useCallback } from 'react';
export function useForm(initialValues, validationRules) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validateField = useCallback((name, value) => {
const rules = validationRules[name] || [];
for (const rule of rules) {
const error = rule(value, values);
if (error) return error;
}
return '';
}, [validationRules, values]);
const handleChange = useCallback((event) => {
const { name, value, type, checked } = event.target;
const newValue = type === 'checkbox' ? checked : value;
setValues(prev => {
const updated = { ...prev, [name]: newValue };
// Re-validate if touched
if (touched[name]) {
const error = validateField(name, newValue);
setErrors(prev => ({ ...prev, [name]: error }));
}
return updated;
});
}, [touched, validateField]);
const handleBlur = useCallback((event) => {
const { name } = event.target;
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name, values[name]);
setErrors(prev => ({ ...prev, [name]: error }));
}, [validateField, values]);
const validateFields = useCallback((fieldNames) => {
const newErrors = {};
let isValid = true;
const newTouched = {};
fieldNames.forEach(name => {
newTouched[name] = true;
const error = validateField(name, values[name]);
if (error) {
newErrors[name] = error;
isValid = false;
}
});
setTouched(prev => ({ ...prev, ...newTouched }));
setErrors(prev => ({ ...prev, ...newErrors }));
return isValid;
}, [validateField, values]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
setIsSubmitting,
handleChange,
handleBlur,
validateFields,
setValues,
reset,
};
}
// Usage:
// const form = useForm(initialFormState, fieldValidation);
// <FormField value={form.values.email} error={form.errors.email} ... />
14. Extending the Form — Adding/Removing Fields Dynamically
function DynamicFieldsDemo() {
const [fields, setFields] = useState([{ id: 1, value: '' }]);
function addField() {
setFields(prev => [...prev, { id: Date.now(), value: '' }]);
}
function removeField(id) {
setFields(prev => prev.filter(f => f.id !== id));
}
function updateField(id, value) {
setFields(prev => prev.map(f => f.id === id ? { ...f, value } : f));
}
return (
<div>
<h3>Skills</h3>
{fields.map((field, index) => (
<div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input
value={field.value}
onChange={(e) => updateField(field.id, e.target.value)}
placeholder={`Skill ${index + 1}`}
style={{ flex: 1 }}
/>
{fields.length > 1 && (
<button onClick={() => removeField(field.id)} style={{ color: 'red' }}>
✕
</button>
)}
</div>
))}
<button onClick={addField} style={{ marginTop: 8 }}>
+ Add Skill
</button>
</div>
);
}
15. Key Takeaways
- Form architecture = state model + validation rules + reusable field components + step navigation
- Controlled inputs give complete control over every value, enabling real-time validation and formatting
- Validation on blur (not on every keystroke) provides the best UX — errors appear after the user finishes a field
- Validate on change only for fields that have already been touched (re-validation)
- Per-step validation prevents users from advancing with invalid data
- The
nameattribute is the key to a singlehandleChangefor all fields - FormField component abstracts away error display, styling, and input type variations
- Custom
useFormhook extracts all form logic — makes it reusable across your entire app - Dynamic fields (add/remove) work by managing an array of field objects with unique IDs
- Always handle loading, success, AND error states on form submission
Explain-It Challenge
-
The Product Manager Explanation: Explain to a PM why form validation happens on blur (not every keystroke) and why per-step validation matters for conversion rates.
-
The Architecture Discussion: Compare our custom
useFormhook to libraries like React Hook Form and Formik. What trade-offs do you make building your own vs using a library? -
The Refactoring Challenge: The current form has all step components in separate files. How would you restructure it to be config-driven — where adding a new step requires only adding an entry to a configuration object, not writing a new component?
Navigation: ← Dynamic UI Rendering · Next → 2.6 Component Architecture