A few months ago I was involved in an analysis about how to extend a component library from another library. The case was normal: take a component that wasn't yours and adjust it without rewriting it entirely. What we used worked, but it worked mainly for one thing: the visual part. Themes, CSS variables, className, some style prop. Solved, without much drama.
From there came another question that I kept chewing on for a while, quite a bit more uncomfortable than the one we were solving at that moment:
Can we achieve something similar to class specialization, but using composition in UI components?
It's not about bringing OOP extends to React with Proxy. It's something simpler to say than to achieve: take a component that already exists and build a variant of it, with different behavior, not just a different look, without copying the code or touching the original implementation.
That question kept circling for a good while until I ran into it again while working on a Fader for my UI Library, my design system with an audio console aesthetic.
The real problem: almost every “extension” ends up being a visual theme
If you search for how to extend a React component, almost all answers land in the same place: style props, className, design tokens, some theming variant. That problem is already well solved.
The problem appears when what you want to change is not how the component looks but how it behaves. The curve with which a drag gesture is interpreted, or how a position is converted into a value. What happens if I need to intercept a callback before it reaches the parent. Add a visual layer that depends on internal state, or insert content into a specific area of the component.
Most solutions fall short right there, and the typical way out is the usual one: you copy the component, change what you need, and end up with a fork that will go out of sync with the original as soon as someone fixes a bug there. I've seen it happen in more than one project.

That fork is what I wanted to avoid. Copying code is not the problem in itself. The problem is that it breaks the relationship between the base component and its variants: if tomorrow I change something in the original Fader, the fork doesn't even find out, and keeps living its own separate life.
First attempt: Proxy, and the typing that sent it to the trash
My first instinct was Proxy. Conceptually it fit well with what I wanted: intercept accesses, wrap behavior, decide at runtime what to do with each call. I tried building the extension by wrapping the component in a Proxy that would intercept props and handlers.
It worked, in the sense that the runtime did what I asked of it. The problem was another one: TypeScript has no way to infer types through a Proxy built at runtime. As soon as I started wrapping components like that I lost autocompletion, I lost prop validation, and anyone who touched that code afterward would have to blindly trust that the Proxy did the right thing.
For a standalone component I might have let it pass anyway. But this is for a design system I use as a portfolio, something that in theory another person could adopt someday, and broken typing there takes away a good part of the point of using TypeScript. The idea of intercepting still seemed correct to me; the cost of keeping it typed was what didn't add up. So I set it aside for a while and kept looking elsewhere.
What I found reviewing how others solve it
It was then that I started looking at how existing component systems solve this. There is no single answer, but there is something that repeats quite a bit: almost all of them bet on composition at some level. Slots, render props, replacement of internal parts of the component, that style of “unstyled primitives” that expose their pieces so you can recompose them your way.
None of this is new, nor do I claim it is. I noticed that in most of those cases composition is used to combine components from the outside: you build a form by putting primitives together, or you put a node into a specific slot. That composition lives on the side of whoever consumes the component, not inside the definition of the component itself.
Mine went in another direction. I wanted composition to work as an internal extension mechanism, something the component itself offers as part of its API, not something each person assembles on their own by putting loose pieces together every time they use it.
The decision: let composition do the work of inheritance
Proxy gave me interception, but poorly typed. Outside-in composition didn't give me that feeling of “specialization” I was looking for. I started wondering if I could keep the good from both: keep composition as the mechanism, but expose it as an explicit operation of the component itself.
Then extend() appeared.

The idea is easy to summarize, though not so easy to implement well: extend() is a method that, from an existing Fader, builds a new object that wraps the previous one. It doesn't modify or rewrite it, nor does it reach in behind its back. It wraps it, nothing more. What you pass it (a different conversion strategy, a callback interceptor, an extra visual layer, a slot with content) gets combined with what the base Fader already did.
What extend() looks like in the Fader
Broadly speaking, the base Fader has a shape similar to this:
export interface FaderExtensionConfig {
behavior?: FaderBehaviorExtension;
callbacks?: FaderCallbackExtension;
styles?: FaderStylesExtension;
slots?: FaderSlotExtension;
}
export interface FaderComponent extends FunctionComponent<LinearFaderProps> {
extend(config: FaderExtensionConfig): FaderComponent;
}LinearFader, which is the one most used in practice, is literally the base Fader without any extension on top:
const baseFader = createFader();
export const LinearFader = baseFader;
export const Fader = baseFader;There is no parallel implementation of “linear Fader” hidden somewhere; it's the same component going through extend() with an empty object.

