Lesson 4

Events & forms

Home › Courses › React › Lesson 4
⚛️ Lesson 4 · 20 minutes

Events & forms.
The controlled component pattern.

Every app needs forms. Login, signup, search, checkout, contact — forms are how users send data to your app. In vanilla JavaScript, you read input values from the DOM when the form submits. In React, you keep the input value in state and update it on every keystroke. That's the controlled component pattern, and it's what makes React forms predictable. This lesson teaches events, controlled inputs, submission, and validation.

~20 minutes Beginner → Intermediate React 19
React path ← Lesson 3 Events Controlled inputs Form submit Multiple fields Validation Challenge

A controlled input has value from state and onChange updating state. Every keystroke: user types → onChange fires → setState updates → component re-renders → input shows new value. This is the standard React form pattern. Always call event.preventDefault() in the onSubmit handler — otherwise the browser reloads and loses React state. Use one state object per form, not one useState per field.

Before this lesson: complete Lesson 3. You need to understand state and the updater pattern.

🖱️ React events — camelCase, function references

Look like HTML attributes. Work like JavaScript listeners. Same event object.

React events are almost identical to vanilla JavaScript events. The differences: (1) camelCase names (onClick, not onclick), (2) pass a function reference, not a string, (3) React wraps the native event in a SyntheticEvent object with the same properties, normalized across browsers.

Event patterns
// Basic onClick — pass the function


// ❌ WRONG — this CALLS handleClick on every render


// ✅ CORRECT — pass a reference
function handleClick() {
console.log('clicked!');
}

// Inline arrow function — when you need to pass arguments


// The event object — always the first argument
function handleChange(event) {
console.log(event.target.value); // current input value
console.log(event.target.name); // input's name attribute
}

// Common events:
// onClick — click
// onChange — input/select/textarea value changes
// onSubmit — form submitted
// onFocus — input focused
// onBlur — input loses focus
// onKeyDown — key pressed
// onMouseEnter — mouse enters element

// React 17+ — event objects are NOT pooled (no need for event.persist)
async function handleAsync(event) {
await someAsyncOperation();
console.log(event.target.value); // works — not null
}

camelCase

onClick, not onclick. onChange, not onchange. HTML uses lowercase; JSX uses camelCase. Same for attributes like tabIndex, className.

Pass the function

onClick={handleClick} passes the function. onClick={handleClick()} calls it immediately. The parens matter — the #1 event bug.

Arrow function for arguments

onClick={() => handleDelete(id)} — wraps the call so it happens on click, not on render. Required when passing arguments.

SyntheticEvent

React's wrapper around the native event. Has target, preventDefault(), stopPropagation() — same API as native. Normalized across browsers.

The event pooling thing is dead — ignore old tutorials. Before React 17, event objects were pooled and became null after the handler returned, which is why old tutorials say event.persist() or capture values synchronously. React 17+ removed pooling — events are now standard JavaScript objects that work anywhere. If you see event.persist() in a tutorial from 2020 or earlier, it's outdated. Don't add it to new code.

🎛️ Controlled inputs — React owns the value

State holds the value. onChange updates it. The input reflects state on every render.

Controlled input pattern
import { useState } from 'react';

function NameForm() {
const [name, setName] = useState('');

return (
"name" type="text" value={name} onChange={(e) => setName(e.target.value)} />

You typed: {name}

); } // What happens on every keystroke: // 1. User types "A" // 2. onChange fires with e.target.value = "A" // 3. setName("A") schedules a re-render // 4. Component re-renders — name is now "A" // 5. Input's value prop is "A" — matches what user typed // // The input ALWAYS displays what state says. // If you forget onChange, the input becomes read-only // (because value is stuck at the last state value).

value from state

value={name} — the input's visible value is always the current state. If state is "Alice," input shows "Alice." If state is "", input is empty.

onChange updates

onChange={e => setName(e.target.value)} — every keystroke fires onChange with the new value. e.target.value is the character(s) after the keystroke.

Forgetting onChange

If you set value={name} but no onChange, the input is read-only — you can't type. React warns "You provided a value prop to a form field without an onChange handler."

Uncontrolled alternative

— sets initial value, but React doesn't track changes. You read via ref.current.value. Use only for file inputs or huge forms.

If your input won't let you type, you forgot onChange. The pattern is value={x} onChange={e => setX(e.target.value)}. Setting value without onChange makes the input a "read-only" input — the value comes from state, but nothing updates state. React will log a warning in the console. If you don't want React to control the input, use defaultValue instead of value.

