Topic 13 of 15 · React Developer

Topic 13 : React Forms

Lesson TL;DRTopic 13: React Forms 📖 9 min read · 🎯 Advanced · 🧭 Prerequisites: actionsandreducers, networking Why this matters Picture this: you're building a signup page, a login form, a search bar — basicall...
9 min read·advanced·react · forms · controlled-components · form-validation

Topic 13: React Forms

📖 9 min read · 🎯 Advanced · 🧭 Prerequisites: actions-and-reducers, networking

Why this matters

Picture this: you're building a signup page, a login form, a search bar — basically anything real that users actually type into. Without knowing how React handles forms, you're guessing. And guessing with forms leads to bugs you can't explain: values that don't update, validations that fire at the wrong time, inputs that feel broken. I've seen students spend hours on this. Today we fix that. We're going to cover controlled components, multi-input state, validation, and refs — the four things React needs from you to make forms actually work.

What You'll Learn

  • Build a basic controlled form with useState and onChange handlers
  • Manage multiple inputs with a single shared state object and a generic handleChange
  • Validate form data before submission and render inline error messages
  • Use useRef to read uncontrolled input values directly from the DOM

The Analogy

Think of a paper form on a government desk. An uncontrolled form is like handing the citizen a pen and letting them write directly on the paper — you only read it when they hand it back. A controlled form is like a clerk sitting beside them, typing every letter into a live database the moment they speak it. React's controlled-component pattern is the clerk: every keystroke flows into state, state flows back into the input, and you always know exactly what's in every field without waiting for the form to be submitted.

Chapter 1: Basic Form Handling

In React, forms look like regular HTML forms, but React takes ownership of the data. Instead of the browser tracking what's in each field, your component's state does — which means you can read, transform, or validate values at any moment.

src/components/SimpleForm.js:

import React, { useState } from 'react';

function SimpleForm() {
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');

    const handleSubmit = (event) => {
        event.preventDefault();
        alert(`Name: ${name}, Email: ${email}`);
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label>Name:</label>
                <input
                    type="text"
                    value={name}
                    onChange={(e) => setName(e.target.value)}
                />
            </div>
            <div>
                <label>Email:</label>
                <input
                    type="email"
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                />
            </div>
            <button type="submit">Submit</button>
        </form>
    );
}

export default SimpleForm;

Key points:

  • event.preventDefault() stops the browser from doing a full-page POST, keeping the app in SPA territory.
  • Each input has both a value prop (read from state) and an onChange handler (writes to state). Remove either one and the field breaks.

Integrating into App — src/App.js:

import React from 'react';
import './App.css';
import SimpleForm from './components/SimpleForm';

function App() {
    return (
        <div className="App">
            <h1>React Forms</h1>
            <SimpleForm />
        </div>
    );
}

export default App;

Chapter 2: Controlled Components

A controlled component is one where React state is the single source of truth for every input value. The component renders what state says; the user types; onChange fires; state updates; the component re-renders with the new value. The DOM never leads — it only follows.

the trainer drew the data-flow loop on the board:

flowchart LR
    User -->|types| onChange
    onChange -->|calls setState| State
    State -->|value prop| Input
    Input -->|renders| User

When a form grows beyond two fields, keeping one useState call per field becomes noise. Consolidate them into a single object and write one generic handleChange:

src/components/ControlledForm.js:

import React, { useState } from 'react';

function ControlledForm() {
    const [formData, setFormData] = useState({
        name: '',
        email: '',
        message: ''
    });

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

    const handleSubmit = (event) => {
        event.preventDefault();
        console.log('Form Data:', formData);
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label>Name:</label>
                <input
                    type="text"
                    name="name"
                    value={formData.name}
                    onChange={handleChange}
                />
            </div>
            <div>
                <label>Email:</label>
                <input
                    type="email"
                    name="email"
                    value={formData.email}
                    onChange={handleChange}
                />
            </div>
            <div>
                <label>Message:</label>
                <textarea
                    name="message"
                    value={formData.message}
                    onChange={handleChange}
                ></textarea>
            </div>
            <button type="submit">Submit</button>
        </form>
    );
}

