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

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.

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);
}
};

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:
- Never store passwords in state longer than needed. Clear sensitive data after submission.
- Use HTTPS for all authentication requests, no exceptions.
- Avoid localStorage for sensitive tokens in high-security apps. Use httpOnly cookies when possible.
- Add rate limiting on your backend to prevent brute force attacks.
- Implement password visibility toggle for better UX.
- Use semantic HTML and proper labels for accessibility.

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.