Skip to content

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 = D
Abstraction: 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-abstraction
class 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:

  1. Modifying the core class (breaking existing components)
  2. Creating another abstraction (creating a tower of abstractions)

The composition approach solved this completely:

// Good: Composition
const 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 box
const 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 operations
const 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:

  1. External API Integration: fetchUser(userId) hides HTTP complexity
  2. Database Operations: saveUser(user) hides SQL/NoSQL details
  3. 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:

  1. Premature Optimization: getUserData() that just calls fetchUser()
  2. Interface Duplication: Similar methods with tiny variations
  3. Conditional Complexity: renderMode() that switches behavior arbitrarily

I fell into this trap when I created:

// Over-abstraction for "future needs
const 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 structure
const 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 blocks
const 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 combination
const 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 extend
const DropdownButton = {
render() { /* Fixed structure */ }
}
// After: Flexible and composable
const 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:

  1. Identify Hot Paths: Find components that need frequent extension
  2. Extract Common Patterns: What functionality is consistently needed?
  3. Create Building Blocks: Small, focused components/utils
  4. Gradual Migration: Refactor one abstraction at a time
  5. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments