The Compound Component Pattern: Rethinking React Component Design

UI Engineer currently simplifying the process of building generative AI bots @yellow.ai. On a journey to make the web a better place by building engaging and performant UIs.
Introduction
React component design has evolved significantly over the years. From class components to functional components with hooks, from prop drilling to context.
One pattern that has gained significant traction in modern React development is the Compound Component Pattern (also known as the Composer Pattern). This pattern is widely adopted in Radix UI and further popularized by Shadcn UI, one of the most popular modern UI libraries built on top of it.
Instead of creating monolithic components with dozens of configuration props, the compound pattern let’s us build components that are composed together, giving consumers fine-grained control over structure and behavior while maintaining encapsulation of shared state and logic.
In this article, we'll explore:
Why traditional prop-heavy components become unmaintainable
How the compound component pattern solves these problems
How to decide what should become a compound component
Real-world examples
Best practices and when to use this pattern
The Traditional Approach
Let's start with an example of building a data table component. Initially, you might start with something simple, but as requirements grow, so does the complexity.
Prop Hell
Here's how a typical DataTable component could evolve:
// Week 1: Simple table
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
}
// Week 4: Adding basic features
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
showPagination?: boolean;
showSearch?: boolean;
}
// Month 2: More features requested
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
showPagination?: boolean;
paginationPosition?: "top" | "bottom" | "both";
pageSize?: number;
showSearch?: boolean;
searchPlaceholder?: string;
searchDebounceMs?: number;
showRowSelection?: boolean;
selectionMode?: "single" | "multiple";
onSelectionChange?: (selected: T[]) => void;
}
// Month 6: The never ending list of props
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
// Pagination props
showPagination?: boolean;
paginationPosition?: "top" | "bottom";
pageSize?: number;
showPageSizeSelector?: boolean;
paginationClassName?: string;
onPageChange?: (page: number) => void;
customPaginationRenderer?: (props: PaginationProps) => ReactNode;
// Search props
showSearch?: boolean;
searchPosition?: "left" | "right";
searchPlaceholder?: string;
searchDebounceMs?: number;
onSearchChange?: (query: string) => void;
customSearchRenderer?: (props: SearchProps) => ReactNode;
// Selection props
showRowSelection?: boolean;
selectionMode?: "single" | "multiple";
showSelectAll?: boolean;
selectedRows?: T[];
onSelectionChange?: (selected: T[]) => void;
// Sorting props
sortable?: boolean;
defaultSort?: { field: keyof T; direction: "asc" | "desc" };
onSortChange?: (sort: SortConfig<T>) => void;
// Filtering props
showFilters?: boolean;
filterPosition?: "top" | "inline" | "sidebar";
filters?: FilterConfig<T>[];
onFilterChange?: (filters: ActiveFilters<T>) => void;
// Loading/Empty states
loading?: boolean;
loadingRenderer?: ReactNode;
emptyMessage?: string;
emptyRenderer?: ReactNode;
// Styling props
className?: string;
tableClassName?: string;
rowHeight?: number | "auto";
striped?: boolean;
bordered?: boolean;
hoverable?: boolean;
// ...and more and more props
}
At this point, it might feel natural to group related props into nested objects (like paginationConfig or searchConfig) instead of keeping them all at the root level. While this appears to improve organization, it doesn’t actually solve the fundamental problem
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
// Pagination props
paginationConfig: {
showPagination?: boolean;
paginationPosition?: "top" | "bottom";
pageSize?: number;
showPageSizeSelector?: boolean;
paginationClassName?: string;
onPageChange?: (page: number) => void;
customPaginationRenderer?: (props: PaginationProps) => ReactNode;
}
// Search props
searchConfig: {
showSearch?: boolean;
searchPosition?: "left" | "right";
searchPlaceholder?: string;
searchDebounceMs?: number;
onSearchChange?: (query: string) => void;
customSearchRenderer?: (props: SearchProps) => ReactNode;
}
//...
}
Now let's see what the component implementation looks like:
function DataTable<T>({
data,
columns,
showPagination = false,
paginationPosition = "bottom",
pageSize = 10,
showPageSizeSelector = true,
paginationClassName,
customPaginationRenderer,
showSearch = false,
searchPosition = "top",
searchPlaceholder = "Search...",
searchDebounceMs = 300,
customSearchRenderer,
onSearchChange,
showRowSelection = false,
selectionMode = "multiple",
selectedRows = [],
onSelectionChange,
showSelectAll = true,
sortable = false,
defaultSort,
onSortChange,
multiSort = false,
showFilters = false,
filterPosition = "top",
filters = [],
onFilterChange,
loading = false,
loadingRenderer,
emptyMessage = "No data available",
emptyRenderer,
className,
tableClassName,
rowHeight = "auto",
striped = false,
bordered = true,
hoverable = true,
}: DataTableProps<T>) {
// State management for 15+ different states
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [searchQuery, setSearchQuery] = useState("");
const [selectedRowsState, setSelectedRowsState] = useState<T[]>(selectedRows);
const [sortConfig, setSortConfig] = useState(defaultSort);
const [activeFilters, setActiveFilters] = useState<ActiveFilters<T>>({});
// Complex conditional logic for rendering different parts
const renderPagination = () => {
if (!showPagination) return null;
if (customPaginationRenderer) {
return customPaginationRenderer();
}
return (
<div className={paginationClassName}>
{/* Default pagination implementation */}
</div>
);
};
const renderSearch = () => {
if (!showSearch) return null;
if (customSearchRenderer) {
return customSearchRenderer();
}
return <div>{/* Default search implementation */}</div>;
};
// Massive return statement with nested conditionals
return (
<div className={className}>
{loading && (loadingRenderer || <DefaultLoader />)}
{showSearch && searchPosition === "left" && renderSearch()}
{showPagination && paginationPosition === "top" && renderPagination()}
{showFilters && filterPosition === "top" && <FilterBar />}
{showSearch && searchPosition === "right" && renderSearch()}
<table className={tableClassName}>
<Header />
<tbody>
{data.length === 0
? emptyRenderer || <div>{emptyMessage}</div>
: data.map((row, index) => (
<React.Fragment key={index}>
<tr>
{showRowSelection && (
<td>
<Checkbox />
</td>
)}
{columns.map((col) => (
<td key={String(col.field)}>{/* ... */}</td>
))}
</tr>
</React.Fragment>
))}
</tbody>
</table>
{showPagination && paginationPosition === "bottom" && renderPagination()}
</div>
);
}
Visualizing the Complexity
These are the number of decisions the component has to take and then update its view based on each of those conditions
The Problems with this approach
1. Cognitive overload from Prop Hell
Developers are forced to juggle 20+ props just to get the component working. While TypeScript’s IntelliSense offers some guidance, it doesn’t eliminate the mental burden. Finding the right combination of props often turns into a tedious back-and-forth between the editor and the documentation.
2. Multiple conditional statements
The component is filled with numerous conditional statements, which makes it highly unpredictable and opens up areas for future bugs.
Let’s take a look at how many conditional checks are required to decide what, and where to render for the Pagination component:
// FIRST CHECK: What to render?
const renderPagination = () => {
if (!showPagination) return null;
if (customPaginationRenderer) {
return customPaginationRenderer({ /* ... */ });
}
return <div className={paginationClassName}>{/* pagination UI */}</div>;
};
return (
<div>
{/* SECOND CHECK: Top position */}
{showPagination && paginationPosition === 'top' && renderPagination()}
<table>{/* table content */}</table>
{/* THIRD CHECK: Bottom position */}
{showPagination && paginationPosition === 'bottom' && renderPagination()}
</div>
);
3 checks only to render a simple pagination component 😢
3. Lack of customisability
Need to show pagination above the search bar? You’ll have to introduce yet another prop showPaginationOnTopOfSearch and wire it up with some complex conditional logic.
{showPagination && paginationPosition === "top" && showPaginationOnTopOfSearch && renderPagination()}
{showSearch && searchPosition === "left" && renderSearch()}
{showPagination && paginationPosition === "top" && !showPaginationOnTopOfSearch && renderPagination()}
Want to insert a custom button between the search and pagination? Another prop like customContentBetweenSearchAndPagination.
Every layout variation requires a new flag.
4. Maintenance and testing nightmare
Adding a new feature means adding more props
Changing behavior means updating complex conditional logic
Deprecating features is nearly impossible without breaking changes
The component file grows to thousands of lines
With 20+ boolean flags, you have potentially hundreds of combinations to test. While not all are valid, testing even a subset of meaningful combinations becomes impossible.
5. Increased Risk of AI Hallucination
As AI-assisted coding becomes the norm, having a component API with dozens of loosely related props dramatically increases the chance of AI hallucinations. The more options and conditional behaviors a component exposes, the higher the likelihood that AI will hallucinate around it.
The Compound Component Pattern: A Better Way
The compound component pattern inverts the control. Instead of the component deciding what to render based on flags, the consumer composes the UI by placing components where they want them.
Core Principles
Composition Over Configuration: Build UIs by composing components rather than configuring a monolithic component
Inversion of Control: Consumers control the layout and structure
Single Responsibility: Each sub-component has a clear, focused purpose
The Compound DataTable
Here's how the same DataTable will look with the compound pattern:
The parent component manages shared state, and child components access that state through context.
DataTableContext
// contexts/DataTableContext.tsx
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
interface DataTableContextValue<T> {
data: T[];
filteredData: T[];
selectedRows: T[];
currentPage: number;
pageSize: number;
sortConfig: SortConfig<T> | null;
searchQuery: string;
// Actions
setSearchQuery: (query: string) => void;
toggleRowSelection: (row: T) => void;
selectAllRows: () => void;
deselectAllRows: () => void;
setPage: (page: number) => void;
setPageSize: (size: number) => void;
setSortConfig: (config: SortConfig<T> | null) => void;
// Computed values
totalPages: number;
hasSelection: boolean;
isAllSelected: boolean;
}
const DataTableContext = createContext<DataTableContextValue(null);
export function useDataTable<T>() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error(
"DataTable compound components must be used within DataTable.Root"
);
}
return context as DataTableContextValue<T>;
}
Root
This component holds all the common states and their action handlers. It passes those to the children through the DataTableContext defined above.
interface DataTableRootProps<T> {
data: T[];
children: ReactNode;
getRowId?: (row: T) => string | number;
onSelectionChange?: (selected: T[]) => void;
}
function DataTableRoot<T>({
data,
children,
getRowId = (_row, index) => index,
onSelectionChange,
}: DataTableRootProps<T>) {
const [selectedRows, setSelectedRows] = useState<T[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [sortConfig, setSortConfig] = useState<SortConfig<T> | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const filteredData = useMemo(() => {
let result = [...data];
if (searchQuery) {
result = result.filter((row) =>
Object.values(row).some((value) =>
String(value).toLowerCase().includes(searchQuery.toLowerCase())
)
);
}
// Apply sorting if configured
if (sortConfig) {
result.sort((a, b) => {
const aVal = a[sortConfig.field];
const bVal = b[sortConfig.field];
const modifier = sortConfig.direction === "asc" ? 1 : -1;
return aVal > bVal ? modifier : -modifier;
});
}
return result;
}, [data, searchQuery, sortConfig]);
const totalPages = Math.ceil(filteredData.length / pageSize);
const toggleRowSelection = useCallback(
(row: T) => {
setSelectedRows((prev) => {
const isSelected = prev.includes(row);
const newSelection = isSelected
? prev.filter((r) => r !== row)
: [...prev, row];
onSelectionChange?.(newSelection);
return newSelection;
});
},
[onSelectionChange]
);
const selectAllRows = useCallback(() => {
setSelectedRows(filteredData); // currently storing the entire row data, can be updated to just ID
onSelectionChange?.(filteredData);
}, [filteredData, onSelectionChange]);
const deselectAllRows = useCallback(() => {
setSelectedRows([]);
onSelectionChange?.([]);
}, [onSelectionChange]);
const value: DataTableContextValue<T> = {
data,
filteredData,
selectedRows,
currentPage,
pageSize,
sortConfig,
searchQuery,
setSearchQuery,
toggleRowSelection,
toggleRowExpansion,
selectAllRows,
deselectAllRows,
setPage: setCurrentPage,
setPageSize,
setSortConfig,
totalPages,
hasSelection: selectedRows.length > 0,
isAllSelected:
selectedRows.length === filteredData.length && filteredData.length > 0,
};
return (
<DataTableContext.Provider value={value}>
<div className="data-table">{children}</div>
</DataTableContext.Provider>
);
}
useReducer hook and define actions to update the state instead.Let’s look into the child components, which will be building blocks for the main table

Search
interface SearchProps {
placeholder?: string;
debounceMs?: number;
className?: string;
}
function Search({
placeholder = "Search...",
debounceMs = 300,
className,
}: SearchProps) {
const { searchQuery, setSearchQuery } = useDataTable();
const debouncedUpdate = useMemo(()=>debounce(setSearchQuery, debounceMs),[debounceMs])
return (
<input
type="text"
onChange={debouncedUpdate}
placeholder={placeholder}
className={clsx("data-table-search", className)}
/>
);
}
HeaderCell
interface HeaderCellProps<T> {
column: Column<T>;
sortable?: boolean;
}
function HeaderCell<T>({ column, sortable = false }: HeaderCellProps<T>) {
const { sortConfig, setSortConfig } = useDataTable<T>();
const handleSort = () => {
if (!sortable) return;
const newDirection =
sortConfig?.field === column.field && sortConfig.direction === "asc"
? "desc"
: "asc";
setSortConfig({ field: column.field, direction: newDirection });
};
const isSorted = sortConfig?.field === column.field;
return (
<th onClick={handleSort} className={sortable ? "sortable" : ""}>
{column.label}
{sortable && isSorted && (
<span>{sortConfig?.direction === "asc" ? " ↑" : " ↓"}</span>
)}
</th>
);
}
Cell
interface CellProps<T> {
row: T;
column: Column<T>;
}
function Cell<T>({ row, column }: CellProps<T>) {
const value = row[column.field];
const displayValue = column.render
? column.render(value, row)
: String(value);
return <td>{displayValue}</td>;
}
Row
interface RowProps<T> {
row: T;
children: ReactNode;
onClick?: (row: T) => void;
className?: string | ((row: T) => string);
}
function Row<T>({ row, children, onClick, className }: RowProps<T>) {
const { selectedRows } = useDataTable<T>();
const selected = selectedRows.some((selRow) => selRow.id === row.id);
const computedClassName =
typeof className === "function" ? className(row, selected) : className;
return (
<tr className={clsx(computedClassName, "table-row")} onClick={() => onClick?.(row)}
data-selected={selected}
>
{children}
</tr>
);
}
TableView component
It is composed of HeaderCell, Cell, and Row
interface TableProps<T> {
columns: Column<T>[];
className?: string;
}
function Table<T>({ columns, className }: TableProps<T>) {
const { filteredData, currentPage, pageSize } = useDataTable<T>();
const startIndex = (currentPage - 1) * pageSize;
const currentPageData = useMemo(
() => filteredData.slice(startIndex, startIndex + pageSize),
[startIndex, pageSize, filteredData]
);
return (
<table className={`data-table-table ${className ?? ""}`}>
<thead>
<tr>
{columns.map((col) => (
<DataTable.HeaderCell key={String(col.field)} column={col} />
))}
</tr>
</thead>
<tbody>
{currentPageData.map((row, index) => (
<DataTable.Row
key={row.id}
row={row}
>
{columns.map((col) => (
<DataTable.Cell key={String(col.field)} row={row} column={col} />
))}
</DataTable.Row>
))}
</tbody>
</table>
);
}
Pagination
function Pagination() {
const { currentPage, totalPages, setPage } = useDataTable();
const handlePrev = () => {
setPage(Math.max(currentPage - 1, 0))
}
const handleNext = () => {
setPage(Math.min(currentPage + 1, totalPages))
}
return (
<div className="data-table-pagination">
<button
disabled={currentPage === 1}
onClick={handlePrev}
>
Prev
</button>
<span>
Page {currentPage} of {totalPages}
</span>
<button
disabled={currentPage === totalPages}
onClick={handleNext}
>
Next
</button>
</div>
);
}
PageSizeSelector
interface PageSizeSelectorProps {
options?: number[];
}
function PageSizeSelector({
options = [10, 25, 50, 100],
}: PageSizeSelectorProps) {
const { pageSize, setPageSize } = useDataTable();
return (
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="data-table-page-size"
>
{options.map((size) => (
<option key={size} value={size}>
{size} per page
</option>
))}
</select>
);
}
SelectionCell
interface SelectionCellProps<T> {
row: T;
}
function SelectionCell<T>({ row }: SelectionCellProps<T>) {
const { selectedRows, toggleRowSelection } = useDataTable<T>();
const isSelected = selectedRows.includes(row);
return (
<td className="data-table-selection-cell">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRowSelection(row)}
/>
</td>
);
}
SelectedRowsCount
function SelectedCount() {
const { selectedRows } = useDataTable();
if (selectedRows.length === 0) return null;
return (
<div className="data-table-selected-count">
{selectedRows.length} row{selectedRows.length !== 1 ? "s" : ""} selected
</div>
);
}
Compositional API
A common and elegant practice is to expose related subcomponents as static properties of the main component, allowing intuitive access patterns like DataTable.Pagination
export const DataTable = {
Root: DataTableRoot,
Search,
Table,
HeaderCell,
Cell,
Row,
Pagination,
PageSizeSelector,
SelectionCell,
SelectedCount,
};
Usage: The Beautiful Composition
Now, let’s look at how clean and flexible the usage becomes. We can compose any view using the building blocks we wrote above.
Example 1: Simple table with pagination

