webdev.complete
🪝 State & Effects
⚛️React
Lesson 82 of 117
25 min

Lists, Keys, Forms

Stable keys. Controlled inputs. Common bugs.

Two things you'll do every day in React: render dynamic lists, and capture user input from forms. They both have hidden potholes. The good news: once you learn the rules, you stop falling in.

Rendering lists with map

JSX accepts arrays of elements. So to render a list, map your data:

tsx
const fruits = ["apple", "pear", "kiwi"];

return (
  <ul>
    {fruits.map((fruit) => (
      <li key={fruit}>{fruit}</li>
    ))}
  </ul>
);

Every list element needs a key prop. React uses the key to track which item is which between renders. Without keys, React falls back to comparing by index, and the moment you add, remove, or reorder items, you get subtle, infuriating bugs.

The cardinal rule: don't use the index as key

Indexes look tempting (items.map((x, i) => ...)) but they make React think a different item moved into the same slot. If your list is static and never reorders, indexes are fine. If anything moves, breaks, or has state, you get bugs:

tsx
// Items list: ["A", "B", "C"]
// Each <Row> has its own internal state (an input value)
// User types something into Row 1 (which is "B")
// Then deletes "A" from the top of the list

// With key={i}: Row 0 is now "B" but still has the OLD state (the value the user typed)
// With key={item.id}: state moves with the item, as expected.

Stable, unique IDs are the answer. Database row IDs, UUIDs, anything that follows the item around.

Keys aren't props
keydoesn't get passed to your component. If you need an ID inside, pass it as a separate prop like id={item.id}.

Conditional rendering

The four patterns you'll see most:

tsx
// 1. Ternary
{isLoading ? <Spinner /> : <Content />}

// 2. && short-circuit
{user && <UserBadge user={user} />}

// 3. Variable
let body;
if (status === "loading") body = <Spinner />;
else if (status === "error") body = <ErrorPanel />;
else body = <Content />;
return body;

// 4. Early return
if (!data) return null;
return <Content data={data} />;

The infamous && with 0 bug

This bug bites everyone exactly once. Then you remember it forever.

tsx
const cart = []; // empty cart

return (
  <div>
    {cart.length && <p>{cart.length} items</p>}
  </div>
);

// You expect: nothing renders
// You get: a literal "0" appears on the page

Because 0 && ANYTHING short-circuits to 0, and React happily renders 0 as text. Same for empty strings (though React skips those). Fix: cast to boolean.

tsx
{cart.length > 0 && <p>{cart.length} items</p>}
{Boolean(cart.length) && <p>{cart.length} items</p>}
{cart.length ? <p>{cart.length} items</p> : null}

Controlled inputs

A "controlled" input is one where React owns the value. Every keystroke fires onChange, which updates state, which re-renders the input with the new value.

tsx
const [name, setName] = useState("");

return (
  <input
    value={name}
    onChange={(e) => setName(e.target.value)}
    placeholder="Your name"
  />
);

This is the React way. It gives you live validation, format-as-you-type, and full control. The cost: one render per keystroke. For most forms you won't notice. For very large forms, React Hook Form uses uncontrolled inputs to avoid this (later lesson).

React 19 forms with action=

React 19 brought a big shift. Native HTML <form> now accepts a function as its action prop. React handles the submit event, calls your function with FormData, manages pending state, and even works without JavaScript (progressive enhancement).

tsx
function ContactForm() {
  async function submit(formData) {
    const name = formData.get("name");
    await saveContact({ name });
  }

  return (
    <form action={submit}>
      <input name="name" />
      <button type="submit">Save</button>
    </form>
  );
}

For most form needs, this is now the recommended starting point. You don't need useStatefor every field. You get FormData on submit. We'll cover this in detail in the React 19 chapter.

Try it: a working todo list

Stable keys, controlled input, conditional "empty" message done right (no 0 bug). Try editing items by clicking them.

import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Stable keys" },
    { id: 2, text: "Controlled inputs" },
  ]);
  const [text, setText] = useState("");
  const [filter, setFilter] = useState("all");

  function add() {
    if (!text.trim()) return;
    setTodos([...todos, { id: Date.now(), text, done: false }]);
    setText("");
  }

  function toggle(id) {
    setTodos(todos.map((t) => t.id === id ? { ...t, done: !t.done } : t));
  }

  function remove(id) {
    setTodos(todos.filter((t) => t.id !== id));
  }

  const visible = todos.filter((t) =>
    filter === "all" ? true : filter === "done" ? t.done : !t.done
  );

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", maxWidth: 400 }}>
      <h3>Todos</h3>
      <form onSubmit={(e) => { e.preventDefault(); add(); }}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Add a todo..."
          style={{ padding: 6, width: "70%" }}
        />{" "}
        <button>Add</button>
      </form>

      <div style={{ margin: "12px 0" }}>
        {["all", "open", "done"].map((f) => (
          <button
            key={f}
            onClick={() => setFilter(f)}
            style={{ marginRight: 6, fontWeight: filter === f ? "bold" : "normal" }}
          >
            {f}
          </button>
        ))}
      </div>

      {/* Notice: > 0 check, NOT raw .length */}
      {visible.length > 0 ? (
        <ul style={{ paddingLeft: 16 }}>
          {visible.map((t) => (
            <li key={t.id} style={{ marginBottom: 6 }}>
              <span
                onClick={() => toggle(t.id)}
                style={{
                  textDecoration: t.done ? "line-through" : "none",
                  cursor: "pointer",
                  color: t.done ? "#9ca3af" : "inherit",
                }}
              >
                {t.text}
              </span>{" "}
              <button onClick={() => remove(t.id)} style={{ fontSize: 11 }}>x</button>
            </li>
          ))}
        </ul>
      ) : (
        <p style={{ color: "#6b7280" }}>Nothing here.</p>
      )}
    </div>
  );
}

Quiz

Quiz1 / 3

Which is the safest key for a reorderable list?

Recap

  • Render lists with .map. Every element needs a stable key.
  • Avoid the index as key for anything reorderable, deletable, or stateful.
  • Beware && with numbers. Use > 0 or a ternary.
  • Controlled inputs: value from state, onChange sets state. The React way.
  • React 19 <form action={fn}> handles submit, FormData, and pending state for you. Reach for it for new forms.