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.
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:
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:
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:
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:
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:
// Old way - avoid this
type ButtonProps = {
label: string;
};
const Button: React.FC<ButtonProps> = ({ label }) => {
return <button>{label}</button>;
};Versus:
// 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:
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. UseOmitto 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: anyin codebases. It defeats the purpose of TypeScript. UseReactNodefor 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.ReactNodeis the right type for anything renderable; it's flexible and type-safe.- Skip
React.FCand 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
preventDefaultorcurrentTarget.






Comments
Loading comments…