On this page

Props and Children: Flexible Component Design

14 min read TextCh. 1 — React Fundamentals

What Are Props?

Props (short for "properties") are the mechanism React uses to pass data from a parent component to its children. Props flow downward through the component tree — from parent to child — and are read-only inside the receiving component. A component should never modify its own props.

// Parent passes props
<UserCard name="Alice" role="admin" score={1500} />

// Child receives props
function UserCard({ name, role, score }: UserCardProps) {
  return <div>{name} — {role} — {score} pts</div>;
}

Typing Props with TypeScript

TypeScript makes prop definitions explicit and self-documenting. The standard approach is to define an interface for your props:

interface ButtonProps {
  label: string;           // required string
  variant: 'primary' | 'secondary' | 'ghost'; // union type
  size?: 'sm' | 'md' | 'lg';  // optional
  disabled?: boolean;
  onClick?: () => void;
}

function Button({
  label,
  variant,
  size = 'md',
  disabled = false,
  onClick,
}: ButtonProps) {
  return (
    <button
      type="button"
      className={`btn btn--${variant} btn--${size}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}

TypeScript will now give you autocomplete and type errors when you use <Button /> incorrectly. For example, passing variant="invalid" will produce a compile-time error.

Required vs. Optional Props

  • Props without ? are required — TypeScript will error if they are omitted.
  • Props with ? are optional — you should always handle the case where they are undefined.
interface AlertProps {
  message: string;         // required
  type?: 'info' | 'warning' | 'error'; // optional, defaults to 'info'
  onDismiss?: () => void;  // optional callback
}

function Alert({ message, type = 'info', onDismiss }: AlertProps) {
  return (
    <div
      role="alert"
      className={`alert alert--${type}`}
    >
      <p>{message}</p>
      {onDismiss && (
        <button type="button" onClick={onDismiss} aria-label="Dismiss">
          ×
        </button>
      )}
    </div>
  );
}

Passing Functions as Props

Props can be any JavaScript value — including functions. This is how child components communicate back to their parents (the "lifting state up" pattern):

interface NumberInputProps {
  value: number;
  min?: number;
  max?: number;
  onChange: (newValue: number) => void;
}

function NumberInput({ value, min = 0, max = 100, onChange }: NumberInputProps) {
  function handleDecrement() {
    if (value > min) onChange(value - 1);
  }

  function handleIncrement() {
    if (value < max) onChange(value + 1);
  }

  return (
    <div className="number-input">
      <button type="button" onClick={handleDecrement} aria-label="Decrease">−</button>
      <output>{value}</output>
      <button type="button" onClick={handleIncrement} aria-label="Increase">+</button>
    </div>
  );
}

// Parent manages state
function QuantitySelector() {
  const [quantity, setQuantity] = React.useState(1);

  return (
    <div>
      <NumberInput value={quantity} min={1} max={99} onChange={setQuantity} />
      <p>Total: {quantity} items selected</p>
    </div>
  );
}

The `children` Prop

The children prop is a special prop that receives whatever JSX is placed between the opening and closing tags of a component. This is the primary mechanism for composition in React.

import { type ReactNode } from 'react';

interface PanelProps {
  heading: string;
  children: ReactNode;
}

function Panel({ heading, children }: PanelProps) {
  return (
    <aside className="panel">
      <h3 className="panel__heading">{heading}</h3>
      <div className="panel__content">{children}</div>
    </aside>
  );
}

// Usage — anything can go inside Panel
function Sidebar() {
  return (
    <Panel heading="Navigation">
      <nav>
        <a href="/dashboard">Dashboard</a>
        <a href="/settings">Settings</a>
      </nav>
    </Panel>
  );
}

Slot-Based Composition with Named Props

When a component needs multiple slots of content (not just one children), use named ReactNode props:

interface LayoutProps {
  header: ReactNode;
  sidebar: ReactNode;
  main: ReactNode;
  footer?: ReactNode;
}

function Layout({ header, sidebar, main, footer }: LayoutProps) {
  return (
    <div className="layout">
      <header className="layout__header">{header}</header>
      <div className="layout__body">
        <aside className="layout__sidebar">{sidebar}</aside>
        <main className="layout__main">{main}</main>
      </div>
      {footer && <footer className="layout__footer">{footer}</footer>}
    </div>
  );
}

// Usage
function App() {
  return (
    <Layout
      header={<NavBar />}
      sidebar={<TreeMenu />}
      main={<ArticleContent />}
      footer={<SiteFooter />}
    />
  );
}

This pattern is sometimes called the "render props via JSX" pattern and gives the parent full control over each region of the layout.

Spreading Props

When you wrap an existing HTML element and want to forward all its native attributes, you can spread props:

import { type ComponentPropsWithoutRef } from 'react';

// Extend native button attributes + add custom props
interface IconButtonProps extends ComponentPropsWithoutRef<'button'> {
  icon: string;
  label: string;
}

function IconButton({ icon, label, className = '', ...rest }: IconButtonProps) {
  return (
    <button
      type="button"
      className={`icon-btn ${className}`}
      aria-label={label}
      {...rest}
    >
      <span aria-hidden="true">{icon}</span>
    </button>
  );
}

// All native button props work automatically
<IconButton
  icon="🗑"
  label="Delete item"
  onClick={handleDelete}
  disabled={isDeleting}
  data-testid="delete-btn"
/>

Using ComponentPropsWithoutRef<'button'> from React's type definitions ensures your wrapper component exposes the full API of the native element it wraps.

Component Composition Patterns

Container / Presenter Pattern

Separate the logic (container) from the visual representation (presenter):

// Presenter — pure display, no side effects
function UserListView({ users, onSelect }: UserListViewProps) {
  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>
          <button type="button" onClick={() => onSelect(u.id)}>
            {u.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

// Container — fetches data, manages state
function UserListContainer() {
  const [users, setUsers] = React.useState<User[]>([]);

  React.useEffect(() => {
    fetch('/api/users').then((r) => r.json()).then(setUsers);
  }, []);

  function handleSelect(id: string) {
    console.log('Selected user:', id);
  }

  return <UserListView users={users} onSelect={handleSelect} />;
}

Compound Components

Compound components share implicit state through a common parent:

import { createContext, useContext, useState, type ReactNode } from 'react';

interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }: { children: ReactNode }) {
  return <div role="tablist" className="tab-list">{children}</div>;
}

function Tab({ id, label }: { id: string; label: string }) {
  const ctx = useContext(TabsContext)!;
  return (
    <button
      type="button"
      role="tab"
      aria-selected={ctx.activeTab === id}
      onClick={() => ctx.setActiveTab(id)}
    >
      {label}
    </button>
  );
}

function TabPanel({ id, children }: { id: string; children: ReactNode }) {
  const ctx = useContext(TabsContext)!;
  if (ctx.activeTab !== id) return null;
  return <div role="tabpanel">{children}</div>;
}

Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// Elegant usage
function App() {
  return (
    <Tabs defaultTab="overview">
      <Tabs.List>
        <Tabs.Tab id="overview" label="Overview" />
        <Tabs.Tab id="details" label="Details" />
      </Tabs.List>
      <Tabs.Panel id="overview"><p>Overview content</p></Tabs.Panel>
      <Tabs.Panel id="details"><p>Detailed content</p></Tabs.Panel>
    </Tabs>
  );
}

Prop Types Best Practices

  1. Always define an interface for component props — never use inline type annotations for complex shapes.
  2. Export prop interfaces alongside the component so consumers can extend them.
  3. Use union types for variants: 'sm' | 'md' | 'lg' instead of string.
  4. Prefer ReactNode over JSX.Element for the children prop.
  5. Use ComponentPropsWithoutRef<'element'> when wrapping native HTML elements.

In the next lesson, you will add interactivity with useState and learn how React handles user events.

Prefer ReactNode over JSX.Element for children
Use `ReactNode` from React for the `children` prop type. It accepts strings, numbers, JSX elements, arrays, fragments, and null — making your component maximally flexible. `JSX.Element` is more restrictive and only accepts a single JSX element.
Prop destructuring with defaults
You can set default values directly in the destructuring pattern: `function Button({ variant = 'primary', size = 'md' }: ButtonProps)`. This is cleaner than using `props.variant ?? 'primary'` inside the function body.
Avoid prop drilling more than two levels deep
If you find yourself passing the same prop through three or more component levels just to reach a deeply nested child, consider using Context API or a global state solution instead. Excessive prop drilling makes components harder to refactor.
import { type ReactNode } from 'react';

interface CardProps {
  title: string;
  description?: string;
  variant?: 'default' | 'featured' | 'danger';
  footer?: ReactNode;
  children: ReactNode;
}

function Card({
  title,
  description,
  variant = 'default',
  footer,
  children,
}: CardProps) {
  const variantClass = {
    default: 'card--default',
    featured: 'card--featured',
    danger: 'card--danger',
  }[variant];

  return (
    <section className={`card ${variantClass}`}>
      <div className="card__header">
        <h2 className="card__title">{title}</h2>
        {description && (
          <p className="card__description">{description}</p>
        )}
      </div>

      <div className="card__body">
        {children}
      </div>

      {footer && (
        <footer className="card__footer">
          {footer}
        </footer>
      )}
    </section>
  );
}

export { Card };
export type { CardProps };