Some real variants that came out of this, exactly as I needed them:
// CurvedVolumeFader
const exponentialMapping = {
valueToPosition: (value, { min, max }) => {
const t = (value - min) / (max - min);
return Math.pow(t, 2);
},
positionToValue: (position, { min, max }) => {
const t = Math.sqrt(Math.max(0, Math.min(1, position)));
return Math.round(min + t * (max - min));
},
};
const CurvedVolumeFader = LinearFader.extend({
behavior: {
mapping: exponentialMapping,
},
slots: {
aboveThumb: ({ value }) => (
<span style={{ color: '#e85d04', fontFamily: 'JetBrains Mono', fontSize: '10px' }}>
{Math.round(value)}
</span>
),
},
});
return <CurvedVolumeFader label="CH. 1" defaultValue={65} />;
// VolumeFader
const VolumeFader = Fader.extend({
slots: {
aboveThumb: ({ value }) => <span data-testid="tooltip">{value}%</span>,
},
});And since extend() returns another Fader, it can be chained:
const WithFirst = Fader.extend({
callbacks: {
onChange: (next, value) => {
order.push("first");
next(value);
},
},
});
const WithSecond = WithFirst.extend({
callbacks: {
onChange: (next, value) => {
order.push("second");
next(value);
},
},
});
// al disparar el evento: order === ["second", "first"]Each extend() adds a new layer without overriding the previous one.
Where Strategy, callbacks, layers, and slots come in
I don't want to turn this into a Design Patterns class. Nor did I set out to implement patterns for no reason; they appeared on their own because the problem kept asking for them.
Strategy solves how a gesture is converted into a value: linear, logarithmic, step-quantized curves are strategies that can be swapped. extend({ strategy }) replaces that specific piece without touching the rest.
For interception I ended up using a callback chain. Each extension that adds an onChange receives the incoming value, the previous one, and a propagate function with which it decides whether the change continues its course, is transformed, or is cut off right there.
Presentation was solved with visual layers (a peak meter, scale marks, a tooltip, without rewriting the base Fader's render) and with slots for the typical “I need to put this here,” such as replacing the label or the thumb handle.

All this goes through extend(). It's not a new pattern; rather, it's the point where composition stopped being something implicit that you assemble every time you use the component, and became an explicit operation that the component itself offers.
Why I care that LinearFader is “Fader without extensions”
This is more a design decision than a technical detail. If you want something similar to class specialization without using classes, at some point you have to be able to demonstrate that the base case and the “un-extended” case are the same thing, literally. If LinearFader had its own code in addition to extend({}), the premise would break there: there would be behavior living outside the extension mechanism, and extend() would stop being the single entry point for specializing the component.
That LinearFader is literally Fader.extend({}) is the sign that the mechanism holds up. The day it stops being that, I'll know I broke something in the design.
An optimization that slipped in along the way: separating the drag value from the confirmed value
This wasn't in the original plan; it appeared while I was testing the extensions with drag. If the Fader re-renders on every pixel of drag movement, any extension that depends on that render, a reactive visual layer for example, starts to feel heavy fairly quickly. The solution was to separate two states: one that changes freely while the user drags, and another that is only confirmed on release, which is the one that triggers the “official” onChange. Each extension decides which of the two to react to, depending on whether it needs immediate feedback (a visual layer) or only cares about the final value (persistence, analytics, whatever).
It's not a technique exclusive to this approach; it's quite common in continuous-interaction components. I mention it because it ended up being part of how the callback extensions were designed: onChange receives the already confirmed value, not every frame of the drag.
What is solved and what questions remain open
With extend() I have, for now, something that feels close to class specialization: a base Fader, variants that inherit its default behavior and override specific parts, and the guarantee, as long as the mechanism holds up, that the case without extensions is the original component without extra wrappers.
What I still don't have entirely clear:
How well extend() holds up when extensions start interacting with each other in non-trivial ways. I think of two visual layers that depend on each other, for example, and I don't yet know quite how that would be solved.
Whether the same mechanism makes sense for much simpler components than a Fader. Maybe there the cost of having your own extend() isn't as justified.
How this works in TypeScript when extensions are chained many times and the inferred types start becoming a slog to read.
I also don't want to present this as if I had invented something that didn't exist before, because that's not the case. Composition as an extension mechanism is already used in the industry in various ways. I'm exploring something more specific: instead of letting composition live on the side of whoever consumes the component, assembling loose pieces from the outside, make it live on the side of the component itself and be exposed as an explicit operation (extend()) that anyone can use to build a variant without copying code or touching the original.
If someone has already solved this in another way, or sees something I'm overlooking in how I chained the extensions, I'd really like to hear it. The extended Fader is in Storybook in case you want to see it working:
https://ui.christiannarvaez.site/?path=/story/atoms-linearfader--extended-variant





Comments 0
Join the conversation with your account.
Sign inLoading comments…