David Leger

Design Engineer

dave.js logo

How to Write Flexible React Component Interfaces

A guide to writing type-safe, ergonomic React components that are easy to use and extend.

The React logo broken into modular pieces

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!