Compound is the Best Way to Write React Components
Learn how compound patterns can make your React components more flexible and maintainable
Published Sep 20, 2025
Suggest ChangesAbout a month ago I was working on one of my project and I had this one simple component nothing much complicated, but as I needed more feature I simply passed one more prop to it. I already had bunch of props.
Props are the way we pass data into React components. That part was fine. The real problem was how it felt.
The more props I added, the messier the component became. And it wasn’t even giving me the flexibility I actually wanted. What if I wanted a different style for the title? Or a different label for the button? The only way forward seemed to be… adding more props.
Readability suffered too.. Sometimes I’d come back to the project after a week, open the file, and just sit there trying to remember what was going on. Even small changes felt harder than they should.
for simplicity I’m omitting the style in code and other unnecessary details.
Let’s look at a simpler example. You’ve probably seen this kind of component in documentation
npm install react react-dom
<CommandBlock
title="Install Dependencies"
command="npm install react react-dom"
/>
interface CommandBlockProps {
command: string;
}
function CommandBlock({command}: CommandBlockProps) {
return (
<>
{...}
<span>
{command}
</span>
)}
</>
);
}
Just a command by itself doesn’t make it obvious what it does, so we might want to add a title to this CommandBlock component. We need to pass one more prop called title.
<CommandBlock
title="Install Dependencies"
command="npm install react react-dom"
/>
interface CommandBlockProps {
title?: string;
command: string;
className?: string;
}
function CommandBlock({ title, command, className }: CommandBlockProps) {
return (
<>
{...}
{title && (
<span className="font-medium text-sm sm:text-base">
{title}
</span>
)}
{...}
</>
);
}
Now at least the purpose is clear: this command installs dependencies.
npm install react react-dom
Let’s extend it little more, I don’t want to show a terminal icon. Well, you guessed it… I added another prop.
<CommandBlock
title="Install Dependencies"
command="npm install react react-dom"
showTerminalIcon={false} // add aditional prop
/>
interface CommandBlockProps {
title?: string;
command: string;
showTerminalIcon?:boolean
}
function CommandBlock({ title, command, showTerminalIcon }: CommandBlockProps) {
return (
<>
{...}
{showTerminalIcon && (
<SquareTerminal />
)}
{title && (
<span>{title}</span>
)}
{...}
</>
);
}
npm install react react-dom
But what if someone is using pnpm, yarn, or buninstead of npm? We can’t just ignore them. Let’s tweak our CommandBlock component so it can handle multiple package managers without breaking anything.
npm install shadcn@latest add tabs
Let’s add a new prop called commands an array of commands for different package managers. That way, the component could handle multiple package managers without breaking anything.
<CommandBlock
// additional prop to pass commands
commands={[
{ label: "pnpm", command: "pnpm add shadcn@latest add tabs" },
{ label: "npm", command: "npm install shadcn@latest add tabs" },
{ label: "yarn", command: "yarn add shadcn@latest add tabs" },
{ label: "bun", command: "bunx --bun shadcn@latest add tabs" },
]}
defaultValue="npm"
/>
We need both single command block and tabs one as well, how do we do this then?…Let’s separate out the components
- SingleCommandBlock — for a single command
- MultiCommandBlock — for multiple commands
To support this, I will create another component inside the same file called MultiCommandBlock, rename the original CommandBlock to SingleCommandBlock, and add an additional check: if the commands prop is passed, we return a MultiCommandBlock, otherwise we return a SingleCommandBlock.
This is how it looks like.
export default function CommandBlock(props: CommandBlockProps) {
const { command, commands, ...rest } = props;
if (command) {
return (
<SingleCommandBlock command={command} {...rest} />
);
}
if (commands && commands.length > 0) {
return (
<MultiCommandBlock commands={commands} {...rest} />
);
}
return null;
}
This is the typical approach for building a component, but it comes with some downsides. Every new feature we add means passing more props, adding more conditional logic, and drilling props through multiple layers. Before long, the component starts to feel messy and hard to reason about.
Personally, I find it very difficult to read the component, even when I come back to the project after a month. I want to make things easier for my future self, so I don’t have to struggle reading and understanding the code I wrote.
Let’s see how we can make things easier with compound design pattern.
Compound Pattern
React compound is about building components by combining smaller pieces, rather than relying on inheritance or passing tons of props. It’s like LEGO: you create small, reusable blocks that snap together to form complex structures.
I’ve been using this pattern in my recent project chatcn.me which is a collection of components built with Shadcn to help you create AI chat UIs. Check it out if you’re building AI chat applications.
Up until now, we had a single CommandBlock component, and we were passing multiple props to handle every little feature—title, icons, multiple commands, etc. It worked, but it quickly became messy, hard to read, and difficult to maintain.
What if we could build the same component by composing smaller, focused pieces? That’s where the compound pattern shines. Instead of one big prop-heavy component, we break it down into reusable blocks.
<CommandBlock>
<CommandBlockHeader>
<CommandBlockTitle showTerminalIcon>
Install Dependencies
</CommandBlockTitle>
</CommandBlockHeader>
<CommandBlockContent command="npm install react react-dom" />
</CommandBlock>
npm install react react-dom
<CommandBlock>
<CommandBlocksTabs defaultValue="bun">
<CommandBlockTabHeader showTerminalIcon>
{packageManagerCommands.map((cmd) => (
<CommandBlockTabTrigger
key={cmd.label}
value={cmd.label}
label={cmd.label}
/>
))}
</CommandBlockTabHeader>
{packageManagerCommands.map((cmd) => (
<CommandBlockTabContent
key={cmd.label}
value={cmd.label}
command={cmd.command}
/>
))}
</CommandBlocksTabs>
</CommandBlock>
npm install shadcn@latest add tabs
Notice how there’s no prop mess, and every piece can be styled or removed independently. For example, if we don’t want a title, we can simply remove the CommandBlockTitle, and everything still works
<CommandBlock>
<CommandBlockHeader/>
<CommandBlockContent command="npm install react react-dom" />
</CommandBlock>
npm install react react-dom
Take a look at our component structure
Here’s how it works:
-
CommandBlock – The top-level container and context provider. It manages shared state like which command is active and whether it has been copied.
-
CommandBlockHeader – Wraps the header section and includes things like the title.
-
CommandBlockTitle – Responsible for rendering the title and terminal icon, and the copy button.
-
CommandBlockContent – Displays the actual command.
-
CommandBlockTabs – Wraps the tabs for multiple commands.
-
CommandBlockTabHeader / CommandBlockTabTrigger / CommandBlockTabContent – Handle the tab logic and rendering for each command.
// Context
const CommandBlockContext = createContext<CommandBlockContextType | null>(null);
function useCommandBlockContext() {
return useContext(CommandBlockContext);
}
// Top-level component
function CommandBlock({ children, className }: CommandBlockProps) {
return <CommandBlockContext.Provider>...</CommandBlockContext.Provider>;
}
// Header & title
function CommandBlockHeader({ children }: CommandBlockHeaderProps) {
return <>...</>;
}
function CommandBlockTitle({
children,
showTerminalIcon,
}: CommandBlockTitleProps) {
return <>...</>;
}
// Command content
function CommandBlockContent({ command, className }: CommandBlockContentProps) {
return <>...</>;
}
// Tabs
function CommandBlockTabs({ children, defaultValue }: CommandBlocksTabsProps) {
return <>...</>;
}
function CommandBlockTabHeader({
children,
showTerminalIcon,
}: CommandBlockTabHeaderProps) {
return <>...</>;
}
function CommandBlockTabTrigger({ value, label }: CommandBlockTabTriggerProps) {
return <>...</>;
}
function CommandBlockTabContent({ value, command }: CommandBlockTabContentProps) {
return <>...</>;
}
You can find the full code here
Thanks for reading ❤️