Lists, Conditions, and 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.
- Provide accessible labels and error messages in 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 and do not change the original. 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));
Do this during rendering as derived data. Do not keep a second state for the filtered list.
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} 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} 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} />} // correct
Choosing 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
Not rendering removes the element and destroys its state. Hiding it with CSS (hidden attribute or a class) keeps it in the DOM and preserves state. 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. In React you usually make the component state the source of truth: the input's 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. Convert with Number(...) before arithmetic.
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 | Easy | Hard |
| Typing cost | A render per keystroke | None |
| Good for | Most forms in this course | Simple forms, file inputs |
A file input (type="file") is always uncontrolled because its value can only be set by the user.
4. Forms with several fields
Create one state object and a handler that uses the input name.
function BookingForm({ onBook }) {
const [form, setForm] = useState({
name: '',
email: '',
seats: 1,
level: 'beginner',
terms: false,
});
const [errors, setErrors] = useState({});
function handleChange(event) {
const { name, value, type, checked } = event.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
function validate(values) {
const found = {};
if (values.name.trim().length < 2) found.name = 'Enter your full name.';
if (!/^\S+@\S+\.\S+$/.test(values.email)) found.email = 'Enter a valid email.';
if (Number(values.seats) < 1 || Number(values.seats) > 6) {
found.seats = 'Choose between 1 and 6 seats.';
}
if (!values.terms) found.terms = 'You must accept the terms.';
return found;
}
function handleSubmit(event) {
event.preventDefault();
const found = validate(form);
setErrors(found);
if (Object.keys(found).length === 0) {
onBook({ ...form, seats: Number(form.seats) });
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Full name</label>
<input id="name" name="name" value={form.name} onChange={handleChange}
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'name-error' : undefined} />
{errors.name && <p id="name-error" role="alert">{errors.name}</p>}
</div>
{/* email, seats, level, terms follow the same pattern */}
<button type="submit">Book</button>
</form>
);
}
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.noValidateturns off the browser's built-in bubbles so you can show your own consistent messages. You may also keep native validation (required,type="email",min,max) as a first line of defense. Client-side checks improve usability but never replace server validation.htmlForconnects the label to the inputid(the React name forfor).- Error messages are linked with
aria-describedby, androle="alert"makes assistive technology announce them.
Resetting the form
After success, call setForm(initialForm) where initialForm is a constant defined outside the component. Alternatively give the form a new key to remount it with fresh state.
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) {
setItems(prev => [...prev, { id: crypto.randomUUID(), title, seatsLeft: seats }]);
}
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.
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.
Exam 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 form should show "Thanks, {email}!" after submission. Instead the page reloads, and the input cannot be edited after the first character is typed.
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 can't the user edit the input? (2) Why does the page reload? (3) What other defect exists? (4) Fix it.
Solution.
- The input has
valuebut noonChange, so it is read-only. AlsouseState()starts asundefined, which makes the field uncontrolled at first and then React warns when it receives a string. - The button is
type="submit"by default and the form has noonSubmitwithpreventDefault, so the browser submits and reloads. - The click handler sets
donebefore the form is validated and even for an empty email; the confirmation message would print "Thanks, !". - Fixed:
function Signup() {
const [email, setEmail] = useState('');
const [done, setDone] = useState(false);
function handleSubmit(event) {
event.preventDefault();
if (email.trim() !== '') setDone(true);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email}
onChange={e => setEmail(e.target.value)} />
<button type="submit">Join</button>
{done && <p>Thanks, {email}!</p>}
</form>
);
}
Check: typing works, pressing Enter or clicking Join shows the message without a reload, and an empty field shows nothing.
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)
