Skip to content

React Components, State, Forms, and Routing

Step 21 of 45

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

  1. Render arrays of data with map and choose correct keys.
  2. Filter, sort and transform data for display without mutating state.
  3. Show different content with if, the ternary operator, and &&, and avoid the falsy-number trap.
  4. Build controlled inputs for text, checkbox, radio, select, and textarea.
  5. Handle form submission, prevent the default reload, and validate input.
  6. Manage several fields with one state object and a generic change handler.
  7. Distinguish controlled from uncontrolled inputs.
  8. 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.

JSX
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 separate id prop.
  • 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.

JSX
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.

JSX
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.

JSX
{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

JSX
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.

JSX
<p>{seatsLeft === 0 ? 'Sold out' : `${seatsLeft} ${seatsLeft === 1 ? 'seat' : 'seats'} left`}</p>

Logical AND

Use && to render something or nothing.

JSX
{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.

JSX
{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

JSX
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.

JSX
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).

JSX
<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.

JSX
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]: value is a computed property name that updates the field whose name matches the input.
  • The functional update prev => ({ ...prev, ... }) is safe and does not mutate.
  • onSubmit is placed on the form, not on the button, so pressing Enter also works. event.preventDefault() stops the page reload.
  • noValidate skips 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.
  • htmlFor connects the label to the input id (the React name for for).
  • 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.

JSX
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.

JSX
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.

JSX
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 visible in 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 show 0 for an empty list.

Common pitfalls

  • Missing or unstable keys, especially Math.random() or the index in a dynamic list.
  • && with numbers, which prints 0.
  • value without onChange, creating a read-only field.
  • Initializing a controlled input with undefined, then switching to controlled.
  • Forgetting preventDefault in a submit handler, so the page reloads and state is lost.
  • Using onClick on the submit button only, which skips submission through the Enter key.
  • Treating event.target.value as a number for numeric inputs.
  • Mutating arrays with push, splice, or sort on 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, read value. Remember that conditional rendering with && can display 0.

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.

JSX
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.

JSX
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]: value syntax 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 /> rendering 0.
  • 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 map and 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 the 0 trap.
  • I build controlled inputs with value or checked plus onChange.
  • 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