<DataTable.Root data={tasks}>
<DataTable.Table columns={columns} />
<DataTable.Pagination />
</DataTable.Root>
Example 2: Custom layout - Search on the left, pagination on the right

<DataTable.Root data={tasks}>
<div className="flex justify-between">
<DataTable.Search />
<DataTable.Pagination />
</div>
<DataTable.Table columns={columns} />
</DataTable.Root>
Example 3: Table with search and selection

<DataTable.Root data={users} onSelectionChange={handleSelection}>
<div>
<DataTable.Search placeholder="Search tasks..." />
<DataTable.SelectedCount />
</div>
<DataTable.Table columns={userColumns} />
</DataTable.Root>
When (and When Not) to Use the Compound Component Pattern?
Not everything needs to be compound or composed, and remember over-decomposition can make a component API more complex than necessary.
When to use?
Do consumers need layout flexibility?
If different use cases require different positions or arrangements, make it compound.
Example:
// Some might want pagination at top
<DataTable.Root data={orders}>
<DataTable.Pagination />
<DataTable.Table columns={columns} />
</DataTable.Root>
// Some at bottom
<DataTable.Root data={orders}>
<DataTable.Table columns={columns} />
<DataTable.Pagination />
</DataTable.Root>
Is it optional functionality?
If not all consumers need the feature, make it opt-in through composition rather than a boolean flag. This keeps the API clean and supports tree-shaking.Example:
Instead of:<Dialog showCloseButton showBackdrop showFooter />Prefer:
<Dialog> <Dialog.Backdrop /> <Dialog.Content> <Dialog.CloseButton /> <Dialog.Footer /> </Dialog.Content> </Dialog>Does it have multiple valid implementations?
If consumers might want to customize or completely replace sections with their own implementations, make it compound so they can replace individual pieces.Example:
EmptyStateof Table// Default empty state <DataTable.Root data={[]}> <DataTable.Table columns={columns} /> <DataTable.Empty> <p>No results found</p> </DataTable.Empty> </DataTable.Root>Is there a clear parent–child relationship?
Compound components shine when children rely on shared context from the parent but remain decoupled from each other.
Example: Tabs and TabList both depend on a shared active tab state.
Do you want to enforce some defaults like proper accessibility?
Example: All Radix primitives are inherently accessible, ensuring that consumers create accessible components by default without any additional effort.
Decision Tree
When not to use?
The component is simple - a button with 2-3 props doesn't need this pattern
Structure is fixed - if there's only one valid layout, props are fine
You're building application components - not every component needs this flexibility
It's already an atomic component - icons, badges, labels would rarely benefit from composition
Architecture Comparison
Radix UI: Real-World Implementation
Radix UI is one of the best examples of the compound pattern in production. Let's examine how they implement common components:
Select Component
import * as Select from "@radix-ui/react-select";
<Select.Root value={value} onValueChange={setValue}>
<Select.Trigger>
<Select.Value placeholder="Select an option..." />
<Select.Icon>▼</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content>
<Select.ScrollUpButton>▲</Select.ScrollUpButton>
<Select.Viewport>
<Select.Group>
<Select.Label>Fruits</Select.Label>
<Select.Item value="apple">
<Select.ItemText>Apple</Select.ItemText>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
<Select.Item value="banana">
<Select.ItemText>Banana</Select.ItemText>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.Label>Vegetables</Select.Label>
<Select.Item value="carrot">
<Select.ItemText>Carrot</Select.ItemText>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton>▼</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>;
Why This Works Brilliantly
Complete Control: Want the scroll buttons? Include them. Don't need grouping? Skip
Select.Group.Accessibility Built-in: Each component inherently handles ARIA attributes and keyboard navigation
Customizable: Want a different indicator? Replace
Select.ItemIndicatorwith anything.Portal Support:
Select.Portallets you render content outside the DOM hierarchy for z-index issues.
Compare this to a prop-based approach:
// Traditional approach would need:
<Select
options={options}
showScrollButtons={true}
groupBy="category"
customTrigger={(props) => <CustomTrigger />}
customItem={(option, props) => <CustomItem />}
customIndicator={<CustomIndicator />}
portalTarget={document.body}
showSeparators={true}
// ... 20 more props
/>
Conclusion
The Compound Component Pattern represents a paradigm shift in React component design. Instead of fighting against growing complexity by adding more and more props and conditionals, we embrace composition and give control back to the consumer.
The Mental Model
Compound component thinking:
"How do I break this into composable pieces that users can arrange?"
Final Thoughts
The compound component pattern isn't a silver bullet. For simple components, traditional props work great. But for complex, feature-rich components like data tables, forms, dialogs, and navigation menus, the compound pattern offers unmatched flexibility and maintainability.
References
Composition Is All You Need | Fernando Rojo (Must watch! This is what inspired me to write the blog.)
Radix UI Documentation (Check internal implemenation of the components in Github, it’s a goldmine)
Happy composing! 🎨



