Composition vs Abstraction in JavaScript: When to Use Each for Maximum Maintainability
Your DropdownButton just needs a simple feature, but the abstraction makes it impossible to extend. Sound familiar? I tried adding a custom header with an avatar to our team’s dropdown component, and suddenly I was fighting the abstraction layer instead of building features.
The problem wasn’t the code itself—it was that we chose abstraction over composition when we should have done the opposite. Composition usually scales better than piling responsibilities into one component, yet developers keep reaching for abstraction first.
Let me show you why composition preserves flexibility while abstraction can create maintainability debt, and when to actually use each approach.
Understanding the Fundamental Difference
Composition: Building by combining existing pieces (like LEGO blocks) Abstraction: Simplifying by hiding complexity (like a car’s dashboard)
The key distinction is simple: Composition is additive, abstraction is reductive.
Composition: A + B + C = DAbstraction: A → B (where B hides A)With composition, you can see all the pieces working together. With abstraction, you’re working through a black box that might be hiding exactly what you need to modify.
When to Prefer Composition (The 80% Solution)
Most of the time, composition is the right choice. I learned this the hard way after over-engineering our team’s authentication system.
Scenario 1: Component Building in React
Here’s what I tried first (the wrong approach):
// Bad: Over-abstractionclass DropdownButton extends React.Component { renderHeader() { /* Complex logic */ } renderFooter() { /* More complex logic */ } renderSpecialItems() { /* Even more logic */ } render() { return ( <div> {this.renderHeader()} <button onClick={this.onClick}>{this.props.label}</button> {this.renderFooter()} {this.renderSpecialItems()} </div> ) }}When the product team asked for dropdowns with custom headers, avatars, and dynamic footers, this abstraction collapsed. I couldn’t extend it without either:
- Modifying the core class (breaking existing components)
- Creating another abstraction (creating a tower of abstractions)
The composition approach solved this completely:
// Good: Compositionconst Dropdown = ({ children }) => ( <div className="dropdown"> {children} </div>)
const DropdownButton = ({ label, onClick }) => ( <Dropdown> <button onClick={onClick}>{label}</button> </Dropdown>)
// Flexible usage<Dropdown> <DropdownHeader>Welcome</DropdownHeader> <DropdownButton onClick={handleClick}>Save</DropdownButton> <DropdownFooter>© 2024</DropdownFooter></Dropdown>Now when the team asked for avatars in headers? I just added:
<Dropdown> <DropdownHeader> <Avatar src={user.avatar} /> <span>Welcome, {user.name}</span> </DropdownHeader> <DropdownButton onClick={handleClick}>Save</DropdownButton></Dropdown>Scenario 2: Function Composition
The same principle applies to JavaScript functions. I found myself rewriting similar data processing logic across multiple components:
// Abstraction: Black boxconst processUser = (user) => heavyInternalLogic(user)This failed me when I needed to add logging or timing. I couldn’t intercept the “black box.
Composition gave me control:
// Composition: Chaining operationsconst processUser = (user) => validateUser(user) .then(extractProfile) .then(addPermissions) .then(cacheData)Now I could easily add middleware:
const processUserWithLogging = (user) => validateUser(user) .then(data => { console.log('Validating user:', user.id) return data }) .then(extractProfile) .then(addPermissions) .then(cacheData)When Abstraction Actually Helps (The 20% Case)
There are legitimate cases for abstraction, but they’re rare in most applications.
Legitimate Abstraction Examples:
- External API Integration:
fetchUser(userId)hides HTTP complexity - Database Operations:
saveUser(user)hides SQL/NoSQL details - Complex Algorithms:
calculateTax(income)hides tax formula complexity
These work because the complexity is genuinely external and stable. You don’t need to modify the HTTP protocol or tax algorithms regularly.
Red Flag Abstraction Examples:
- Premature Optimization:
getUserData()that just callsfetchUser() - Interface Duplication: Similar methods with tiny variations
- Conditional Complexity:
renderMode()that switches behavior arbitrarily
I fell into this trap when I created:
// Over-abstraction for "future needsconst UserManager = { // Complex class for a simple feature getInstance: () => new UserManager(), validateEmail: (email) => { /* regex */ }, hashPassword: (password) => { /* bcrypt */ }, sendWelcomeEmail: (user) => { /* email service */ }, // ... 20+ methods we might never use}Six months later, we only needed email validation. The UserManager was just extra complexity waiting to break.
The Overengineering Detection Framework
Here are the signs that your abstraction has gone too far:
- ❌ “I’ll need this someday” comments
- ❌ 10+ parameters that 90% of users don’t need
- ❌ Public methods that are only used internally
- ❌ Configuration that controls fundamental behavior
- ❌ Classes with protected/private methods that aren’t actually complex
The Clarity Test:
“Can I explain WHY this abstraction exists to a junior developer in 30 seconds?
If not, it’s likely hiding intent rather than reducing duplication.
Practical Implementation Patterns
React Composition Pattern:
I use this pattern for all reusable UI components:
// Base component provides structureconst Modal = ({ children, onClose }) => ( <div className="modal-overlay" onClick={onClose}> <div className="modal-content" onClick={e => e.stopPropagation()}> {children} </div> </div>)
// Flexible composition<Modal onClose={handleClose}> <ModalHeader>Confirm Delete</ModalHeader> <ModalBody>Are you sure you want to delete this item?</ModalBody> <ModalFooter> <CancelButton onClick={handleCancel}>Cancel</CancelButton> <DeleteButton onClick={handleDelete}>Delete</DeleteButton> </ModalFooter></Modal>This approach naturally leads to smaller, focused components that are easier to test and maintain.
JavaScript Utility Composition:
// Composition creates predictable building blocksconst withLogging = (fn) => (...args) => { console.log(`Calling ${fn.name}`, args) return fn(...args)}
const withTiming = (fn) => (...args) => { const start = performance.now() const result = fn(...args) const end = performance.now() console.log(`${fn.name} took ${end - start}ms`) return result}
// Flexible combinationconst enhancedProcess = withTiming(withLogging(processUser))This pattern gives me decorators that I can apply or remove independently.
Performance and Maintainability Comparison
Composition Benefits:
- ✅ Easier to test individual components
- ✅ Natural extensibility
- ✅ Clear data flow
- ✅ Reusable building blocks
- ✅ Predictable behavior
Abstraction Benefits:
- ✅ Simplified public interface
- ✅ Encapsulation of complexity
- ✅ Consistent behavior
- ✅ Reduced cognitive load (when done right)
In practice, the maintainability benefits of composition far outweigh the interface simplification of abstraction for most applications.
Case Study: The DropdownButton Revisited
Problem: Original abstraction couldn’t handle:
- Custom headers with avatars
- Dynamic footers with buttons
- Specialized menu items with icons
Solution: Composition pattern
// Before: Rigid and hard to extendconst DropdownButton = { render() { /* Fixed structure */ }}
// After: Flexible and composableconst Dropdown = ({ children }) => <div className="dropdown">{children}</div>const DropdownItem = ({ icon, children }) => ( <div className="dropdown-item"> {icon && <span className="icon">{icon}</span>} {children} </div>)
// Usage patterns<Dropdown> <DropdownItem icon="👤">Profile</DropdownItem> <DropdownItem icon="⚙️">Settings</DropdownItem></Dropdown>Migration Strategy: From Abstraction to Composition
If you’re stuck with over-engineered abstraction, here’s how to migrate:
- Identify Hot Paths: Find components that need frequent extension
- Extract Common Patterns: What functionality is consistently needed?
- Create Building Blocks: Small, focused components/utils
- Gradual Migration: Refactor one abstraction at a time
- Measure Impact: Test both performance and maintainability
I typically start by creating the composed version alongside the old abstraction, then migrate usage piece by piece.
Conclusion: Choose Composition by Default
Key Takeaway: Start with composition and only introduce abstraction when it provides clear value. The most maintainable code is code that’s easy to understand, extend, and modify without introducing unexpected behavior.
Final Rule: When in doubt, default to composition. You can always extract abstraction later, but recovering from over-engineered abstraction is much harder.
Remember that code has two audiences:
- The machine: It needs to run correctly
- Your coworkers: They need to understand and maintain it
Composition makes the second audience’s job easier without sacrificing functionality.
Apply these principles consistently, and you’ll avoid the abstraction trap that makes so many JavaScript applications impossible to maintain.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Reddit Discussion on Composition
- 👨💻 React Composition Documentation
- 👨💻 SOLID Principles Explained
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments