Components and Props

Building components, passing data down, and why props are read-only.

What you'll learn

  • Writing a component, and why the capital letter matters
  • Passing props, with defaults and destructuring
  • The children prop, and composition through it
  • Why a component must never write to its own props

A component is a function

It takes an object of properties and returns JSX. That is all:

function Badge(props) {
  return (
    <span style={{ background: "#eee", borderRadius: 4, padding: "2px 8px" }}>
      {props.label}
    </span>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, display: "flex", gap: 8 }}>
      <Badge label="new" />
      <Badge label="beta" />
    </div>
  );
}

The capital letter is not a style preference. JSX treats a lowercase tag as a built-in DOM element and a capitalised one as a component. <badge /> would render an unknown HTML element rather than call your function.

Destructuring props

Nearly all React code unpacks props in the signature, using the destructuring from the refresher. It documents what a component accepts at a glance, and gives you a place for defaults:

function Badge({ label, tone = "neutral" }) {
  const colors = {
    neutral: { background: "#eee", color: "#333" },
    success: { background: "#dcfce7", color: "#166534" },
    danger: { background: "#fee2e2", color: "#991b1b" },
  };

  return (
    <span style={{ borderRadius: 4, padding: "2px 8px", ...colors[tone] }}>
      {label}
    </span>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, display: "flex", gap: 8 }}>
      <Badge label="default" />
      <Badge label="shipped" tone="success" />
      <Badge label="failed" tone="danger" />
    </div>
  );
}

Props are not limited to strings. Numbers, booleans, objects, arrays, and functions all pass the same way — anything that is a JavaScript value.

Props are read-only

A component must never modify the props it receives. React assumes that given the same props, a component renders the same thing; writing to props breaks that assumption and the result is a component whose output depends on how many times it happened to run.

function Total({ items }) {
  items.push({ name: "tax" }); // never do this — it mutates the caller's array
  return <p>{items.length} items</p>;
}

If a value needs to change over time, it belongs in state, which is the subject of Managing State with useState. The division is worth memorising now:

  • Props come from the parent. The component reads them.
  • State belongs to the component. The component changes it.

Data flows one way — down. A child cannot reach up and change its parent's data; the parent passes a function down instead, which is covered in Lifting State Up.

children

Everything between a component's opening and closing tags arrives as the children prop. This is what makes components composable rather than configurable:

function Card({ title, children }) {
  return (
    <section style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, marginBottom: 8 }}>
      <h2 style={{ margin: "0 0 6px", fontSize: 15 }}>{title}</h2>
      {children}
    </section>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <Card title="Text">
        <p style={{ margin: 0 }}>Any markup can go here.</p>
      </Card>

      <Card title="Anything else">
        <button>Even a button</button>
      </Card>
    </div>
  );
}

Without children, Card would need a prop for every possible thing it might contain. With it, Card does not need to know. That trade — composition over configuration — comes back in Composition over Configuration.

Passing functions

A prop can be a function, which is how a child tells its parent that something happened:

function ActionButton({ label, onAction }) {
  // The child decides when; the parent decides what.
  return <button onClick={onAction}>{label}</button>;
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, display: "flex", gap: 8 }}>
      <ActionButton label="Save" onAction={() => console.log("saved")} />
      <ActionButton label="Delete" onAction={() => console.log("deleted")} />
    </div>
  );
}

ActionButton knows nothing about saving or deleting. It knows it has a label and something to call — which is why it can be reused for both.

Common mistakes

  • A lowercase component name. <badge /> is an HTML tag, not your function.
  • Mutating props. Treat them as read-only; use state for what changes.
  • Forgetting children. If a component wraps content, it needs it.
  • A prop per variation. Six booleans usually means you wanted children.

Exercise

UserCard currently hard-codes everything. Give it name and role props with a default role of "member", and let the caller pass extra content as children. Both cards below should render without changing App.

function UserCard() {
  return (
    <section style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, marginBottom: 8 }}>
      <strong>Name goes here</strong>
      <p style={{ margin: "4px 0 0", color: "#666" }}>role goes here</p>
    </section>
  );
}

export default function App() {
  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <UserCard name="Ada" role="admin">
        <p style={{ margin: "6px 0 0" }}>Joined in 1843.</p>
      </UserCard>

      <UserCard name="Grace" />
    </div>
  );
}