As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let me tell you about something that changed how I write code. Years ago, I built web applications using deep chains of inheritance. I would create a base component, extend it, then extend those extensions. It seemed logical at the time—a neat way to organize related concepts. But eventually, I ran into problems. A change in a parent class would break features in seemingly unrelated parts of the application. Add…
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let me tell you about something that changed how I write code. Years ago, I built web applications using deep chains of inheritance. I would create a base component, extend it, then extend those extensions. It seemed logical at the time—a neat way to organize related concepts. But eventually, I ran into problems. A change in a parent class would break features in seemingly unrelated parts of the application. Adding a new behavior often meant deciding where in the inheritance tree it belonged, and that decision felt arbitrary. The code became rigid and frightening to modify.
This is the classic problem with inheritance in user interfaces. You start with a BaseButton. Then you need an IconButton, so you extend it. Later, you need a SubmitButton, so you extend BaseButton again. Now, what if you need an IconSubmitButton? Do you extend IconButton or SubmitButton? You might be forced to duplicate code or create awkward middle-ground classes. The hierarchy, which felt so organized, becomes a constraint.
// The inheritance trap I used to fall into
class Notification {
constructor(message) {
this.message = message;
}
display() {
console.log(`Notification: ${this.message}`);
}
}
class ToastNotification extends Notification {
display() {
super.display();
console.log('Displaying as toast');
}
}
class AlertNotification extends Notification {
display() {
super.display();
console.log('Displaying as alert');
}
}
// What about a ToastNotification that auto-dismisses?
// Do I extend ToastNotification? What if I also want it to be persistent sometimes?
class AutoDismissToastNotification extends ToastNotification {
constructor(message, duration) {
super(message);
this.duration = duration;
}
display() {
super.display();
console.log(`Will auto-dismiss in ${this.duration}ms`);
}
}
// The hierarchy is getting deep. Change is hard.
Composition offered a different path. Instead of asking "What is this thing?" (an "is-a" relationship), I started asking "What does this thing do?" or "What behaviors does it have?" (a "has-a" relationship). It’s the difference between saying "An AdminUserCard is a UserCard" and saying "An AdminUserCard has user data, has admin privileges, and has a state."
This mental shift was liberating. I began building small, independent pieces of logic that could be snapped together like Lego bricks. A component isn’t defined by its place in a family tree, but by the collection of capabilities I give it.
// A compositional approach: building with small pieces
const withLogging = (component) => ({
...component,
log: (action) => console.log(`[${component.name || 'Component'}]: ${action}`)
});
const withDataFetching = (url) => (component) => ({
...component,
async load() {
this.log?.(`Fetching from ${url}`);
const response = await fetch(url);
this.data = await response.json();
return this.data;
}
});
const withLocalStorage = (key) => (component) => ({
...component,
save() {
localStorage.setItem(key, JSON.stringify(this.data));
this.log?.('Saved to localStorage');
},
loadFromStorage() {
const stored = localStorage.getItem(key);
this.data = stored ? JSON.parse(stored) : null;
return this.data;
}
});
// Now, I assemble a component with the exact behaviors it needs.
const myDataComponent = withLogging(
withDataFetching('/api/users')(
withLocalStorage('userCache')({
name: 'UserFetcher'
})
)
);
// I can use it like this:
async function initialize() {
await myDataComponent.load(); // Fetches from API, logs action
myDataComponent.save(); // Saves to cache, logs action
}
In modern frameworks like React, this pattern is everywhere, even if you don’t see the classic "mixin" style. The most direct example is React Hooks. Hooks are, in essence, composable units of behavior. They let you add state, side effects, context, and more to a function component by simply calling them.
Let me show you what I mean. Before hooks, sharing logic between components often meant using patterns like Higher-Order Components or Render Props, which are also compositional. But hooks made composition feel native and straightforward.
// Here’s a simple custom hook. It's just a function.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Cleanup is part of the composed behavior
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array means this runs once on mount
return width;
}
// Another independent piece of logic
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]); // Re-runs only if the title changes
}
// Now, I compose these hooks freely in any component.
function MyResponsiveHeader() {
const screenWidth = useWindowWidth();
useDocumentTitle(`App (${screenWidth}px wide)`);
const isMobile = screenWidth < 768;
return (
<header>
<h1>My App</h1>
<p>You are on a {isMobile ? 'mobile' : 'desktop'} viewport.</p>
</header>
);
}
The beauty is in the isolation. useWindowWidth doesn’t know or care about document titles. useDocumentTitle doesn’t know about window resizing. I can test each of them independently. I can use useWindowWidth in a hundred components, and if I find a bug in it, I fix it in one place. This is the opposite of inheritance, where a bug in a base class can break every single subclass.
Let’s look at a more integrated example, like a form. In an inheritance model, you might have a BaseForm, then a UserForm, then a RegistrationForm. With composition, I think in terms of reusable form logic.
// A hook that manages the core state of a form
function useFormFields(initialState) {
const [fields, setFields] = useState(initialState);
const handleFieldChange = (fieldName, value) => {
setFields(prev => ({
...prev,
[fieldName]: value
}));
};
const reset = () => setFields(initialState);
return {
fields,
handleFieldChange,
reset
};
}
// A separate hook for form validation
function useFormValidation(fields, validationRules) {
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
Object.keys(validationRules).forEach(fieldName => {
const rule = validationRules[fieldName];
const value = fields[fieldName];
if (rule.required && !value) {
newErrors[fieldName] = 'This field is required';
}
if (rule.pattern && value && !rule.pattern.test(value)) {
newErrors[fieldName] = rule.message || 'Invalid format';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0; // Returns true if valid
};
return { errors, validate };
}
// Now, I compose them together in a component.
function UserRegistrationForm() {
// Compose state management
const { fields, handleFieldChange, reset } = useFormFields({
email: '',
password: ''
});
// Compose validation logic
const validationRules = {
email: { required: true, pattern: /^\S+@\S+$/i, message: 'Invalid email' },
password: { required: true, minLength: 8 }
};
const { errors, validate } = useFormValidation(fields, validationRules);
const handleSubmit = (event) => {
event.preventDefault();
if (validate()) {
console.log('Form is valid, submitting:', fields);
// Submit to an API...
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
value={fields.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
/>
{errors.email && <span style={{color: 'red'}}>{errors.email}</span>}
</div>
<div>
<label>Password:</label>
<input
type="password"
value={fields.password}
onChange={(e) => handleFieldChange('password', e.target.value)}
/>
{errors.password && <span style={{color: 'red'}}>{errors.password}</span>}
</div>
<button type="submit">Register</button>
<button type="button" onClick={reset}>Reset</button>
</form>
);
}
See what happened? The form component itself is mostly the presentation layer—the JSX. All the complex behavior (state management, validation) is composed from external, reusable hooks. If I need a new form, like a LoginForm, I can reuse useFormFields and useFormValidation. I might compose them with a new hook, useAuthSubmit, that handles login-specific API calls.
This pattern extends to component design itself, through patterns like "Compound Components." This is where a set of components work together implicitly, sharing state behind the scenes, giving you a lot of declarative power.
// A simple counter built with compound components
const CounterContext = React.createContext();
function Counter({ children }) {
const [count, setCount] = useState(0);
const value = { count, setCount };
return (
<CounterContext.Provider value={value}>
<div className="counter">{children}</div>
</CounterContext.Provider>
);
}
function CounterDisplay() {
const { count } = useContext(CounterContext);
return <span className="count">{count}</span>;
}
function CounterIncrement({ step = 1 }) {
const { setCount } = useContext(CounterContext);
return (
<button onClick={() => setCount(c => c + step)}>
Increment by {step}
</button>
);
}
function CounterDecrement({ step = 1 }) {
const { setCount } = useContext(CounterContext);
return (
<button onClick={() => setCount(c => c - step)}>
Decrement by {step}
</button>
);
}
// Now, I use them by composition, not configuration.
function App() {
return (
<Counter>
<h1>
Count: <CounterDisplay />
</h1>
<CounterIncrement step={5} />
<CounterDecrement step={2} />
{/* I could put other components here, order doesn't matter to logic */}
<p>This is a flexible counter UI.</p>
</Counter>
);
}
In this pattern, Counter, CounterDisplay, CounterIncrement, and CounterDecrement are designed to work together. The state is shared via context, but the user of these components (the App function) composes the structure and layout. I have control over the markup. This is far more flexible than a monolithic <Counter initialValue={0} step={1} /> component that renders a fixed structure.
This compositional thinking isn’t limited to React. It’s a general software design principle. In vanilla JavaScript, you see it in how we build objects or functions.
// Composing objects by mixing in behaviors
const canSwim = {
swim() {
console.log(`${this.name} is swimming.`);
}
};
const canFly = {
fly() {
console.log(`${this.name} is flying.`);
}
};
const canRun = {
run() {
console.log(`${this.name} is running.`);
}
};
function createBird(name) {
return {
name,
...canFly,
...canRun // Some birds, like ostriches, run!
};
}
function createDuck(name) {
return {
name,
...canSwim,
...canFly,
...canRun
};
}
const donald = createDuck('Donald');
donald.swim(); // "Donald is swimming."
donald.fly(); // "Donald is flying."
// What about a penguin? It swims and runs, but doesn't fly.
function createPenguin(name) {
return {
name,
...canSwim,
...canRun
};
}
This is object composition. A duck has the ability to swim, fly, and run. It’s not defined as a subclass of a "FlyingBird" that itself is a subclass of "Animal." We just assemble the needed traits. Adding a new trait, like canDive, doesn’t require restructuring a class hierarchy. We just create the canDive object and mix it into any creature that needs it.
The same goes for functions. We can compose small, pure functions to create more complex operations.
// Composing functions
const add = (a, b) => a + b;
const double = (x) => x * 2;
const square = (x) => x * x;
// A classic compose: apply functions right-to-left.
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
// Create a new function by composing others.
const doubleThenSquare = compose(square, double);
console.log(doubleThenSquare(5)); // double(5) = 10, square(10) = 100
// Or a pipe (left-to-right, often easier to read)
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const addThenDoubleThenSquare = pipe(add, double, square);
console.log(addThenDoubleThenSquare(3, 4)); // add(3,4)=7, double(7)=14, square(14)=196
These simple functions—add, double, square—are our Lego bricks. pipe and compose are the ways we snap them together. The resulting function, addThenDoubleThenSquare, is a new behavior built from smaller, reusable, testable parts.
So, why does all this matter for day-to-day web development?
First, it makes code easier to reason about. When I look at a component built with hooks, I can see exactly what behaviors it uses at the top of the function: useState, useEffect, useDocumentTitle. It’s a clear list of capabilities. I don’t have to trace through a chain of parent classes to understand where a method comes from.
Second, it makes code more reusable. A well-written hook like useLocalStorage can be dropped into any component, in any project. It’s not tied to a specific component family.
Third, it makes code more flexible and less fragile. Need to add analytics tracking to a component? With composition, I can write a useAnalytics hook and add it. I don’t have to figure out which middle-layer class in an inheritance hierarchy should contain that logic, risking side effects for all its children.
Finally, it encourages smaller, more focused modules. Each hook, each composable function, aims to do one thing well. This aligns perfectly with modern JavaScript tooling and bundle analysis, where keeping things small and independent helps performance.
The move from inheritance to composition felt like switching from a rigid, pre-fabricated building to one made with modular blocks. The first looks impressive initially but is hard to renovate. The second might start simpler, but I can rearrange it, add new wings, or replace sections without fearing the whole structure will collapse. For the fast-changing requirements of web development, that flexibility isn’t just nice—it’s essential.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva