How to Write Flexible React Component Interfaces
A guide to writing type-safe, ergonomic React components that are easy to use and extend.

When building a design system or UI library, we want to simplify our component interfaces as much as possible while not compromising on flexibility. A reusable component should be easy to use in simple cases, but also easy to extend when needed. Let’s walk through how to build a button component in React that meets these criteria.
1. Start with the native props
Instead of reinventing the wheel, reuse the built-in button attributes using
ComponentProps<'button'>. This gives your component all the correct event handlers
and accessibility props out of the box.
import { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'>;
export function Button({ className, ...props }: ButtonProps) {
return (
<button
{...props}
className={cn('rounded-md px-3 py-2 font-medium', className)}
/>
);
}
The cn() utility merges conditional or external class names safely. This allows
consumers of our component to easily add their own custom classes to override our
default styles.
Note: while allowing className to be passed in adds some flexibility, it also
increases the risk of diverging styles across usages. If you expose className I'd
also suggest adding a lint rule to warn consumers that they should use className
sparingly.
If you want to restrict which props consumers can use, you can narrow them with Omit<>:
type ButtonProps = Omit, 'type'> & {
variant?: 'primary' | 'secondary';
};
2. Accept richer renderable props
I often see components that accept renderable content via a string prop. This is common
for label, description, message props, etc. This is a common anti-pattern because
it unnecessarily limits the flexibility of the component. Instead, use ReactNode for
renderable content to allow richer content to be passed in.
import { ReactNode } from 'react'
type ButtonProps = ComponentProps<'button'> & {
helperText?: ReactNode;
};
Now you can pass in anything React can render such as text, JSX elements, or even other components.
<Button helperText={'<em>Shift + Enter</em>'}>
Send
</Button>
3. Add an is prop for polymorphism
Sometimes you want your button to render as a link, a div, or even a router Link.
Instead of creating a new component for each case, support an is prop to make it
polymorphic:
type ButtonProps = {
is?: T;
className?: string;
} & ComponentProps;
export function Button({
is,
className,
...props
}: ButtonProps) {
const Component = is || 'button';
return <Component {...props} className={cn('btn', className)} />
};
Now you can reuse your button with different underlying elements while keeping proper TypeScript types.
<Button>Submit</Button>
<Button is="a" href="/docs">Docs</Button>
<Button is={Link} to="/settings">Settings</Button>
With these three techniques you get a flexible, type-safe, and ergonomic button component that fits naturally into any design system. This is a simple example, but you can apply these same principles to any component you build.
Happy building!