Composition: The Foundation of Modern React
Learn how composition over inheritance creates more flexible, maintainable, and powerful React components. Master the art of building complex UIs from simple, reusable pieces.
๐ Composition vs Inheritance
React's composition model is an absolute superpower! ๐ช Forget class hierarchies and inheritance chains โ composition lets you snap components together like building blocks. It's the reason React scales so beautifully! ๐๏ธ
๐ด Impact: CRITICAL โ This is THE foundational concept of React. Get this right and everything else clicks into place! ๐ง
๐ In this section: Containment pattern โข Multiple slots โข Specialization โข Why Facebook chose composition
Containment
Some components don't know their children ahead of time. This is especially common for components like Sidebar or Dialog that represent generic "boxes". We recommend that such components use the special children prop to pass children elements directly into their output:
function FancyBorder(props) {return (<div className={'FancyBorder FancyBorder-' + props.color}>{props.children}</div>);}function WelcomeDialog() {return (<FancyBorder color="blue"><h1 className="Dialog-title">Welcome</h1><p className="Dialog-message">Thank you for visiting our spacecraft!</p></FancyBorder>);}function App() { return <WelcomeDialog />; }export default App;
Multiple "Holes"
While this is less common, sometimes you might need multiple "holes" in a component. In such cases you may come up with your own convention instead of using children:
function SplitPane(props) {return (<div className="SplitPane"><div className="SplitPane-left">{props.left}</div><div className="SplitPane-right">{props.right}</div></div>);}function Contacts() { return <div style={{ padding: 8 }}>Contacts (mock)</div>; }function Chat() { return <div style={{ padding: 8 }}>Chat (mock)</div>; }function App() {return (<SplitPaneleft={<Contacts />}right={<Chat />} />);}export default App;
Specialization
Sometimes we think about components as being "special cases" of other components. For example, we might say that a WelcomeDialog is a special case of Dialog. In React, this is also achieved by composition:
function FancyBorder(props) {return (<div className={'FancyBorder FancyBorder-' + props.color}>{props.children}</div>);}function Dialog(props) {return (<FancyBorder color="blue"><h1 className="Dialog-title">{props.title}</h1><p className="Dialog-message">{props.message}</p></FancyBorder>);}function WelcomeDialog() {return (<Dialogtitle="Welcome"message="Thank you for visiting our spacecraft!" />);}function App() { return <WelcomeDialog />; }export default App;
Composition works equally well for components defined as classes:
function FancyBorder(props) {return (<div className={'FancyBorder FancyBorder-' + props.color}>{props.children}</div>);}function Dialog(props) {return (<FancyBorder color="blue"><h1 className="Dialog-title">{props.title}</h1><p className="Dialog-message">{props.message}</p>{props.children}</FancyBorder>);}class SignUpDialog extends React.Component {constructor(props) {super(props);this.handleChange = this.handleChange.bind(this);this.handleSignUp = this.handleSignUp.bind(this);this.state = {login: ''};}render() {return (<Dialog title="Mars Exploration Program" message="How should we refer to you?"><input value={this.state.login} onChange={this.handleChange} /><button onClick={this.handleSignUp}>Sign Me Up!</button></Dialog>);}handleChange(e) {this.setState({login: e.target.value});}handleSignUp() {alert(`Welcome aboard, ${this.state.login}!`);}}function App() { return <SignUpDialog />; }export default App;
So What About Inheritance?
At Facebook, we use React in thousands of components, and we haven't found any use cases where we would recommend creating component inheritance hierarchies. Props and composition give you all the flexibility you need to customize a component's look and behavior in an explicit and safe way.
๐ง Thinking in Components: Avoiding Conditional Rendering Hell
Ever seen JSX that looks like a nested if-else nightmare? ๐ฑ That's conditional rendering hell! The real power of React is composing components together โ and once you learn to think in components, you'll never write spaghetti JSX again! ๐โก๏ธโจ
๐ Impact: HIGH โ Clean conditional rendering is what separates readable codebases from unmaintainable ones. Your PR reviewers will love you! ๐
๐ In this section: Conditional rendering problems โข Early returns pattern โข Layout extraction โข TypeScript type narrowing
The Problem with Conditional Rendering
Conditional rendering inside JSX starts to become a problem when we use it for rendering different states of a component. Consider this self-contained component:
const MOCK_DATA = { assignee: { name: 'Alex' }, content: [{ id: 1, name: 'Milk' }, { id: 2, name: 'Bread' }] };const useQuery = () => ({ data: MOCK_DATA, isPending: false });const Card = ({ children }: any) => <div style={{ border: '1px solid #333', padding: 16 }}>{children}</div>;const CardHeading = ({ children }: any) => <h3>{children}</h3>;const CardContent = ({ children }: any) => <div>{children}</div>;const UserInfo = ({ name }: any) => <p>Assignee: {name}</p>;const Skeleton = () => <div>Loading...</div>;const ShoppingItem = ({ name }: any) => <li>{name}</li>;function ShoppingList() {const { data, isPending } = useQuery();return (<Card><CardHeading>Welcome ๐</CardHeading><CardContent>{data?.assignee ? <UserInfo {...data.assignee} /> : null}{isPending ? <Skeleton /> : null}{data ? data.content.map((item) => <ShoppingItem key={item.id} {...item} />) : null}</CardContent></Card>);}function App() { return <ShoppingList />; }export default App;
When we add more states, like an empty screen, the conditional logic becomes complex and hard to read:
const MOCK_DATA = { assignee: { name: 'Sam' }, content: [{ id: 1, name: 'Eggs' }] };const useQuery = () => ({ data: MOCK_DATA, isPending: false });const Card = ({ children }: any) => <div style={{ border: '1px solid #333', padding: 16 }}>{children}</div>;const CardHeading = ({ children }: any) => <h3>{children}</h3>;const CardContent = ({ children }: any) => <div>{children}</div>;const UserInfo = ({ name }: any) => <p>Assignee: {name}</p>;const Skeleton = () => <div>Loading...</div>;const EmptyScreen = () => <p>No items</p>;const ShoppingItem = ({ name }: any) => <li>{name}</li>;function ShoppingList() {const { data, isPending } = useQuery();return (<Card><CardHeading>Welcome ๐</CardHeading><CardContent>{data?.assignee ? <UserInfo {...data.assignee} /> : null}{isPending ? <Skeleton /> : null}{!data && !isPending ? <EmptyScreen /> : null}{data ? data.content.map((item) => <ShoppingItem key={item.id} {...item} />) : null}</CardContent></Card>);}function App() { return <ShoppingList />; }export default App;
Solution: Early Returns with Composition
The solution is to extract the layout and use early returns. This makes each state clear and easy to understand:
const Card = ({ children }: any) => <div style={{ border: '1px solid #333', padding: 16 }}>{children}</div>;const CardHeading = ({ children }: any) => <h3>{children}</h3>;const CardContent = ({ children }: any) => <div>{children}</div>;const Skeleton = () => <div>Loading...</div>;const EmptyScreen = () => <p>No items</p>;const UserInfo = ({ name }: any) => <p>Assignee: {name}</p>;const ShoppingItem = ({ name }: any) => <li>{name}</li>;const MOCK_DATA = { assignee: { name: 'Jordan' }, content: [{ id: 1, name: 'Apples' }, { id: 2, name: 'Oranges' }] };const useQuery = () => ({ data: MOCK_DATA, isPending: false });function Layout(props: { children: React.ReactNode }) {return (<Card><CardHeading>Welcome ๐</CardHeading><CardContent>{props.children}</CardContent></Card>);}function ShoppingList() {const { data, isPending } = useQuery();if (isPending) return <Layout><Skeleton /></Layout>;if (!data) return <Layout><EmptyScreen /></Layout>;return (<Layout>{data.assignee ? <UserInfo {...data.assignee} /> : null}{data.content.map((item) => <ShoppingItem key={item.id} {...item} />)}</Layout>);}function App() { return <ShoppingList />; }export default App;
Key Benefits
- Reduced cognitive load: Clear path for developers to follow, nothing is nested
- Easy to extend: Add more conditions without breaking other states
- Better type inference: TypeScript knows data is defined after handling the empty case
๐ The Future of React: Enhancing Components through Composition Pattern
Say goodbye to prop hell! ๐ The Composition Pattern is a total game-changer that turns 20-prop monsters into elegant, composable pieces. It's like going from a Swiss Army knife to a modular toolkit โ each piece does one thing perfectly! ๐ ๏ธโจ
๐ Impact: HIGH โ This pattern is used by Material UI, Radix, and every major component library. Learn it and you're thinking like the pros! ๐
๐ In this section: Prop hell anti-pattern โข Breaking down components โข Sub-component composition โข Real-world CTA button example
The Traditional Approach (Prop Hell)
In traditional component architecture, it's common to pass numerous props throughout the component tree. This can easily lead to "prop hell" as requirements evolve:
const Icon = ({ name, size, color }: any) => <span style={{ marginRight: 4, marginLeft: 4, fontSize: size }}>{name}</span>;interface CtaButtonProps extends React.ComponentProps<'button'> {variant?: 'primary' | 'secondary';size?: 'small' | 'large';iconNameLeft?: string;iconNameRight?: string;iconColorLeft?: string;iconColorRight?: string;iconSizeLeft?: number;iconSizeRight?: number;text: string;}const CtaButton: React.FC<CtaButtonProps> = ({variant = 'primary',size = 'medium',iconNameLeft,iconNameRight,iconColorLeft,iconColorRight,iconSizeLeft,iconSizeRight,text,className = '',...props}) => {const classes = `btn ${variant} ${size} ${className}`;return (<button className={classes} {...props}>{iconNameLeft && <Icon name={iconNameLeft} size={iconSizeLeft} color={iconColorLeft} />}<span>{text}</span>{iconNameRight && <Icon name={iconNameRight} size={iconSizeRight} color={iconColorRight} />}</button>);};function App() {return (<CtaButtoniconNameLeft="+"iconColorLeft="red"iconNameRight="+"iconColorRight="green"iconSizeLeft={20}iconSizeRight={30}text="Add more"/>);}export default App;
The Composition Pattern Solution
Break down components into smaller, manageable pieces that can be composed together:
const Icon = ({ name, color, size }: any) => <span style={{ marginRight: 4, fontSize: size }}>{name}</span>;interface CtaRootProps extends React.ComponentProps<'button'> {variant?: 'primary' | 'secondary';size?: 'small' | 'large';children?: React.ReactNode;}const CtaRoot: React.FC<CtaRootProps> = ({ variant = 'primary', size = 'medium', className = '', children, ...props }) => (<button className={`btn ${variant} ${size} ${className}`} {...props}>{children}</button>);const CtaIcon = (props: any) => <Icon {...props} />;const CtaText = ({ children, ...props }: any) => <span {...props}>{children}</span>;const Cta = { Root: CtaRoot, Icon: CtaIcon, Text: CtaText };function App() {return (<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}><Cta.Root><Cta.Icon name="+" color="red" size={20} /><Cta.Text>Add more</Cta.Text></Cta.Root><Cta.Root><Cta.Text>Add more</Cta.Text><Cta.Icon name="+" color="green" size={20} /></Cta.Root><Cta.Root><Cta.Icon name="+" color="red" size={20} /><Cta.Text>Add more</Cta.Text><Cta.Icon name="+" color="green" size={30} /></Cta.Root></div>);}export default App;
Advantages of Composition Pattern
- Reduced Prop-Drilling: Each sub-component maintains its own props
- Explicit Control: Each sub-component can be included or excluded explicitly
- Easier Variants: Rearrange composed sub-components without additional props
- Clear Structure: Easy to understand component structure at a glance
๐๏ธ Understanding the Origins: Composition in Object-Oriented Programming
Time for a history lesson that actually matters! ๐ Composition over inheritance isn't just a React thing โ it's a battle-tested OOP principle. Let's explore it through Kotlin to understand WHY it works so well, and why the Gang of Four said 'favor composition' back in 1994! ๐
๐ต Impact: MEDIUM โ Understanding the roots of composition makes you a better architect in any language, not just React! ๐ณ
๐ In this section: OOP composition basics โข Kotlin delegation โข Encapsulation problems โข Multiple functionality reuse
Simple Behavior Reuse
Let's start with a simple problem: we have two classes with partially similar behavior. Many developers would extract this using inheritance, but composition is often better:
// โ Inheritance Approachabstract class LoaderWithProgressBar {fun load() {// show progress baraction()// hide progress bar}abstract fun action()}class ProfileLoader : LoaderWithProgressBar() {override fun action() {// load profile}}// โ Composition Approachclass ProgressBar {fun show() {/* show progress bar */}fun hide() {/* hide progress bar */}}class ProfileLoader {val progressBar = ProgressBar()fun load() {progressBar.show()// load profileprogressBar.hide()}}class ImageLoader {val progressBar = ProgressBar()fun load() {progressBar.show()// load imageprogressBar.hide()}}
Multiple Functionalities
Composition shines when you need to extract multiple pieces of functionality. You can only extend one class, but you can compose many:
class ImageLoader {private val progressBar = ProgressBar()private val finishedAlert = FinishedAlert()fun load() {progressBar.show()// load imageprogressBar.hide()finishedAlert.show()}}// With inheritance, you'd be forced to place both functionalities// in a single superclass, leading to complex hierarchies
Inheritance Breaks Encapsulation
When we extend a class, we depend not only on how it works from the outside but also on how it is implemented inside. This is why we say that inheritance breaks encapsulation:
// โ Inheritance - breaks encapsulationclass CounterSet<T> : HashSet<T>() {var elementsAdded: Int = 0private setoverride fun add(element: T): Boolean {elementsAdded++return super.add(element)}override fun addAll(elements: Collection<T>): Boolean {elementsAdded += elements.sizereturn super.addAll(elements)}}// Problem: HashSet.addAll() uses add() internally,// so counter is incremented twice!// โ Composition with Delegationclass CounterSet<T>(private val innerSet: MutableSet<T> = mutableSetOf()) : MutableSet<T> by innerSet {var elementsAdded: Int = 0private setoverride fun add(element: T): Boolean {elementsAdded++return innerSet.add(element)}override fun addAll(elements: Collection<T>): Boolean {elementsAdded += elements.sizereturn innerSet.addAll(elements)}}// Now it works correctly!
Key Differences
- Composition is more secure: We depend only on externally observable behavior
- Composition is more flexible: We can compose many objects, but extend only one class
- Composition is more explicit: We know exactly where methods come from
- Inheritance gives polymorphic behavior: But it's constraining - every subclass must be consistent
๐ฏ Key Takeaways
When to Use Composition
- When you need to reuse code between components
- When you want flexible, customizable components
- When you need multiple "holes" for different content
- When building generic container components
When to Use Inheritance
- When there's a definite "is a" relationship
- When building framework base classes (like React.Component)
- When you need strong polymorphic behavior
- When all subclasses should pass the same tests
Remember: In React, composition is almost always the better choice. Use props and composition to build flexible, maintainable components that can evolve with your application's needs.