Code To Learn logo

Code To Learn

M7: Forms & Authentication

L7: Create SignInForm with React Hook Form

Build a validated form with React Hook Form and Zod

Time to build a powerful, validated form using React Hook Form and Zod! 📝

Why React Hook Form?

Traditional forms are painful:

// ❌ Manual state management (tedious)
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [touched, setTouched] = useState({});

// Lots of boilerplate...

React Hook Form is better:

// ✅ Simple, powerful
const { register, handleSubmit, formState: { errors } } = useForm();

<input {...register('email')} />
// That's it!

Benefits:

  • Less code: No manual state
  • Better performance: Fewer re-renders
  • Built-in validation: Integrated with Zod
  • Error handling: Automatic
  • TypeScript support: Full type safety

Why Zod?

Zod provides schema validation:

// Define what valid data looks like
const schema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Must be 8+ characters'),
});

// Validate data
schema.parse({ email: 'test@test.com', password: '12345678' });
// ✅ Valid!

schema.parse({ email: 'bad-email', password: '123' });
// ❌ Throws error with messages

Benefits:

  • Type safety: TypeScript infers types
  • Reusable schemas: Use in forms, APIs, databases
  • Clear error messages: User-friendly
  • Composable: Build complex validations

Install Dependencies

Install React Hook Form and Zod:

npm install react-hook-form zod @hookform/resolvers

What each does:

  • react-hook-form: Form state and validation
  • zod: Schema validation
  • @hookform/resolvers: Connects Zod to React Hook Form

Verify installation:

npm list react-hook-form zod

Should show installed versions (v7.x+ for react-hook-form, v3.x+ for zod).

Create Validation Schema

Define what valid sign-in data looks like:

src/schemas/signInSchema.js
import { z } from 'zod';

export const signInSchema = z.object({
  email: z
    .string()
    .min(1, 'Email is required')
    .email('Please enter a valid email address'),
  
  password: z
    .string()
    .min(1, 'Password is required')
    .min(8, 'Password must be at least 8 characters'),
});

Create SignInForm Component

Build the form with validation:

src/components/SignInForm.jsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signInSchema } from '@/schemas/signInSchema';

function SignInForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(signInSchema),
  });

  const onSubmit = async (data) => {
    console.log('Form data:', data);
    // TODO: Implement sign-in logic in Lesson 9
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="sign-in-form">
      {/* Email Field */}
      <div className="form-field">
        <label htmlFor="email" className="form-label">
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email')}
          className={`form-input ${errors.email ? 'form-input-error' : ''}`}
          placeholder="you@example.com"
        />
        {errors.email && (
          <p className="form-error">{errors.email.message}</p>
        )}
      </div>

      {/* Password Field */}
      <div className="form-field">
        <label htmlFor="password" className="form-label">
          Password
        </label>
        <input
          id="password"
          type="password"
          {...register('password')}
          className={`form-input ${errors.password ? 'form-input-error' : ''}`}
          placeholder="••••••••"
        />
        {errors.password && (
          <p className="form-error">{errors.password.message}</p>
        )}
      </div>

      {/* Submit Button */}
      <button
        type="submit"
        disabled={isSubmitting}
        className="submit-button"
      >
        {isSubmitting ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}

export default SignInForm;

Add Form Styling

Create beautiful, accessible form styles:

src/app/global.css
/* Form Container */
.sign-in-form {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

/* Form Field */
.form-field {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* Label */
.form-label {
  font-size: 0.875rem;
  font-weight: 500;
  color: #374151;
  cursor: pointer;
}

/* Input */
.form-input {
  width: 100%;
  padding: 0.75rem 1rem;
  font-size: 1rem;
  color: #1f2937;
  background: white;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  transition: all 0.2s;
  outline: none;
}

.form-input:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.form-input::placeholder {
  color: #9ca3af;
}

/* Input Error State */
.form-input-error {
  border-color: #ef4444;
}

.form-input-error:focus {
  border-color: #ef4444;
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}

/* Error Message */
.form-error {
  font-size: 0.875rem;
  color: #ef4444;
  margin: 0;
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.form-error::before {
  content: '⚠️';
  font-size: 1rem;
}

/* Submit Button */
.submit-button {
  width: 100%;
  padding: 0.875rem 1rem;
  font-size: 1rem;
  font-weight: 600;
  color: white;
  background: #3b82f6;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.2s;
}

.submit-button:hover:not(:disabled) {
  background: #2563eb;
  transform: translateY(-1px);
  box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}

.submit-button:active:not(:disabled) {
  transform: translateY(0);
}

.submit-button:disabled {
  background: #9ca3af;
  cursor: not-allowed;
}

/* Focus Visible (Accessibility) */
.form-input:focus-visible,
.submit-button:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

Understanding the Code

The useForm hook provides everything:

const {
  register,       // Register inputs
  handleSubmit,   // Handle form submission
  formState,      // Form state (errors, isSubmitting, etc.)
} = useForm({
  resolver: zodResolver(signInSchema),  // Use Zod for validation
});

Breaking down formState:

const { errors, isSubmitting } = formState;

// errors: Object with validation errors
// {
//   email: { message: 'Invalid email' },
//   password: { message: 'Too short' }
// }

// isSubmitting: Boolean
// true → Form is submitting
// false → Form is idle

resolver connects Zod:

// Without resolver (manual validation)
useForm({
  // You'd have to validate manually
});

// With resolver (automatic validation)
useForm({
  resolver: zodResolver(signInSchema),  // Zod handles it!
});

When validation happens:

User types → onChange validation (optional)
User blurs field → onBlur validation (default)
User submits → onSubmit validation (always)

Configure with mode:

useForm({
  mode: 'onChange',  // Validate on every change
  mode: 'onBlur',    // Validate when field loses focus (default)
  mode: 'onSubmit',  // Only validate on submit
});

register connects inputs to form:

<input {...register('email')} />

What {...register('email')} does:

Spreads these props onto the input:

<input
  name="email"
  ref={/* React Hook Form's internal ref */}
  onChange={/* Internal change handler */}
  onBlur={/* Internal blur handler */}
/>

Manual expansion (don't do this):

// ❌ Don't do this (use {...register('email')} instead)
const emailProps = register('email');

<input
  name={emailProps.name}
  ref={emailProps.ref}
  onChange={emailProps.onChange}
  onBlur={emailProps.onBlur}
/>

Spread syntax is cleaner:

// ✅ Do this
<input {...register('email')} />

Register with options:

<input
  {...register('email', {
    required: 'Email is required',
    pattern: {
      value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
      message: 'Invalid email',
    },
  })}
/>

But with Zod, options aren't needed (Zod handles validation).

Two-step validation:

Step 1: Schema definition

const signInSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Must be 8+ characters'),
});

Step 2: Apply to form

useForm({
  resolver: zodResolver(signInSchema),
});

Validation flow:

User fills form

User submits

handleSubmit runs

Zod validates data

Valid? → onSubmit runs
Invalid? → Errors show

Example validation:

// User enters:
email: 'bad-email'
password: '123'

// Zod validates:
email: 'bad-email'  → ❌ Not a valid email
password: '123'     → ❌ Less than 8 characters

// Errors object:
{
  email: { message: 'Please enter a valid email address' },
  password: { message: 'Password must be at least 8 characters' }
}

// UI shows both errors
// onSubmit does NOT run

Errors are automatic:

const { formState: { errors } } = useForm();

// Check if field has error
if (errors.email) {
  // Show error message
  <p>{errors.email.message}</p>
}

Conditional styling:

<input
  className={`form-input ${errors.email ? 'form-input-error' : ''}`}
/>

Result:

No error:  class="form-input"
Has error: class="form-input form-input-error"

Error display pattern:

{errors.email && (
  <p className="form-error">{errors.email.message}</p>
)}

Timeline:

Initial state: errors = {}
  → No error message shown

User submits with invalid email
  → errors = { email: { message: 'Invalid email' } }
  → Error message shown

User fixes email
  → errors = {}
  → Error message hidden

Multiple errors:

// Field has multiple validation rules
z.string()
  .min(1, 'Required')
  .email('Invalid email')
  .max(100, 'Too long')

// Zod shows FIRST error that fails:
'''Required'
'short''Invalid email'
'test@x.com' → (no error)
'test@...(very long)''Too long'

Zod Schema Deep Dive

Testing the Form

Test valid submission:

  1. Enter valid email: test@example.com
  2. Enter valid password: password123
  3. Click "Sign In"
  4. Check console: Should log form data

No errors, data logged!

Test email validation:

  1. Enter invalid email: bad-email
  2. Enter any password
  3. Click "Sign In"
  4. Should see error: "Please enter a valid email address"

Error shown, form NOT submitted!

Test password validation:

  1. Enter valid email
  2. Enter short password: 123
  3. Click "Sign In"
  4. Should see error: "Password must be at least 8 characters"

Error shown, form NOT submitted!

Test empty fields:

  1. Leave both fields empty
  2. Click "Sign In"
  3. Should see errors on both fields:
    • Email: "Email is required"
    • Password: "Password is required"

Multiple errors shown!

Test disabled state:

  1. Fill form with valid data
  2. Click "Sign In"
  3. Button should show "Signing in..." and be disabled
  4. After console.log, button returns to "Sign In"

Button disabled during submission!

What's Next?

In Lesson 8, we'll:

  1. Add the SignInForm to SignInPage
  2. Remove the placeholder
  3. Test the complete sign-in page
  4. Add finishing touches (remember me, forgot password)

✅ Lesson Complete! You've created a powerful, validated form!

Key Takeaways

  • React Hook Form reduces boilerplate code significantly
  • Zod schemas provide type-safe validation
  • register connects inputs to form state
  • handleSubmit validates before calling onSubmit
  • errors object shows validation errors automatically
  • isSubmitting tracks submission state
  • Spread syntax {...register('name')} registers fields
  • Conditional CSS shows error states visually
  • Error messages come from Zod schema
  • Type safety with TypeScript integration