Skip to content
Back to Blog
ReactCompositionDesign PatternsTypeScriptComponent Architecture

Children Over Props: The React Composition Pattern That Scales

Most prop explosion problems have the same root cause: components making rendering decisions they shouldn't own. Here's how children-based composition fixes it across buttons, cards, alerts, form fields, and layouts.

11 min read

Every component starts clean. Then the edge cases arrive.

// week 1
function Button({ label, onClick }: { label: string; onClick: () => void }) {
  return <button onClick={onClick}>{label}</button>
}

Someone needs an icon. Fine.

// week 3
function Button({ label, onClick, icon }: ButtonProps) {
  return (
    <button onClick={onClick}>
      {icon && <span className="btn-icon">{icon}</span>}
      {label}
    </button>
  )
}

Then a loading state. Then a disabled tooltip. Then three sizes. Then two variants.

// week 26 — this is the same component
<Button
  label="Save"
  onClick={handleSave}
  icon={<SaveIcon />}
  iconPosition="left"
  loading={isSaving}
  loadingLabel="Saving..."
  disabled={!isDirty}
  disabledTooltip="No changes to save"
  size="md"
  variant="primary"
  fullWidth={false}
  type="submit"
/>

This is the prop explosion problem. And it's not a failure of discipline. It's what happens when you treat a component like a configuration object instead of a composition unit.

The fix is simpler than it looks. Stop passing data as props when you can pass components as children.


The Button

The week-26 Button isn't broken because it has too many props. It's broken because the component is making layout decisions it shouldn't own. iconPosition, loadingLabel, fullWidth are rendering decisions the consumer should control.

// ✅ Button owns nothing except what a button needs to own
function Button({ children, onClick, disabled, type = 'button' }: ButtonProps) {
  return (
    <button type={type} onClick={onClick} disabled={disabled} className="btn">
      {children}
    </button>
  )
}
 
function ButtonIcon({ children, side = 'left' }: { children: ReactNode; side?: 'left' | 'right' }) {
  return <span className={`btn-icon btn-icon--${side}`}>{children}</span>
}
 
function ButtonSpinner() {
  return <span className="btn-spinner" aria-hidden><Spinner /></span>
}
 
function ButtonBadge({ count }: { count: number }) {
  return <span className="btn-badge">{count}</span>
}

The consumer assembles exactly what they need:

// loading
<Button onClick={handleSave} disabled={isSaving}>
  <ButtonSpinner />
  Saving...
</Button>
 
// icon left
<Button onClick={handleSave}>
  <ButtonIcon><SaveIcon /></ButtonIcon>
  Save
</Button>
 
// icon right
<Button onClick={handleNext}>
  Next
  <ButtonIcon side="right"><ArrowIcon /></ButtonIcon>
</Button>
 
// notification count badge
<Button onClick={openInbox}>
  <ButtonIcon><InboxIcon /></ButtonIcon>
  Inbox
  <ButtonBadge count={unreadCount} />
</Button>
 
// keyboard shortcut hint
<Button onClick={handleSave} type="submit">
  <ButtonIcon><SaveIcon /></ButtonIcon>
  Save
  <span className="btn-shortcut">⌘S</span>
</Button>
 
// just text
<Button onClick={handleCancel}>Cancel</Button>

Every one of these variations required zero changes to Button itself. No new props, no new branches inside the component. The component owns exactly what a button needs to own: the onClick, the disabled state, the base styles.


The Card

Cards are where this problem gets expensive fast. They start reasonable.

// reasonable start
function Card({ title, description, image }: CardProps) {
  return (
    <div className="card">
      {image && <img src={image} alt="" className="card-image" />}
      <div className="card-body">
        <h3>{title}</h3>
        <p>{description}</p>
      </div>
    </div>
  )
}

Then marketing needs a badge. Then a footer with actions. Then a horizontal layout. Then an image overlay. Then a skeleton loading state.

// ❌ six months later
function Card({
  title,
  description,
  image,
  imagePosition = 'top',
  badge,
  badgeVariant = 'default',
  footer,
  footerAlign = 'right',
  loading,
  overlay,
  overlayText,
  href,
  onClick,
  selected,
  bordered,
}: CardProps) {
  // ...50 lines of branching logic
}

Every new feature touches the same file. Every imagePosition value that isn't 'top' or 'left' is a bug waiting to happen.

// ✅ the card becomes a layout shell, nothing more
function Card({ children, className }: { children: ReactNode; className?: string }) {
  return <div className={cn('card', className)}>{children}</div>
}
 
function CardImage({ src, alt = '', overlay }: { src: string; alt?: string; overlay?: ReactNode }) {
  return (
    <div className="card-image-wrapper">
      <img src={src} alt={alt} className="card-image" />
      {overlay && <div className="card-image-overlay">{overlay}</div>}
    </div>
  )
}
 
function CardBody({ children }: { children: ReactNode }) {
  return <div className="card-body">{children}</div>
}
 
function CardFooter({ children, align = 'right' }: { children: ReactNode; align?: 'left' | 'right' | 'between' }) {
  return <div className={`card-footer card-footer--${align}`}>{children}</div>
}
 
function CardBadge({ children, variant = 'default' }: { children: ReactNode; variant?: 'default' | 'success' | 'warning' }) {
  return <span className={`card-badge card-badge--${variant}`}>{children}</span>
}

Now each product requirement is a composition decision, not a prop:

// product card with badge and actions
<Card>
  <CardImage src={product.image} alt={product.name} />
  <CardBody>
    <CardBadge variant="success">In Stock</CardBadge>
    <h3>{product.name}</h3>
    <p>{product.description}</p>
    <p className="price">{product.price}</p>
  </CardBody>
  <CardFooter>
    <Button onClick={() => addToCart(product)}>Add to Cart</Button>
  </CardFooter>
</Card>
 
// blog post card — image with overlay, no footer
<Card>
  <CardImage src={post.coverImage} alt={post.title} overlay={
    <CardBadge>{post.category}</CardBadge>
  }>
  </CardImage>
  <CardBody>
    <h3>{post.title}</h3>
    <p>{post.excerpt}</p>
    <span className="text-sm text-gray-500">{post.readTime} min read</span>
  </CardBody>
</Card>
 
// horizontal layout — just flip the className
<Card className="card--horizontal">
  <CardImage src={user.avatar} alt={user.name} />
  <CardBody>
    <h3>{user.name}</h3>
    <p>{user.role}</p>
  </CardBody>
  <CardFooter align="between">
    <Button variant="ghost">Message</Button>
    <Button>Follow</Button>
  </CardFooter>
</Card>
 
// loading state — swap the body, nothing else changes
<Card>
  <CardImage src="/placeholder.png" alt="" />
  <CardBody>
    <div className="skeleton skeleton--title" />
    <div className="skeleton skeleton--text" />
    <div className="skeleton skeleton--text w-2/3" />
  </CardBody>
</Card>

The horizontal layout, the image overlay, the loading state — none of them required a new prop on Card.


The Alert

Alerts seem simple. Then you need icons. Then dismissible alerts. Then alerts with action buttons. Then alerts with links inline. Then multiline alerts with a title.

// ❌ the prop list grows with every design ticket
function Alert({
  message,
  type = 'info',
  icon,
  dismissible,
  onDismiss,
  action,
  actionLabel,
  onAction,
  title,
}: AlertProps) {
  return (
    <div className={`alert alert--${type}`}>
      {icon && <span className="alert-icon">{icon}</span>}
      <div className="alert-content">
        {title && <p className="alert-title">{title}</p>}
        <p>{message}</p>
        {action && (
          <button onClick={onAction} className="alert-action">{actionLabel}</button>
        )}
      </div>
      {dismissible && (
        <button onClick={onDismiss} className="alert-dismiss">✕</button>
      )}
    </div>
  )
}

The message is a string. What if the message needs a link inside it? New prop. What if the action needs to be a link, not a button? New prop. It never ends.

// ✅ alert is a styled container, consumer owns the content
function Alert({ children, variant = 'info' }: { children: ReactNode; variant?: 'info' | 'success' | 'warning' | 'error' }) {
  return (
    <div role="alert" className={`alert alert--${variant}`}>
      {children}
    </div>
  )
}
 
function AlertIcon({ children }: { children: ReactNode }) {
  return <span className="alert-icon" aria-hidden>{children}</span>
}
 
function AlertContent({ children }: { children: ReactNode }) {
  return <div className="alert-content">{children}</div>
}
 
function AlertTitle({ children }: { children: ReactNode }) {
  return <p className="alert-title">{children}</p>
}
 
function AlertDismiss({ onDismiss }: { onDismiss: () => void }) {
  return (
    <button onClick={onDismiss} className="alert-dismiss" aria-label="Dismiss">

    </button>
  )
}
// simple info alert
<Alert>
  <AlertContent>Your session expires in 5 minutes.</AlertContent>
</Alert>
 
// error with icon and dismiss
<Alert variant="error">
  <AlertIcon><XCircleIcon /></AlertIcon>
  <AlertContent>Failed to save changes. Please try again.</AlertContent>
  <AlertDismiss onDismiss={() => setError(null)} />