📤 Form submit — preventDefault is required

Without it, the browser reloads and every state resets. Always call it first.

Complete form
function ContactForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);

function handleSubmit(event) {
event.preventDefault(); // ← REQUIRED

// Handle the data — send to server, save, validate, etc.
console.log({ name, email });
setSubmitted(true);

// Reset the form
setName('');
setEmail('');
}

if (submitted) {
return 

Thanks! We'll be in touch.

; } return (
"text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required /> "email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required />
); }

preventDefault()

Stops the browser from navigating away. Without it, submitting the form reloads the page — losing all state. This is the first line of every submit handler.

onSubmit on form

Attach the handler to the

, not the button. This way both button clicks AND Enter keypresses in inputs trigger it — standard form behavior.

type="submit"

Buttons inside a form default to type="submit" — clicking them submits. If you want a non-submit button (like a Cancel), set type="button".

Native validation

required, type="email", minLength — HTML5 validation still works. The browser blocks submit if invalid. Combine with React for both client and server validation.

Always put onSubmit on the , not the button. Users don't just click the submit button — they press Enter while focused on a text input. If you attach the handler to the button via onClick, pressing Enter does nothing (or triggers the default submit, which reloads the page). onSubmit on the form catches both paths. This is why it exists — use it.

📋 Multiple fields — one state object

Don't add a useState per field. Use one object and computed keys.

One state object, any number of fields
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: '',
});

// One handler works for all inputs
function handleChange(event) {
const { name, value } = event.target;
setForm(prev => ({ ...prev, [name]: value }));
}

function handleSubmit(event) {
event.preventDefault();
console.log(form);
// { name: 'Alice', email: 'a@b.com', password: '...' }
}

return (

"name"
value={form.name}
onChange={handleChange}
placeholder="Name"
/>
"email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
"password"
type="password"
value={form.password}
onChange={handleChange}
placeholder="Password"
/>


);
}

The magic: [name]: value

[name] is a computed property name — it uses the input's name attribute as the key. So name="email" updates form.email.

One handler, all inputs

Every input has the same onChange={handleChange}. The handler looks at event.target.name to know which field changed. Scales to 20 fields with no extra code.

Spread prev

{ ...prev, [name]: value } — copies existing values and overrides one. Never mutate directly. The updater form ensures we always have the latest state.

When to split

If fields have different behavior (validation per field, conditional rendering), consider splitting into separate states. For simple forms, one object is best.

This is the standard React form pattern. One state object, one change handler, and [name]: value computed keys. Every React tutorial, every production form, every job interview question about forms starts from this pattern. Learn it once and you can build a signup form, a login form, a checkout form, a settings form — all with the same six lines.

✅ Validation — client and server

Both are required. Client is UX; server is security.

Validation pattern
function SignupForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});

function validate(data) {
const errs = {};

if (!data.email.includes('@')) {
errs.email = 'Email must contain @';
}

if (data.password.length < 8) {
errs.password = 'Password must be at least 8 characters';
}

return errs;
}

function handleSubmit(event) {
event.preventDefault();

const errs = validate(form);
if (Object.keys(errs).length > 0) {
setErrors(errs);
return; // stop submit
}

// Now submit to server (which ALSO validates)
console.log('Valid! Sending to server:', form);
}

return (
"email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> {errors.email &&

'red' }}>{errors.email}

} "password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /> {errors.password &&

'red' }}>{errors.password}

}
); }

Client validation

Runs in the browser. Fast feedback — user sees errors immediately. The UX layer. Not a security boundary — can be bypassed.

Server validation

Runs on the server. The security boundary. Must validate everything the client sends, even if the client "already validated." Anyone can POST directly.

Errors as state

const [errors, setErrors] = useState({}) — one object, one key per field. Empty object means "no errors." Renders conditionally: {errors.email &&

{errors.email}

}.

Show errors on submit

Don't show errors on every keystroke — it's annoying. Show them on blur or submit. Or clear an error when the user starts typing. The pattern depends on the form.

Client-side validation is not security. Anyone can open DevTools, remove required, and submit. Anyone can POST directly with curl. Client validation is for user experience — to catch mistakes before they hit the server. Server-side validation is mandatory for every field, every time, regardless of what the client sent. Both are required; they're solving different problems.

🏆 Challenge

Three tasks. Each exercises a different form pattern.

Task 1 — Login form

Build a login form with:

