React Lists, Conditional Rendering, and Controlled Forms
Introduction
This is lesson 3 of chapter 5, aligned with course 5 of the Meta Front-End Developer pathway ("React Basics", domain react-basics). Real interfaces rarely show one fixed element. They show lists of items that appear, disappear and reorder, they change what is displayed depending on conditions such as loading, errors or permissions, and they collect user input through forms.
This lesson teaches three connected skills: rendering collections with map and stable keys, showing or hiding parts of the interface with conditional rendering, and building controlled forms where React state is the single source of truth for what the user typed. The practice scenario is original: a Harbor Workshops page with a searchable list of workshops and a booking form with validation. These skills appear in nearly every React exercise, so the goal is fluency, not just recognition.
Learning objectives
- Render arrays of data with
mapand choose correct keys. - Filter, sort and transform data for display without mutating state.
- Show different content with
if, the ternary operator, and&&, and avoid the falsy-number trap. - Build controlled inputs for text, checkbox, radio, select, and textarea.
- Handle form submission, prevent the default reload, and validate input.
- Manage several fields with one state object and a generic change handler.
- Distinguish controlled from uncontrolled inputs.
- Apply accessible HTML validation and field errors to React forms.
1. Rendering lists
JSX can contain arrays of elements. The usual technique is to transform a data array into an array of elements with map.
const workshops = [
{ id: 'w1', title: 'Sourdough basics', seatsLeft: 4 },
{ id: 'w2', title: 'Wheel pottery', seatsLeft: 0 },
{ id: 'w3', title: 'Linocut printing', seatsLeft: 9 },
];
function Catalog() {
return (
<ul>
{workshops.map(w => (
<li key={w.id}>
{w.title}: {w.seatsLeft} seats
</li>
))}
</ul>
);
}Use parentheses after the arrow (an implicit return of JSX). If you use curly braces for the arrow body, you must write return explicitly; forgetting it is a frequent bug that renders nothing.
Keys
Each element returned from map needs a key prop: a string or number that identifies the item among its siblings. React uses keys to match items between renders, so it can preserve their state and DOM nodes when the list changes.
| Key source | Verdict |
|---|---|
Database id or other stable unique value (w.id) |
Best |
Generated once when the item is created (crypto.randomUUID() stored with the item) |
Good |
Array index (i) |
Acceptable only for static lists that are never reordered, filtered or edited |
Math.random() during rendering |
Never; every render creates new keys and destroys all items |
Rules:
- Keys must be unique among siblings, not globally.
- Keys are not passed as props. A child cannot read
props.key. If it needs the id, pass a separateidprop. - The key goes on the outermost element returned by map. For a fragment, use the long form
<Fragment key={id}>; the short<>cannot take a key.
Why index keys cause bugs: suppose a list of three inputs keyed by index, and you delete the first item. The remaining items shift indexes, so React believes item 0 is still item 0 and keeps the DOM (and the typed text) of the old first row, now attached to different data.
Extracting a component
When list items have their own logic, extract them and pass the key at the call site.
function Catalog({ items }) {
return (
<ul>
{items.map(w => (
<WorkshopRow key={w.id} workshop={w} />
))}
</ul>
);
}The key stays in Catalog (on the element created by map), not inside WorkshopRow.
Filtering and sorting
filter and map return new arrays; their callbacks must also avoid mutating the original items. sort and reverse mutate their array, so copy first.
const available = workshops.filter(w => w.seatsLeft > 0);
const byTitle = [...workshops].sort((a, b) => a.title.localeCompare(b.title));Use immutable React state updates for the underlying collection and derive the filtered list during rendering rather than keeping a second copy.
Empty lists
Always plan for an empty array: it renders nothing, which can look broken. Show a message instead.
{available.length === 0 ? <p>No workshops match your search.</p> : (
<ul>{available.map(w => <li key={w.id}>{w.title}</li>)}</ul>
)}2. Conditional rendering
JSX is JavaScript, so you choose what to show with ordinary JavaScript. Statements such as if work outside the JSX; inside the braces you may use only expressions.
Early return with if
function SeatBadge({ seatsLeft }) {
if (seatsLeft === 0) {
return <span className="badge badge--full">Sold out</span>;
}
return <span className="badge">{seatsLeft} {seatsLeft === 1 ? 'seat' : 'seats'} left</span>;
}A component may return null to render nothing.
Ternary operator
Use it to choose between two pieces of JSX inline.
<p>{seatsLeft === 0 ? 'Sold out' : `${seatsLeft} ${seatsLeft === 1 ? 'seat' : 'seats'} left`}</p>Logical AND
Use && to render something or nothing.
{isMember && <p>Members get 10% off.</p>}The falsy-number trap. && returns the left value if it is falsy. For false, null, undefined and '' React renders nothing, but for the number 0 it renders the digit 0.
{messages.length && <Inbox messages={messages} />} // shows "0" when empty
{messages.length > 0 && <Inbox messages={messages} />} // correctChoosing the technique
| Need | Prefer |
|---|---|
| Two branches, both short | Ternary |
| Show or hide one block | && with a real boolean |
| Many branches or long JSX | if with early returns, or a variable holding JSX |
| Large, independent states (loading, error, success) | Separate small components |
Typical status pattern
function WorkshopList({ status, workshops, error }) {
if (status === 'loading') return <p>Loading workshops...</p>;
if (status === 'error') return <p role="alert">Could not load: {error}</p>;
if (workshops.length === 0) return <p>No workshops yet.</p>;
return <ul>{workshops.map(w => <li key={w.id}>{w.title}</li>)}</ul>;
}Handling loading, error, and empty states is part of finishing a feature. Data fetching itself is covered in chapter 6.
Hiding versus not rendering
Removing a child component from the tree discards that child's state. A component that itself returns null remains mounted and keeps its own state, while omitted descendants are removed. Hiding mounted content with CSS or hidden preserves its state; it may remove it from the accessibility tree, so manage focus appropriately. Choose deliberately: a tab panel that should remember typed text may be hidden, while a modal that should reset when closed should not be rendered.
3. Controlled inputs
In plain HTML the input keeps its own value. React can leave a form control uncontrolled, or control it when the interface needs to react to edits. In the controlled pattern, its value comes from state and each change updates state. That input is controlled.
import { useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
return (
<label>
Search workshops
<input
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
/>
</label>
);
}The data flow is a loop: the user types, onChange fires, state updates, React re-renders, and the input shows the new value. If you pass value without onChange, the input becomes read-only and React warns you. If you pass value={undefined} first and a string later, React warns about switching from uncontrolled to controlled; always initialize with '', not undefined or null.
Different input types
| Element | State property | Handler reads |
|---|---|---|
| Text, email, number, search, textarea | value |
event.target.value |
| Select | value |
event.target.value |
| Checkbox | checked |
event.target.checked |
| Radio group | checked on each (checked={plan === 'basic'}) |
event.target.value |
Note: event.target.value for type="number" is a string. Keep the editable value as a string so it can be empty. Before arithmetic, reject empty input and validate the converted number: Number('') is 0, invalid text can become NaN, and fractional values need an integer check when counting seats.
In React, <textarea> takes a value prop (not child text), and <select> takes value on the select itself (not selected on an option).
<select value={level} onChange={e => setLevel(e.target.value)}>
<option value="beginner">Beginner</option>
<option value="advanced">Advanced</option>
</select>
<label>
<input type="checkbox" checked={newsletter}
onChange={e => setNewsletter(e.target.checked)} />
Send me the newsletter
</label>Controlled versus uncontrolled
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state | The DOM |
| Reading the value | From state at any time | With a ref or FormData at submit |
| Live validation, formatting, dependent fields | Driven by state and handlers | Native validation works; custom feedback can read the DOM |
| Typing cost | State updates cause React work; keep it local | Native editing alone need not trigger a React render |
| Good for | Most forms in this course | Simple forms, file inputs |
File selection is browser-controlled: read files from a file input rather than trying to assign a nonempty filename with value. A script can clear the control; it cannot choose a user's local file.
4. Forms with several fields
Create one state object and a handler that uses the input name.
import { useState, useEffect, useRef } from 'react';
export default function BookingForm({ onBook }) {
const [form, setForm] = useState({
name: '', email: '', seats: '1', level: 'beginner', terms: false,
});
const [errors, setErrors] = useState({});
const focusTarget = useRef(null);
useEffect(() => {
if (focusTarget.current) {
document.getElementById(focusTarget.current)?.focus();
focusTarget.current = null;
}
}, [errors]);
function handleChange(event) {
const { name, value, type, checked } = event.target;
setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
setErrors(prev => {
const next = { ...prev };
delete next[name]; // clear the old message while this field is edited
return next;
});
}
function validate(values, element) {
const found = {};
const seats = Number(values.seats);
if (!values.name.trim()) found.name = 'Enter your name.';
if (!element.elements.namedItem('email').validity.valid) {
found.email = 'Enter an email address such as name@example.com.';
}
if (!values.seats.trim() || !Number.isInteger(seats) || seats < 1 || seats > 6) {
found.seats = 'Choose a whole number from 1 to 6.';
}
if (!['beginner', 'advanced'].includes(values.level)) found.level = 'Choose a listed level.';
if (!values.terms) found.terms = 'Accept the terms to continue.';
return found;
}
function handleSubmit(event) {
event.preventDefault();
const element = event.currentTarget;
const found = validate(form, element);
setErrors(found);
const first = Object.keys(found)[0];
if (first) {
focusTarget.current = `booking-${first}`;
return;
}
onBook({ ...form, name: form.name.trim(), email: form.email.trim(), seats: Number(form.seats) });
}
// This example renders one form. Multiple instances need unique IDs.
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="booking-name">Full name</label>
<input id="booking-name" name="name" autoComplete="name" required
value={form.name} onChange={handleChange}
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'booking-name-error' : undefined} />
{errors.name && <p id="booking-name-error">{errors.name}</p>}
<label htmlFor="booking-email">Email address</label>
<input id="booking-email" name="email" type="email" autoComplete="email" required
value={form.email} onChange={handleChange}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'booking-email-error' : undefined} />
{errors.email && <p id="booking-email-error">{errors.email}</p>}
<label htmlFor="booking-seats">Seats (1 to 6)</label>
<input id="booking-seats" name="seats" type="number" min="1" max="6" step="1" required
value={form.seats} onChange={handleChange}
aria-invalid={Boolean(errors.seats)}
aria-describedby={errors.seats ? 'booking-seats-error' : undefined} />
{errors.seats && <p id="booking-seats-error">{errors.seats}</p>}
<label htmlFor="booking-level">Level</label>
<select id="booking-level" name="level" value={form.level} onChange={handleChange}
aria-invalid={Boolean(errors.level)}
aria-describedby={errors.level ? 'booking-level-error' : undefined}>
<option value="beginner">Beginner</option>
<option value="advanced">Advanced</option>
</select>
{errors.level && <p id="booking-level-error">{errors.level}</p>}
<label htmlFor="booking-terms">
<input id="booking-terms" name="terms" type="checkbox" required
checked={form.terms} onChange={handleChange}
aria-invalid={Boolean(errors.terms)}
aria-describedby={errors.terms ? 'booking-terms-error' : undefined} />
I accept the terms
</label>
{errors.terms && <p id="booking-terms-error">{errors.terms}</p>}
<button type="submit">Book</button>
</form>
);
}The small ref/effect pair defers error focus until React has committed the associated message to the DOM. Chapter 6 explains these Hooks in detail; no focus is moved while an error is merely being cleared during editing.
Key points:
[name]: valueis a computed property name that updates the field whosenamematches the input.- The functional update
prev => ({ ...prev, ... })is safe and does not mutate. onSubmitis placed on the form, not on the button, so pressing Enter also works.event.preventDefault()stops the page reload.noValidateskips automatic interactive validation on submit; it does not disable validity properties or checkValidity(). This example explicitly checks the required email through native validity and validates every other field. Without noValidate, a native-invalid form normally does not dispatch submit. Repeat all checks on the server before accepting a real booking.htmlForconnects the label to the inputid(the React name forfor).- Errors remain associated through aria-describedby. Focus moves to the first invalid control; verify announcements with a screen reader. Live regions can help but do not guarantee speech and can duplicate focus announcements. The example clears the edited field’s old error and rechecks all fields on resubmission.
Resetting the form
After success, call setForm(initialForm) where initialForm is a constant defined outside the component. Alternatively change the key of the state-owning component, such as <BookingForm key={bookingId} />. Changing only the DOM <form> key does not reset state declared in its parent component.
When to validate
| Moment | Pros | Cons |
|---|---|---|
| On submit | Simple, not annoying | Errors appear late |
| On blur (leaving the field) | Immediate feedback after the user finishes | Needs a touched record |
| On every change | Instant | Shows errors before the user finishes typing |
A good default is: validate on submit first, then revalidate on change for fields that already have an error.
5. Combining list, condition and form
A common feature is a filterable list plus a form that adds to the list. State shared by the list and the form lives in their parent.
function WorkshopsPage() {
const [items, setItems] = useState(initialWorkshops);
const [query, setQuery] = useState('');
const [onlyAvailable, setOnlyAvailable] = useState(false);
const visible = items
.filter(w => w.title.toLowerCase().includes(query.trim().toLowerCase()))
.filter(w => !onlyAvailable || w.seatsLeft > 0);
function addWorkshop(title, seats) {
const item = { id: crypto.randomUUID(), title, seatsLeft: seats };
setItems(prev => [...prev, item]);
}
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)}
aria-label="Search workshops" />
<label>
<input type="checkbox" checked={onlyAvailable}
onChange={e => setOnlyAvailable(e.target.checked)} />
Only show available
</label>
{visible.length === 0
? <p>No workshops match.</p>
: <ul>{visible.map(w => <WorkshopRow key={w.id} workshop={w} />)}</ul>}
<NewWorkshopForm onAdd={addWorkshop} />
</>
);
}Three state variables, one derived list, and no duplicated data. This sketch assumes initialWorkshops, WorkshopRow, and NewWorkshopForm are supplied; generate IDs before the updater so it stays pure.
Worked example: a searchable list with a sold-out filter
Problem. Given the workshops array from section 1, show a search box and an "Only available" checkbox. Typing "po" must show only matching titles; with the checkbox on, sold-out items are hidden; when nothing matches, show a message. Selecting a row's "Remove" button deletes it.
Step 1: state. query (string), onlyAvailable (boolean), items (array, because removal changes it). The visible list is derived.
Step 2: derive the list.
const visible = items.filter(w =>
w.title.toLowerCase().includes(query.toLowerCase()) &&
(!onlyAvailable || w.seatsLeft > 0)
);Step 3: render with empty state and keys. Use w.id as the key.
Step 4: remove an item.
function handleRemove(id) {
setItems(prev => prev.filter(w => w.id !== id));
}Step 5: trace. Query "po" with the checkbox off: only "Wheel pottery" matches. Turn the checkbox on: its seatsLeft is 0, so nothing remains and the "No workshops match." message appears. Clear the query and keep the checkbox on: Sourdough (4) and Linocut (9) appear. Click Remove on Linocut: filter returns a new array without it and the list updates.
Why not the alternatives?
- Using the index as the key would, after removing the first row, reuse the DOM of the removed row for another item; this matters if rows contain inputs.
- Keeping
visiblein state would need updating in three places (search, checkbox, removal). - Using
items.splice(index, 1)mutates state, so the render may be skipped. - Writing
{visible.length && ...}would show0for an empty list.
Common pitfalls
- Missing or unstable keys, especially
Math.random()or the index in a dynamic list. &&with numbers, which prints0.valuewithoutonChange, creating a read-only field.- Initializing a controlled input with
undefined, then switching to controlled. - Forgetting
preventDefaultin a submit handler, so the page reloads and state is lost. - Using
onClickon the submit button only, which skips submission through the Enter key. - Treating
event.target.valueas a number for numeric inputs. - Mutating arrays with
push,splice, orsorton state. - Defining validation only on the client, trusting it for security.
- Using placeholder text as the only label.
Study tip: For a question about a list that "loses text typed in inputs after sorting or deleting," the answer is stable unique keys, not index keys. For a checkbox, read
checked; for text, readvalue. Remember that conditional rendering with&&can display0.
Mini case study: the newsletter signup that reloads
Situation. A signup prototype should preview the submitted email locally. Instead the page reloads. The field is editable, but React never records its text, so a brief confirmation has no email value.
function Signup() {
const [email, setEmail] = useState();
const [done, setDone] = useState(false);
return (
<form>
<input type="email" value={email} />
<button onClick={() => setDone(true)}>Join</button>
{done && <p>Thanks, {email}!</p>}
</form>
);
}Questions. (1) Why does React miss the typed value? (2) Why does the page navigate? (3) What else is wrong? (4) Fix it.
Solution. email stays undefined, so this exact input is uncontrolled and editable; no setter is ever called. It does not become read-only after one character, nor switch modes on its own. A defined string value without onChange would instead lock editing. The submit button's click handler is not a form submit handler and does not prevent document navigation; it can also announce success before validation. The input has no associated label.
function Signup() {
const [email, setEmail] = useState('');
const [submittedEmail, setSubmittedEmail] = useState(null);
function handleSubmit(event) {
event.preventDefault();
setSubmittedEmail(email.trim());
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="signup-email">Email address</label>
<input id="signup-email" name="email" type="email" required
autoComplete="email" value={email}
onChange={e => {
setEmail(e.target.value);
setSubmittedEmail(null);
}} />
<button type="submit">Preview signup</button>
<p role="status">
{submittedEmail ? `Signup preview for ${submittedEmail}.` : ''}
</p>
</form>
);
}Native required/email checks run before submit, so empty or malformed values do not reach the handler. A valid submit updates the local preview without navigation; editing clears it. No newsletter is actually registered: a real application must await a successful server response before confirming subscription.
Key terms
- Key: a stable identifier that lets React match list items between renders.
- Conditional rendering: choosing what to display with JavaScript expressions.
- Controlled input: a form control whose value is held in React state.
- Uncontrolled input: a control whose value lives in the DOM.
- Computed property name:
[name]: valuesyntax for a dynamic object key. - Derived data: a value computed from state during rendering.
- Early return: returning from a component before the main JSX.
- Falsy-number trap:
0 && <X />rendering0. - Client-side validation: checks in the browser that improve usability but are not secure.
aria-describedby: attribute linking a control to its help or error text.
Summary checklist
- I render lists with
mapand stable unique keys. - I copy arrays before sorting and never mutate state.
- I handle empty, loading, and error states.
- I choose between
if, ternary, and&&and avoid the0trap. - I build controlled inputs with
valueorcheckedplusonChange. - I use one state object and a generic handler for several fields.
- I prevent the default submit and validate before sending.
- I associate labels and error messages for accessibility.
- I know when an input should be uncontrolled.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 5: React Basics (opens in a new tab)
- React: Rendering Lists (opens in a new tab)
- React: Conditional Rendering (opens in a new tab)
- React:
<input>reference (opens in a new tab) - React:
<form>reference (opens in a new tab) - React: Choosing the State Structure (opens in a new tab)
- W3C WAI: accessible form notifications (opens in a new tab)
- React: useEffect (opens in a new tab)