</Alert>
 
// warning with title and inline link
<Alert variant="warning">
  <AlertIcon><WarningIcon /></AlertIcon>
  <AlertContent>
    <AlertTitle>Storage almost full</AlertTitle>
    <p>
      You're using 4.8GB of your 5GB limit.{' '}
      <a href="/settings/storage" className="alert-link">Upgrade your plan</a>
      {' '}to get more space.
    </p>
  </AlertContent>
</Alert>
 
// success with action button
<Alert variant="success">
  <AlertIcon><CheckCircleIcon /></AlertIcon>
  <AlertContent>
    <AlertTitle>Payment successful</AlertTitle>
    <p>Your order #{orderId} has been confirmed.</p>
  </AlertContent>
  <Button variant="ghost" size="sm" onClick={() => navigate(`/orders/${orderId}`)}>
    View order
  </Button>
</Alert>

The inline link in the third example was impossible with the prop-based version. Here it's just JSX.


The Form Field

Form fields might be the worst offender. Every project has a FormField that started with label and placeholder and ended up with fifteen props.

// ❌ the prop list of a component that tried to do too much
function FormField({
  label,
  name,
  type = 'text',
  placeholder,
  value,
  onChange,
  error,
  hint,
  required,
  disabled,
  prefix,
  suffix,
  maxLength,
  showCount,
}: FormFieldProps) {
  return (
    <div className="field">
      <label htmlFor={name}>
        {label}
        {required && <span className="required">*</span>}
      </label>
      <div className="field-input-wrapper">
        {prefix && <span className="field-prefix">{prefix}</span>}
        <input
          id={name}
          name={name}
          type={type}
          placeholder={placeholder}
          value={value}
          onChange={onChange}
          disabled={disabled}
          maxLength={maxLength}
        />
        {suffix && <span className="field-suffix">{suffix}</span>}
        {showCount && <span className="field-count">{value.length}/{maxLength}</span>}
      </div>
      {hint && <p className="field-hint">{hint}</p>}
      {error && <p className="field-error">{error}</p>}
    </div>
  )
}

What happens when you need a <textarea> instead of an <input>? New prop. A <select>? New prop. A password field with a show/hide toggle? The component grows another branch.

// ✅ field is a label + layout container, input is the consumer's choice
function Field({ children }: { children: ReactNode }) {
  return <div className="field">{children}</div>
}
 
function FieldLabel({ htmlFor, children, required }: { htmlFor: string; children: ReactNode; required?: boolean }) {
  return (
    <label htmlFor={htmlFor} className="field-label">
      {children}
      {required && <span className="field-required" aria-hidden>*</span>}
    </label>
  )
}
 
function FieldInputGroup({ children }: { children: ReactNode }) {
  return <div className="field-input-group">{children}</div>
}
 
function FieldAddon({ children, side }: { children: ReactNode; side: 'prefix' | 'suffix' }) {
  return <span className={`field-addon field-addon--${side}`}>{children}</span>
}
 
function FieldHint({ children }: { children: ReactNode }) {
  return <p className="field-hint">{children}</p>
}
 
function FieldError({ children }: { children: ReactNode }) {
  return <p className="field-error" role="alert">{children}</p>
}
// simple text field
<Field>
  <FieldLabel htmlFor="email" required>Email</FieldLabel>
  <input id="email" type="email" className="input" {...register('email')} />
  {errors.email && <FieldError>{errors.email.message}</FieldError>}
</Field>
 
// field with prefix addon
<Field>
  <FieldLabel htmlFor="price">Price</FieldLabel>
  <FieldInputGroup>
    <FieldAddon side="prefix">$</FieldAddon>
    <input id="price" type="number" className="input" {...register('price')} />
  </FieldInputGroup>
  <FieldHint>Enter the price in USD</FieldHint>
</Field>
 
// field with url prefix and copy button suffix
<Field>
  <FieldLabel htmlFor="slug">URL Slug</FieldLabel>
  <FieldInputGroup>
    <FieldAddon side="prefix">myapp.com/</FieldAddon>
    <input id="slug" type="text" className="input" {...register('slug')} />
    <FieldAddon side="suffix">
      <button onClick={copyUrl} className="icon-btn"><CopyIcon /></button>
    </FieldAddon>
  </FieldInputGroup>
  {errors.slug && <FieldError>{errors.slug.message}</FieldError>}
</Field>
 
// textarea — no new prop needed, just swap the input
<Field>
  <FieldLabel htmlFor="bio">Bio</FieldLabel>
  <textarea id="bio" className="input input--textarea" rows={4} {...register('bio')} />
  <FieldHint>Max 160 characters</FieldHint>
  {errors.bio && <FieldError>{errors.bio.message}</FieldError>}
</Field>
 
// password field with show/hide toggle — composed entirely in the consumer
<Field>
  <FieldLabel htmlFor="password" required>Password</FieldLabel>
  <FieldInputGroup>
    <input
      id="password"
      type={showPassword ? 'text' : 'password'}
      className="input"
      {...register('password')}
    />
    <FieldAddon side="suffix">
      <button onClick={() => setShowPassword(p => !p)} className="icon-btn" type="button">
        {showPassword ? <EyeOffIcon /> : <EyeIcon />}
      </button>
    </FieldAddon>
  </FieldInputGroup>
  {errors.password && <FieldError>{errors.password.message}</FieldError>}
</Field>

The password toggle, the textarea, the copy button — none of them required a new prop on Field. The consumer handled it.


The Empty State

Empty states are surprisingly props-heavy when you're not thinking about composition.

// ❌ every product page has different copy and a different CTA
function EmptyState({
  icon,
  title,
  description,
  primaryAction,
  primaryLabel,
  secondaryAction,
  secondaryLabel,
  size = 'md',
}: EmptyStateProps) {
  return (
    <div className={`empty-state empty-state--${size}`}>
      {icon && <div className="empty-state-icon">{icon}</div>}
      <h3>{title}</h3>
      {description && <p>{description}</p>}
      <div className="empty-state-actions">
        {primaryAction && (
          <Button onClick={primaryAction}>{primaryLabel}</Button>
        )}
        {secondaryAction && (
          <Button variant="ghost" onClick={secondaryAction}>{secondaryLabel}</Button>
        )}
      </div>
    </div>
  )
}

What if the description needs a link? What if there are three actions? What if the CTA is a file upload button?

// ✅ empty state is a centered layout, consumer owns the content
function EmptyState({ children, size = 'md' }: { children: ReactNode; size?: 'sm' | 'md' | 'lg' }) {
  return (
    <div className={`empty-state empty-state--${size}`}>
      {children}
    </div>
  )
}
 
function EmptyStateIcon({ children }: { children: ReactNode }) {
  return <div className="empty-state-icon" aria-hidden>{children}</div>
}
 
function EmptyStateTitle({ children }: { children: ReactNode }) {
  return <h3 className="empty-state-title">{children}</h3>
}
 
function EmptyStateDescription({ children }: { children: ReactNode }) {
  return <p className="empty-state-description">{children}</p>
}
 
function EmptyStateActions({ children }: { children: ReactNode }) {
  return <div className="empty-state-actions">{children}</div>
}
// no results
<EmptyState>
  <EmptyStateIcon><SearchIcon /></EmptyStateIcon>
  <EmptyStateTitle>No results found</EmptyStateTitle>
  <EmptyStateDescription>
    Try adjusting your filters or{' '}
    <button onClick={clearFilters} className="link">clear all filters</button>.
  </EmptyStateDescription>
</EmptyState>
 
// first-time experience with upload
<EmptyState size="lg">
  <EmptyStateIcon><FolderOpenIcon /></EmptyStateIcon>
  <EmptyStateTitle>No files yet</EmptyStateTitle>
  <EmptyStateDescription>
    Upload your first file to get started. Supports PDF, PNG, and JPG up to 10MB.
  </EmptyStateDescription>
  <EmptyStateActions>
    <label className="btn btn-primary">
      Upload file
      <input type="file" className="sr-only" onChange={handleUpload} />
    </label>
  </EmptyStateActions>
</EmptyState>
 
// error state — same component, different content
<EmptyState>
  <EmptyStateIcon><AlertCircleIcon /></EmptyStateIcon>
  <EmptyStateTitle>Something went wrong</EmptyStateTitle>
  <EmptyStateDescription>We couldn't load your data. This is on us.</EmptyStateDescription>
  <EmptyStateActions>
    <Button onClick={retry}>Try again</Button>
    <Button variant="ghost" onClick={contactSupport}>Contact support</Button>
  </EmptyStateActions>
</EmptyState>

The file upload CTA with a hidden <input type="file"> would have been a special case in the prop-based version. Here it's just children.


The Rule Behind All of This

Every example above follows the same pattern. The component that exploded was making decisions it shouldn't make: what text appears in a label, what element renders inside a field, whether the empty state has one CTA or two.

Strip those decisions out. Hand them back to the consumer. What stays in the component is the structure, the styles, and the semantics — the things that don't change between uses.

The question to ask when a component grows a new prop is: does this component need to own this, or can I just put it in children?

Most of the time the answer is children. And when it is, nothing needs to change in the component at all.