Typing Components & Props

Expert · 18 min read · ▶ live playground · ✦ checkpoint

React does not replace the TypeScript ideas you just used in the browser section. It gives you a component model for the same loop: typed data comes in, UI is rendered from that data, and invalid states should be hard to express.

This is a tool-shaped React lesson. The TSX examples are read-only reference code for a real React project; the live playground later uses plain TypeScript to check the prop model without JSX.

Props are the component boundary

A React component receives one object: its props. That object is the boundary between the parent and the child, so it deserves the same care as a function parameter or an API response type.

Read-only reference code:

type ProductCardProps = {
  name: string;
  priceCents: number;
  inStock: boolean;
};
 
export function ProductCard({ name, priceCents, inStock }: ProductCardProps) {
  const price = "$" + (priceCents / 100).toFixed(2);
  const status = inStock ? "In stock" : "Sold out";
 
  return (
    <article aria-label={name}>
      <h2>{name}</h2>
      <p>{price}</p>
      <p>{status}</p>
    </article>
  );
}

You will often see this written as interface ProductCardProps in React codebases. That is valid TypeScript: props are object shapes, and interfaces describe object shapes well.

This course keeps the Section 5.1 default: use type for app data and component props unless you have a specific reason to use interface, such as matching a local convention around extension. The reason shows up in this lesson: prop unions are common in components, and type handles object shapes and unions with one consistent habit.

Notice what is annotated: the props parameter. The component's return value is inferred from the JSX it returns. You can annotate returns when an exported API needs that extra explicitness, but beginners often get more value from typing the input boundary and letting the component body stay readable.

Read the props type from the parent's point of view too. ProductCard is not asking for "some product object"; it asks for exactly the display data it needs: a name, a price in cents, and a stock flag. That keeps the component reusable. A search page, a cart, and an admin dashboard might all have different product models, but each parent can still pass these three values. Props should describe the component's public surface, not leak a whole database row into the child because it happens to contain the fields you need today.

This is also where excess and missing property checks help during authoring. If the parent forgets priceCents, the call site gets a diagnostic. If the parent passes price as preformatted text, the mismatch forces a decision: should the card own formatting, or should the parent? The type does not make that design choice for you, but it makes the choice visible.

Children are just another prop

Composition is the reason React components stay small. A parent can pass content into a child through children, and TypeScript should say what kind of content the child accepts.

For ordinary composition slots, use ReactNode: it covers the renderable values React components normally receive, including elements, strings, numbers, fragments, arrays of nodes, null, and undefined.

Read-only reference code:

import type { ReactNode } from "react";
 
type SettingsSectionProps = {
  title: string;
  description?: string;
  actions?: ReactNode;
  children: ReactNode;
};
 
export function SettingsSection({
  title,
  description,
  actions,
  children,
}: SettingsSectionProps) {
  return (
    <section aria-labelledby={title + "-heading"}>
      <header>
        <div>
          <h2 id={title + "-heading"}>{title}</h2>
          {description === undefined ? null : <p>{description}</p>}
        </div>
        {actions}
      </header>
      <div>{children}</div>
    </section>
  );
}

children is not magic to TypeScript. It is a property on the props object, so make it required when the component needs content and optional when empty content is meaningful. Extra named slots, such as actions, are also just props. Giving them ReactNode keeps composition flexible without pretending they are strings or a single element.

There is a useful naming rule here: children is best for the main body of a component, while named slots are better for secondary regions. SettingsSection can require children because an empty settings section would be strange. It can make actions optional because not every section has a button or menu. If the component needs a very specific child contract, such as a render function or a single typed component, say that explicitly instead of reaching for ReactNode. Most layout and panel components, though, want ordinary renderable content.

Optional props and defaults

Optional props are for values a parent may genuinely omit. Defaults belong at the destructuring boundary, where TypeScript can narrow the value for the component body.

Read-only reference code:

import type { ReactNode } from "react";
 
type EmptyStateProps = {
  title: string;
  message?: string;
  tone?: "neutral" | "warning";
  action?: ReactNode;
};
 
export function EmptyState({
  title,
  message = "There is nothing to review right now.",
  tone = "neutral",
  action,
}: EmptyStateProps) {
  return (
    <section data-tone={tone}>
      <h2>{title}</h2>
      <p>{message}</p>
      {action}
    </section>
  );
}

Inside EmptyState, message is a string, not string | undefined, because the default handles the omitted case. tone narrows the component to two allowed display modes. A parent cannot pass "danger" unless you add that literal to the type.

Default values should be boring. They answer "what should happen when the parent says nothing?" They should not hide validation, network work, or a surprising state change. The parent still owns the decision to show the component; the default only fills in safe display details.

Be careful with optional props that secretly depend on each other. actionLabel?: string and onAction?: () => void looks flexible, but it permits half-configured buttons: a label without a handler, or a handler with no label. When props move together, model the relationship.

One smell is a component body full of checks like "if this prop exists, then maybe that other prop exists too." Sometimes that is normal defensive rendering. Often it means the type is too loose. Tightening the prop model makes the render branch simpler because TypeScript can narrow from a deliberate mode instead of making every property optional everywhere.

Modes deserve prop unions

Discriminated unions are one of the best TypeScript patterns in React because components often have modes: loading versus loaded, link versus button, info versus action. A mode prop can choose the branch, and each branch can require exactly the props it needs.

The narrowing works without JSX:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

The same shape becomes a React component when you put JSX inside the branches:

import type { ReactNode } from "react";
 
type InlineNoticeProps =
  | {
      mode: "info";
      title: string;
      children: ReactNode;
    }
  | {
      mode: "action";
      title: string;
      children: ReactNode;
      actionLabel: string;
      onAction: () => void;
    };
 
export function InlineNotice(props: InlineNoticeProps) {
  return (
    <aside data-mode={props.mode}>
      <h2>{props.title}</h2>
      <div>{props.children}</div>
      {props.mode === "action" ? (
        <button type="button" onClick={props.onAction}>
          {props.actionLabel}
        </button>
      ) : null}
    </aside>
  );
}

In the "action" branch, props.onAction and props.actionLabel exist. In the "info" branch, they do not. That is better than a component that accepts every prop all the time and hopes the render logic remembers which combinations are legal.

The discriminant does double duty. It is runtime data the component can render from, and it is compile-time evidence the checker can narrow with. That is why mode works better than several unrelated booleans like isAction, hasButton, and isWarning. Booleans multiply combinations; a literal union names the allowed states.

This is the bridge from the no-framework app: state still chooses what the screen should show. React components make each piece of that screen a typed boundary.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion