Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 092 / 100

TypeScript patterns for React props: a step-by-step guide

Types that make components easier to use.

Expona Admin7 min read

TypeScript patterns for React props

After 92 days of building with React and Next.js, you've probably felt the friction of poorly typed component APIs. TypeScript patterns for React props aren't just about passing type checking—they're about making your components intuitive to use and maintaining them months later when you've forgotten why you wrote them. In this post, I'll show you the patterns I've used across production e-commerce and SaaS platforms to keep prop types clear, predictable, and hard to misuse.

Extend native props with ComponentProps

One of the first patterns I learned the hard way was reimplementing native HTML attributes. Early in my career, I'd write button components and explicitly type every attribute—disabled, className, aria-label, data-testid. Then I'd forget one, and a consuming team would ping me asking why they couldn't pass it.

ComponentProps from React solves this elegantly. It extracts all the props accepted by a native element, so you inherit everything for free.

tsx
import { ComponentProps } from 'react';

// Extract all button props automatically
type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
};

export function Button({
  variant = 'primary',
  size = 'md',
  ...rest
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      {...rest}
    >
      {rest.children}
    </button>
  );
}

Now consumers get disabled, onClick, type, all the aria attributes, and data attributes—without you maintaining a list. I've used this in trading platforms where accessibility is critical and in e-commerce checkouts where event handlers need to be rock solid. The pattern scales to any element: ComponentProps<'a'>, ComponentProps<'input'>, ComponentProps<'div'>.

If you need to exclude certain props or override them, you can use Omit:

tsx
type ButtonProps = Omit<ComponentProps<'button'>, 'type'> & {
  type?: 'submit' | 'reset'; // Narrower than native
  variant?: 'primary' | 'secondary';
};

Discriminated unions for variant props

Discriminated unions—also called tagged unions—are one of the most powerful TypeScript patterns for component props. They let you enforce that certain props only appear together.

I first hit this need in a ride-hailing platform where a card component could render a link (with href) or a button (with onClick). Developers kept trying to pass both, or neither, creating runtime bugs. Discriminated unions made the contract explicit:

tsx
type CardProps = 
  | {
      variant: 'link';
      href: string;
      target?: '_blank' | '_self';
      // onClick is NOT allowed here
    }
  | {
      variant: 'button';
      onClick: () => void;
      // href is NOT allowed here
    };

export function Card({ variant, ...rest }: CardProps) {
  if (variant === 'link') {
    const { href, target } = rest as Extract<CardProps, { variant: 'link' }>;
    return <a href={href} target={target}>...</a>;
  }

  const { onClick } = rest as Extract<CardProps, { variant: 'button' }>;
  return <button onClick={onClick}>...</button>;
}

The discriminator (in this case variant) narrows the type. If you pass variant: 'link', TypeScript will error if you forget href or try to pass onClick. This pattern has saved me countless hours debugging in production SaaS systems where a card renders differently based on context.

You can extend this to more complex scenarios. In a CRM I built, I used discriminated unions to enforce that certain notification types required specific fields:

tsx
type NotificationProps = 
  | {
      type: 'success';
      message: string;
      dismissAfter?: number;
    }
  | {
      type: 'error';
      message: string;
      retryAction?: () => void;
    }
  | {
      type: 'loading';
      message: string;
      // No dismissAfter, it's shown until explicitly closed
    };

export function Notification(props: NotificationProps) {
  switch (props.type) {
    case 'success':
      return <div>✓ {props.message}</div>;
    case 'error':
      return (
        <div>
          ✗ {props.message}
          {props.retryAction && (
            <button onClick={props.retryAction}>Retry</button>
          )}
        </div>
      );
    case 'loading':
      return <div>... {props.message}</div>;
  }
}

Children types and ReactNode

React.ReactNode is the type that represents anything renderable in React. I see a lot of confusion here, so let me be concrete.

ReactNode includes:

  • React elements (JSX: <Component />)
  • Strings and numbers
  • Fragments
  • Arrays of any of the above
  • null, undefined, booleans (rendered as nothing)

When you want to accept any renderable content, use ReactNode:

tsx
import { ReactNode } from 'react';

type LayoutProps = {
  header?: ReactNode;
  children: ReactNode;
  footer?: ReactNode;
};

export function Layout({ header, children, footer }: LayoutProps) {
  return (
    <div className="layout">
      {header && <header>{header}</header>}
      <main>{children}</main>
      {footer && <footer>{footer}</footer>}
    </div>
  );
}

I use this in dashboard layouts, modals, and card components across platforms. The flexibility of ReactNode means consumers can pass strings, components, fragments, or conditional content without you overthinking the type.

If you need to be more restrictive—for example, accepting only text or elements but not arrays—there's React.ReactElement, but in practice I rarely need it. ReactNode is permissive enough that components stay composable without being loose.

Avoid React.FC and use plain function signatures

React.FC (or React.FunctionComponent) was popular in older React code. I've stopped using it, and here's why.

React.FC implicitly accepts children as a prop, even if you don't want it. It also adds complexity and isn't how modern React (especially with hooks) is typed.

Compare:

tsx
// Old way - avoid this
type ButtonProps = {
  label: string;
};

const Button: React.FC<ButtonProps> = ({ label }) => {
  return <button>{label}</button>;
};

Versus:

tsx
// Modern way - prefer this
type ButtonProps = {
  label: string;
};

export function Button({ label }: ButtonProps) {
  return <button>{label}</button>;
}

The plain function signature is clearer: you see exactly what props are accepted. If you need to accept children, add it explicitly:

tsx
import { ReactNode } from 'react';

type CardProps = {
  title: string;
  children: ReactNode;
};

export function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h3>{title}</h3>
      {children}
    </div>
  );
}

This is what I use across all my projects now—it's clearer to read, easier to reason about, and you avoid the implicit children footgun.

Common mistakes

  • Over-extending with ComponentProps. If you extend ComponentProps<'button'> but then override half the props, you're creating confusion. Use Omit to be explicit about what you're changing.
  • Forgetting to narrow discriminated unions in JSX. Inside a discriminated union branch, TypeScript won't always auto-narrow. Use type guards or explicit assertions like as Extract<Props, { variant: 'link' }>.
  • Using any instead of ReactNode. I've seen children: any in codebases. It defeats the purpose of TypeScript. Use ReactNode for flexibility with type safety.
  • Making children required when it should be optional. Most container components should have optional children. If you require it, you're forcing consumers into awkward patterns.
  • Typing events as () => void. If your onClick needs to receive an event, type it: onClick: (e: React.MouseEvent<HTMLButtonElement>) => void. In a ride-hailing app, I once debugged for hours because a click handler couldn't preventDefault because it wasn't typed to receive the event.

Key takeaways

  • Use ComponentProps<'element'> to inherit all native attributes without maintaining a list.
  • Discriminated unions enforce that certain props only appear together, catching mistakes at compile time.
  • React.ReactNode is the right type for anything renderable; it's flexible and type-safe.
  • Skip React.FC and use plain function signatures—they're clearer and more explicit.
  • Type event handlers with their full signature, including the event object, so consumers can access properties like preventDefault or currentTarget.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles