React compound components: Scalable and reusable
Building scalable and reusable components with React is fairly straightforward, but it’s just as easy to fall into the trap of adding too many props to cover an endless list of options.
Trying to build the perfect component that satisfies every possible end-user request is a doomed crusade. Instead, you should aim for simplicity—and the compound component pattern is a perfect fit for this goal.
Let’s build a highly flexible and versatile UserCard
component to display user information (name, thumbnail, description, and skills).
The source code and a working example are available in this repository: GitHub – 31stfr/31st-ex.
For brevity, I'll use a simple reusable TypeScript type:
export type PropClassName = Pick<HTMLAttributes<HTMLElement>, 'className'>;
The Average Implementation
As a beginner, I often created components with too many props to cover every possible scenario. And yes, it worked: the component was functional and delivered what was expected.
But three weeks later, when the client changed their mind or asked for new features, the component became bloated and unmaintainable.
Here's a typical example of a monolithic component:
// ❌ Lack of flexibility
type BadUserCardProps = {
className?: string;
name: string;
imageAlt: string;
imageSrc: string;
description: string;
skills: string[];
};
const BadUserCard = ({
name,
imageAlt,
imageSrc,
description,
skills,
className,
}: BadUserCardProps) => {
// ❌ No separation of responsibilities
return (
<div
className={twMerge(
'grid grid-cols-[auto_1fr] gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
className
)}
>
<Image
src={imageSrc}
alt={imageAlt}
width={150}
height={150}
className="rounded-lg max-w-[150px] max-h-[150px]"
/>
<H5>{name}</H5>
<div className="p-4 text-sm bg-white text-neutral-500 border rounded-lg">
<ul>
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
</div>
<p className="p-4 text-sm bg-white text-neutral-500 border rounded-lg">{description}</p>
</div>
);
};
export default BadUserCard;
This component does the job—but it’s nearly impossible to replace or customize subcomponents. It lacks flexibility and doesn’t scale.
A Better Implementation
As an efficient developer, you should treat flexibility as a minimum requirement. While not always necessary, presentation components (like user cards or dashboard widgets) often need to adapt to multiple use cases within the same app.
That's where the compound component pattern shines: it allows you to build components designed to adapt.
This pattern requires only a React Context and one or more subcomponents.
Start with a Context
The context acts as a wrapper. Its provider is the root component you expose, and it brings several benefits:
Data sharing between subcomponents inside the main component
A guarantee that subcomponents are only used within the wrapper
A clear and convenient API for consumers
Here's a simple starting point:
type UserCardProps = PropsWithChildren & PropClassName;
interface UserCardContextData {
checked: boolean;
}
// Context
const UserCardContext = createContext<UserCardContextData | undefined>(undefined);
const UserCard = ({ children, className }: UserCardProps) => {
return (
<UserCardContext value={{ checked: true }}>
{children}
</UserCardContext>
);
};
export default UserCard;
This forms the base of our UserCard
component. Notice that since React 19, you can render <UserCardContext>
directly as a provider (without .Provider
).
As a best practice, let's define a custom hook so consumer components can safely access the context:
// Hook
const useUserCardContext = () => {
const context = use(UserCardContext);
if (context === undefined) {
throw new Error("UserCard components must be wrapped in a UserCardContextProvider");
}
return context;
};
Define the Root Styles
We can style the root UserCard
component however we want, wrapping its children:
const UserCard = ({ children, className }: UserCardProps) => {
return (
<UserCardContext value={{ checked: true }}>
<div
className={twMerge(
'flex flex-wrap gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
className
)}
>
{children}
</div>
</UserCardContext>
);
};
Adding Subcomponents
Now our component is ready to host as many subcomponents as we need.
In this example, we’ll add four:
Name
Image
Description
Skills
Each subcomponent is attached to the root component via static property assignment.
UserCard.Name = UserCardName;
UserCard.Image = UserCardImage;
UserCard.Description = UserCardDescription;
UserCard.Skills = UserCardSkills;
This allows consumers to write a clean API like:
<UserCard>
<UserCard.Image src="/me.png" alt="Profile picture" />
<UserCard.Name>Gregory</UserCard.Name>
<UserCard.Description>Front-end developer specialized in React</UserCard.Description>
<UserCard.Skills skills={['React', 'TypeScript', 'TailwindCSS']} />
</UserCard>
Complete code of our component
Note that it is recommended to place hooks
in their own file (starting with use
).
type UserCardProps = PropsWithChildren & PropClassName;
type UserCardNameProps = PropsWithChildren;
type NextImageProps = ComponentProps<typeof Image>;
// Liskov Substitution principle ( Subtype objects should be substitutable for supertype objects )
type UserCardImageProps = NextImageProps;
type UserCardDescriptionProps = PropsWithChildren & PropClassName;
type UserCardSkillsProps = {
skills: string[];
} & PropClassName;
interface UserCardContextData {
checked: boolean;
}
// Context
const UserCardContext = createContext<UserCardContextData | undefined>(undefined);
// Hook
const useUserCardContext = () => {
const context = use(UserCardContext);
if (context === undefined) {
throw new Error("UserCard component's must be wrapped in a UserCardContextProvider");
}
return context;
};
const UserCard = ({ children, className = undefined }: UserCardProps) => {
return (
<UserCardContext value={{ checked: true }}>
<div
className={twMerge(
'flex flex-wrap gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
className
)}
>
{children}
</div>
</UserCardContext>
);
};
const UserCardName = ({ children }: UserCardNameProps) => {
useUserCardContext();
return (
<div className="flex flex-col gap-1">
<H6 className="text-xs uppercase">Name</H6>
<H5>{children}</H5>
</div>
);
};
const UserCardImage = (props: UserCardImageProps) => {
useUserCardContext();
const { src, className, ...rest } = props;
return (
<Image
className={twMerge('rounded-lg aspect-square max-w-[150px] max-h-[150px]', className)}
src={src}
width={150}
height={150}
{...rest}
/>
);
};
const UserCardDescription = ({ children, className = undefined }: UserCardDescriptionProps) => {
useUserCardContext();
return (
<div className="flex flex-col gap-1">
<H6 className="text-xs uppercase">Description</H6>
<div
className={twMerge(
'p-4 text-sm bg-white text-neutral-500 border rounded-lg',
className
)}
>
{children}
</div>
</div>
);
};
const UserCardSkills = ({ skills, className = undefined }: UserCardSkillsProps) => {
useUserCardContext();
return (
<div className="flex flex-col gap-1">
<H6 className="text-xs uppercase">Skills</H6>
<div
className={twMerge(
'p-4 text-sm bg-white text-neutral-500 border rounded-lg',
className
)}
>
<ul className="list-disc list-inside text-sm text-neutral-500">
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
</div>
</div>
);
};
UserCard.Name = UserCardName;
UserCard.Image = UserCardImage;
UserCard.Description = UserCardDescription;
UserCard.Skills = UserCardSkills;
export default UserCard;
Key Takeaways
Each subcomponent is attached to the main component using static property assignment.
Each subcomponent calls
useUserCardContext
to ensure it’s wrapped in the provider.Each subcomponent supports
className
for maximum customization.Each subcomponent has its own props for clarity and flexibility.
The
Image
component uses Next.js’Image
, following the Liskov Substitution Principle (subtypes should be substitutable for their supertypes).tailwind-merge is used extensively to make overriding styles easy.
Conclusion
The compound component pattern is a clean way to create scalable, reusable, and flexible UI components in React.
It prevents components from becoming rigid or bloated, while offering consumers a clear and intuitive API. Next time you catch yourself overloading a component with props, consider splitting responsibilities with compound components instead—you’ll thank yourself later.
About the author
Front-end developer focused on React, Next.js, and clean, scalable CSS. Once building in PHP, Flash and others, now crafting layouts that (mostly) behave as expected. Greg's background in web design (Photoshop, Illustrator) shaped his love for clean layouts and CSS details. This blog is his way of giving back-sharing what he has learned from the same community that keeps inspiring him.