Skip to content

React Components, State, and Navigation

Step 21 of 45

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

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

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]: 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 turns 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.
  • htmlFor connects the label to the input id (the React name for for).
  • Error messages are linked with aria-describedby, and role="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 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.

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, read value. Remember that conditional rendering with && can display 0.

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.

  1. The input has value but no onChange, so it is read-only. Also useState() starts as undefined, which makes the field uncontrolled at first and then React warns when it receives a string.
  2. The button is type="submit" by default and the form has no onSubmit with preventDefault, so the browser submits and reloads.
  3. The click handler sets done before the form is validated and even for an empty email; the confirmation message would print "Thanks, !".
  4. 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]: 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