1. Email input (type="email", required)
2. Password input (type="password", required)
3. "Remember me" checkbox
4. Submit button

Use one state object for all fields. On submit: preventDefault, log the form data, show a "Welcome back!" message. Reset the form after submission.

Task 2 — Search box with live results

Build a search box that:

1. Has a controlled input for the search query
2. Filters a predefined list of items as the user types
3. Shows a "no results" message when nothing matches
4. Clears the query when the user clicks a "clear" button
5. Highlights the matched substring in results

This is the exact pattern every search box uses. Test with a list like ["apple", "banana", "apricot", "cherry"] — typing "ap" should show apple and apricot.

Task 3 — Multi-step form with validation

Build a signup form that spans 2 steps:

Step 1: Name and email inputs. "Next" button disabled if either is empty or email doesn't contain @.

Step 2: Password and confirm password inputs. "Submit" button disabled if passwords don't match or are shorter than 8 characters. Also has a "Back" button to return to step 1 with data preserved.

State: step (1 or 2), form (object with all fields), errors. On submit: log everything and show a success message.

This is a real production pattern. Multi-step forms are everywhere — checkout, onboarding, account setup. Once you can build this, you can build any form.

⚠️ Common mistakes

The event and form bugs that catch every beginner.

1. Forgetting preventDefault

onSubmit={handleSubmit} without event.preventDefault() reloads the page. All state lost. Fix: first line of every submit handler.

2. Calling the function instead of passing it

onClick={handleClick()} calls handleClick on every render. Fix: onClick={handleClick}. Use onClick={() => handleClick()} if you need arguments.

3. value without onChange

without onChange is read-only. You can't type. React logs a warning. Fix: add onChange={e => setName(e.target.value)} or use defaultValue.

4. One useState per field

Five fields = five useState calls = five handlers. Fix: one object + one change handler with [name]: value. Scales to 20 fields.

5. onSubmit on button instead of form

onSubmit on the button means Enter key doesn't trigger submit. Fix: put onSubmit on the form. Attach onClick to the button only if needed.

6. No validation at all

Submitting empty forms is bad UX. Fix: add required for basic checks, plus JS validation for format (email, password strength).

7. Mutating form state

form.email = 'x' doesn't trigger re-render. Fix: setForm({ ...form, email: 'x' }). Immutable updates only.

8. Showing errors on every keystroke

User types one letter, gets "password too short" error. Annoying. Fix: validate on submit, or on blur, not on every change. Or clear errors when typing.

❓ Frequently asked questions

How do I handle events in React?

Use camelCase event props: onClick, onChange, onSubmit, onFocus, onBlur. Pass a function (not a call): onClick={handleClick}. The handler receives a React SyntheticEvent object with the same properties as native events.

What is a controlled component in React?

A controlled component is a form input whose value is driven by React state. The input's value prop is set from state, and onChange updates state. Every keystroke flows: user types → onChange fires → state updates → component re-renders → input shows the new value.

What is the difference between controlled and uncontrolled inputs?

Controlled inputs have their value stored in React state — every keystroke triggers a re-render. Uncontrolled inputs store their value in the DOM — you read it via a ref when needed. Controlled is the React standard. Uncontrolled is only for file inputs or performance-critical forms.

How do I prevent form submission default behavior?

Call event.preventDefault() in the onSubmit handler. Without it, the browser reloads the page — which loses all React state. The pattern: function handleSubmit(e) { e.preventDefault(); ... }. This is required in every React form that handles submission client-side.

How do I handle multiple form fields without repeating state?

Use a single state object with computed property names. const [form, setForm] = useState({ name: '', email: '' }). In the change handler: setForm({ ...form, [e.target.name]: e.target.value }). Each input needs a name attribute matching the state key.

What are the event pooling changes in React 17+?

Before React 17, event objects were pooled and could become null after the handler returned. React 17+ removed event pooling — event objects are now standard JavaScript objects. If you see event.persist() in old tutorials, it's no longer needed.

How do I handle a textarea in React?

Textareas use value and onChange, same as inputs. Note: in HTML, textarea content goes between the tags. In React, use the value prop. Same applies to select: value and onChange with options below.

How do I reset a form after submission?

For controlled forms (the standard), reset the state: setForm({ name: '', email: '' }), which clears all inputs on the next render. This ensures the inputs and state stay in sync — resetting one without the other creates a visual bug.

🔗 Continue your React path

🌐 External resources

← Back to React track
You're in the React track · Lesson 4