export default ControlledForm;

The computed property key [name]: value is the trick — it reads the name attribute from the DOM element and uses it as the state key, so a single handler covers all three fields (and can cover thirty).

Chapter 3: Handling Multiple Inputs

The same pattern scales to any input type: text, number, select, checkbox, radio. The name attribute on each element must match the key in your state object — that's the contract.

src/components/MultipleInputsForm.js:

import React, { useState } from 'react';

function MultipleInputsForm() {
    const [formData, setFormData] = useState({
        username: '',
        age: '',
        gender: 'male'
    });

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

    const handleSubmit = (event) => {
        event.preventDefault();
        console.log('Form Data:', formData);
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label>Username:</label>
                <input
                    type="text"
                    name="username"
                    value={formData.username}
                    onChange={handleChange}
                />
            </div>
            <div>
                <label>Age:</label>
                <input
                    type="number"
                    name="age"
                    value={formData.age}
                    onChange={handleChange}
                />
            </div>
            <div>
                <label>Gender:</label>
                <select
                    name="gender"
                    value={formData.gender}
                    onChange={handleChange}
                >
                    <option value="male">Male</option>
                    <option value="female">Female</option>
                    <option value="other">Other</option>
                </select>
            </div>
            <button type="submit">Submit</button>
        </form>
    );
}

export default MultipleInputsForm;

Notice gender starts as 'male' — the <select> reads its initial value from state, not from a selected attribute on an <option>. This is a common gotcha when converting HTML forms to React.

Chapter 4: Form Validation

Collecting data without validating it is like accepting unsigned contracts. Before you trust formData with a server, check it client-side: give the user immediate feedback, and only proceed when the data is clean.

The pattern:

  1. A separate errors state object, keyed the same way as formData.
  2. A validate() function that populates tempErrors and calls setErrors.
  3. validate() returns true only when the errors object is empty.
  4. handleSubmit calls validate() and bails out if it returns false.

src/components/ValidatedForm.js:

import React, { useState } from 'react';

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

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

    const validate = () => {
        let tempErrors = {};
        if (!formData.email) {
            tempErrors.email = 'Email is required';
        } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
            tempErrors.email = 'Email is invalid';
        }
        if (!formData.password) {
            tempErrors.password = 'Password is required';
        } else if (formData.password.length < 6) {
            tempErrors.password = 'Password must be at least 6 characters';
        }
        setErrors(tempErrors);
        return Object.keys(tempErrors).length === 0;
    };

    const handleSubmit = (event) => {
        event.preventDefault();
        if (validate()) {
            console.log('Form Data:', formData);
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label>Email:</label>
                <input
                    type="email"
                    name="email"
                    value={formData.email}
                    onChange={handleChange}
                />
                {errors.email && <span className="error">{errors.email}</span>}
            </div>
            <div>
                <label>Password:</label>
                <input
                    type="password"
                    name="password"
                    value={formData.password}
                    onChange={handleChange}
                />
                {errors.password && <span className="error">{errors.password}</span>}
            </div>
            <button type="submit">Submit</button>
        </form>
    );
}

export default ValidatedForm;

Validation rules used here:

  • Email required: !formData.email
  • Email format: regex /\S+@\S+\.\S+/ — not RFC-5322 complete, but catches obvious typos
  • Password required: !formData.password
  • Password minimum length: formData.password.length < 6

Error messages render inline with {errors.email && <span className="error">…</span>} — the short-circuit keeps the span out of the DOM entirely when there's no error.

Chapter 5: Using Refs for Form Elements

Sometimes you don't want React to own the field value at all — you just want to read the DOM node once at submit time. That's what useRef is for. The ref object's .current property points directly at the underlying DOM element, so you can call .value, .focus(), .select(), or any other native method.

src/components/RefForm.js:

import React, { useRef } from 'react';

function RefForm() {
    const nameRef = useRef(null);
    const emailRef = useRef(null);

    const handleSubmit = (event) => {
        event.preventDefault();
        const name = nameRef.current.value;
        const email = emailRef.current.value;
        alert(`Name: ${name}, Email: ${email}`);
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label>Name:</label>
                <input type="text" ref={nameRef} />
            </div>
            <div>
                <label>Email:</label>
                <input type="email" ref={emailRef} />
            </div>
            <button type="submit">Submit</button>
        </form>
    );
}

