How to Build a Login Page with React and Hooks: Step-by-Step Tutorial

One of the most popular services we offer is ongoing website maintenance because most clients we work with become return clients.

How to Build a Login Page with React and Hooks: Step-by-Step Tutorial

If you’re learning React or building your next project, creating a login page is one of those rite-of-passage tasks every developer goes through. In this React login page tutorial, we’ll walk through building a functional, reusable login component using only useState and useEffect hooks, with proper form validation and error handling baked in.

No bloated libraries, no complex setup. Just clean code patterns you can drop into any real-world project.

What You’ll Build

By the end of this guide, you’ll have a fully functional login page that includes:

  • Controlled form inputs powered by useState
  • Real-time validation with useEffect
  • Email format checking and password length rules
  • Error messages displayed under each field
  • A submit handler ready to plug into your API
  • Loading state management during submission
login screen laptop

Prerequisites

Before we dive in, make sure you have the following installed:

  • Node.js version 18 or higher
  • A code editor (VS Code recommended)
  • Basic familiarity with JavaScript and JSX

Step 1: Set Up Your React Project

We’ll use Vite because it’s fast, modern, and the recommended way to scaffold a React app today.

npm create vite@latest react-login-app -- --template react
cd react-login-app
npm install
npm run dev

Your dev server should now be running at http://localhost:5173.

Step 2: Create the Login Component

Inside the src folder, create a new file named LoginPage.jsx. This will hold our login form logic.

import { useState, useEffect } from 'react';

function LoginPage() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });

  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState('');

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  return (
    <div className="login-container">
      <h1>Sign In</h1>
      <form>
        {/* Inputs go here */}
      </form>
    </div>
  );
}

export default LoginPage;

Why useState for the form?

Using a single state object keeps the form scalable. If you add new fields later (like a “remember me” checkbox), you only need to update one state, not create a new useState for each input.

login screen laptop

Step 3: Build the Form Inputs

Add the controlled inputs inside your form element:

<form onSubmit={handleSubmit}>
  <div className="form-group">
    <label htmlFor="email">Email</label>
    <input
      type="email"
      id="email"
      name="email"
      value={formData.email}
      onChange={handleChange}
      placeholder="[email protected]"
    />
    {errors.email && <span className="error">{errors.email}</span>}
  </div>

  <div className="form-group">
    <label htmlFor="password">Password</label>
    <input
      type="password"
      id="password"
      name="password"
      value={formData.password}
      onChange={handleChange}
      placeholder="Enter your password"
    />
    {errors.password && <span className="error">{errors.password}</span>}
  </div>

  <button type="submit" disabled={isSubmitting}>
    {isSubmitting ? 'Signing in...' : 'Sign In'}
  </button>

  {submitError && <p className="submit-error">{submitError}</p>}
</form>

Step 4: Add Validation with useEffect

Here’s where useEffect shines. Instead of validating only on submit, we validate as the user types, giving instant feedback.

useEffect(() => {
  const newErrors = {};
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  if (formData.email && !emailRegex.test(formData.email)) {
    newErrors.email = 'Please enter a valid email address';
  }

  if (formData.password && formData.password.length < 8) {
    newErrors.password = 'Password must be at least 8 characters';
  }

  setErrors(newErrors);
}, [formData]);

This effect runs every time formData changes, keeping your error state in sync with user input.

Step 5: Handle Form Submission

Now let’s wire up the submit handler. It should check for errors, call your API, and manage loading states.

const handleSubmit = async (e) => {
  e.preventDefault();
  setSubmitError('');

  if (!formData.email || !formData.password) {
    setSubmitError('All fields are required');
    return;
  }

  if (Object.keys(errors).length > 0) {
    setSubmitError('Please fix the errors before submitting');
    return;
  }

  setIsSubmitting(true);

  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    });

    if (!response.ok) {
      throw new Error('Invalid credentials');
    }

    const data = await response.json();
    localStorage.setItem('token', data.token);
    window.location.href = '/dashboard';
  } catch (err) {
    setSubmitError(err.message || 'Something went wrong');
  } finally {
    setIsSubmitting(false);
  }
};
login screen laptop

Step 6: Style the Login Page

Add some basic CSS to make it look professional. Create LoginPage.css and import it at the top of your component.

.login-container {
  max-width: 400px;
  margin: 80px auto;
  padding: 40px;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.08);
  background: #fff;
}

.form-group {
  margin-bottom: 20px;
  display: flex;
  flex-direction: column;
}

.form-group input {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

.error {
  color: #e74c3c;
  font-size: 12px;
  margin-top: 4px;
}

button {
  width: 100%;
  padding: 12px;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 6px;
  font-weight: 600;
  cursor: pointer;
}

button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

Hook Comparison: useState vs useEffect

Here’s a quick reference to remember which hook does what in this tutorial:

Hook Purpose in Login Page When It Runs
useState Stores form values, errors, loading state On every state update
useEffect Runs validation reactively when inputs change After render, on dependency change

Best Practices for React Login Pages

To make your login implementation production-ready, follow these guidelines:

  1. Never store passwords in state longer than needed. Clear sensitive data after submission.
  2. Use HTTPS for all authentication requests, no exceptions.
  3. Avoid localStorage for sensitive tokens in high-security apps. Use httpOnly cookies when possible.
  4. Add rate limiting on your backend to prevent brute force attacks.
  5. Implement password visibility toggle for better UX.
  6. Use semantic HTML and proper labels for accessibility.
login screen laptop

Common Mistakes to Avoid

  • Validating only on submit instead of giving live feedback
  • Forgetting to disable the submit button during the API call
  • Storing JWT tokens insecurely
  • Not handling network errors gracefully
  • Skipping the type="email" attribute on the email input

Wrapping Up

You now have a clean, reusable React login page built with just two hooks. The pattern shown here scales nicely. You can add fields, swap validation logic, or plug in any authentication backend like Firebase, Supabase, Auth0, or your own Node API.

At Pixelseed, we use this exact pattern as the starting point for most of our client dashboards before layering in features like social login, magic links, and multi-factor authentication.

FAQ

Should I use a library like React Hook Form instead?

For small projects or learning purposes, plain hooks are perfect. If your forms grow complex with dozens of fields or nested validation, libraries like React Hook Form or Formik save time and improve performance.

Can I use this login page with Next.js?

Yes. The component logic stays the same. You’ll just want to swap window.location.href for the Next.js router and consider server actions for the API call.

How do I protect routes after login?

Use React Router v6 with a wrapper component that checks for the auth token and redirects to /login if it’s missing. Combined with this login page, you have a complete auth flow.

Is useEffect the best way to validate forms?

It’s great for live validation as users type. For one-shot validation on submit only, you can skip useEffect and validate directly inside your submit handler.

How do I add a “forgot password” link?

Simply add a link below the form pointing to a separate route, like /forgot-password, which renders a similar component requesting only the email and triggering a password reset email.

Subscription Form

Contact Details

Quick Links

Copyright © 2022 Pixel Seed. All Rights Reserved.