export default RefForm;

Notice there is no value prop and no onChange on these inputs — they are uncontrolled. React is not tracking what's in them; the browser is. The tradeoff:

ControlledUncontrolled (ref)
State inReact useStateDOM node
Instant accessYes (from state)No (must read .current.value)
Real-time validationEasyHard
Reset on submitsetFormData({…})ref.current.value = ''
When to useAlmost alwaysThird-party DOM libs, file inputs, focus management

🧪 Try It Yourself

Task: Build a registration form with three fields — username (text), email (email), and password (password). Validate all three on submit:

  • username must not be empty
  • email must match /\S+@\S+\.\S+/
  • password must be at least 8 characters

Display inline error messages next to each field. On successful validation, log the formData object to the console.

Success criterion: Submitting an empty form shows three error messages. Submitting a valid form logs { username: '...', email: '...', password: '...' } to the browser console with no errors shown.

Starter snippet:

import React, { useState } from 'react';

function RegistrationForm() {
    const [formData, setFormData] = useState({ username: '', email: '', password: '' });
    const [errors, setErrors] = useState({});

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

    const validate = () => {
        let tempErrors = {};
        // TODO: add your validation rules here
        setErrors(tempErrors);
        return Object.keys(tempErrors).length === 0;
    };

    const handleSubmit = (event) => {
        event.preventDefault();
        if (validate()) {
            console.log('Form Data:', formData);
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            {/* TODO: render inputs for username, email, password with error spans */}
            <button type="submit">Register</button>
        </form>
    );
}

export default RegistrationForm;

🔍 Checkpoint Quiz

Q1. What makes a React form input a "controlled component"?

A) It uses a ref to read the DOM value
B) Its value prop is bound to state and onChange updates that state
C) It has a defaultValue attribute set
D) It calls event.preventDefault() on submit

Q2. Given the following handler, what gets logged when a user types "Alice" into <input name="username" /> and submits?

const [formData, setFormData] = useState({ username: '', role: 'viewer' });

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

const handleSubmit = (event) => {
    event.preventDefault();
    console.log(formData);
};

A) { username: 'Alice' }
B) { username: 'Alice', role: 'viewer' }
C) { name: 'username', value: 'Alice' }
D) Nothing — formData is stale

Q3. In the ValidatedForm example, the validate function returns Object.keys(tempErrors).length === 0. Why return this instead of just true?

A) It's a React requirement for form validators
B) It returns true only when no errors were added, letting handleSubmit skip submission when errors exist
C) It clears the errors state automatically
D) It prevents the browser from navigating away

Q4. You need to programmatically focus a text input when a modal opens. Should you use a controlled component or a useRef? Write a one-line explanation.

A1. B — A controlled component keeps its value in React state; the value prop pins what the DOM shows and onChange keeps state in sync.

A2. B — The spread ...formData preserves the existing role: 'viewer' key while [name]: value overwrites only username.

A3. B — Object.keys(tempErrors).length === 0 evaluates to true when the errors object is empty (no failing rules) and false when at least one rule failed, giving handleSubmit a clean boolean gate.

A4. Use useRef — you need to call .current.focus() imperatively on the DOM node, which state alone cannot trigger.

🪞 Recap

  • React controlled components bind value to state and onChange to a setter, making state the single source of truth for every input.
  • A single handleChange function using [event.target.name]: event.target.value can manage an entire form object regardless of how many fields it has.
  • Form validation lives in a dedicated validate() function that populates an errors state object and returns a boolean; handleSubmit only proceeds when it returns true.
  • useRef gives you direct DOM access for uncontrolled inputs — useful for focus management and reading values once at submit time, but not recommended when you need real-time validation.
  • Always call event.preventDefault() in handleSubmit to stop the browser's native form submission from reloading the page.

📚 Further Reading

Like this topic? It’s one of 15 in React Developer.

Block your seat for ₹2,500 and join the next cohort.