Hace unos meses estuve metido en un análisis sobre cómo extender una librería de componentes desde otra librería. El caso era normal: tomar un componente que no era tuyo y ajustarlo sin reescribirlo entero. Lo que usamos funcionó, pero funcionó sobre todo para una cosa: la parte visual. Temas, variables CSS, className, algún prop de estilo. Resuelto, sin mayor drama.
De ahí salió otra pregunta que seguí masticando por un tiempo, bastante más incómoda que la que estábamos resolviendo en ese momento:
¿Podemos conseguir algo parecido a la especialización de una clase, pero usando composición en componentes de UI?
No se trata de traer extends de POO a React con Proxy. Es algo más simple de decir que de lograr: agarrar un componente que ya existe y armar una variante suya, con comportamiento distinto, no solo look distinto, sin copiar el código ni tocar la implementación original.
Esa pregunta siguió rondando un buen rato hasta que volví a toparme con ella trabajando en un Fader para mi UI Library, mi sistema de diseño con estética de consola de audio.
El problema real: casi toda “extensión” termina siendo un tema visual
Si buscás cómo extender un componente de React, casi todas las respuestas caen en el mismo lugar: props de estilo, className, tokens de diseño, alguna variante de theming. Ese problema ya está bien resuelto.
El problema aparece cuando lo que querés cambiar no es cómo se ve el componente sino cómo se comporta. La curva con la que se interpreta un gesto de arrastre, o cómo se convierte una posición en un valor. Qué pasa si necesito interceptar un callback antes de que le llegue al padre. Agregar una capa visual que depende de un estado interno, o meter contenido en una zona específica del componente.
La mayoría de soluciones se quedan cortas justo ahí, y la salida típica es la de siempre: copiás el componente, le cambiás lo que necesitás, y terminás con un fork que se va a desincronizar del original apenas alguien arregle un bug ahí. Lo he visto pasar en más de un proyecto.

Ese fork es lo que quería evitarme. Copiar código no es el problema en sí. El problema está en que rompe la relación entre el componente base y sus variantes: si mañana cambio algo en el Fader original, el fork ni se entera, y sigue viviendo su vida aparte.
Primer intento: Proxy, y el tipado que lo mandó a la basura
Mi primer instinto fue Proxy. Conceptualmente calzaba bien con lo que quería: interceptar accesos, envolver comportamiento, decidir en runtime qué hacer con cada llamada. Probé construir la extensión envolviendo el componente en un Proxy que interceptara props y handlers.
Funcionó, en el sentido de que el runtime hacía lo que le pedía. El problema fue otro: TypeScript no tiene cómo inferir tipos a través de un Proxy armado en runtime. Apenas empecé a envolver componentes así perdí el autocompletado, perdí la validación de props, y cualquiera que tocara ese código después iba a tener que confiar a ciegas en que el Proxy hacía lo correcto.
Para un componente suelto tal vez lo hubiera dejado pasar igual. Pero esto es para un sistema de diseño que uso como portafolio, algo que en teoría otra persona podría adoptar algún día, y un tipado roto ahí me quita buena parte del sentido de usar TypeScript. La idea de interceptar seguía pareciéndome correcta, el costo de mantenerla tipada era lo que no cuadraba. Así que lo dejé de lado por un tiempo y seguí buscando por otro lado.
Qué encontré revisando cómo lo resuelven otros
Fue en esas que me puse a mirar cómo resuelven esto los sistemas de componentes que ya existen. No hay una respuesta única, pero sí algo que se repite bastante: casi todos apuestan por composición en algún nivel. Slots, render props, reemplazo de partes internas del componente, ese estilo de “unstyled primitives” que exponen sus piezas para que vos las recompongas a tu manera.
Nada de esto es nuevo, ni pretendo que lo sea. Noté que en la mayoría de esos casos la composición se usa para combinar componentes desde afuera: armás un formulario juntando primitives, o metés un nodo en un slot puntual. Esa composición vive del lado de quien consume el componente, no dentro de la definición del componente en sí.
Lo mío iba por otro lado. Quería que la composición funcionara como mecanismo interno de extensión, algo que el propio componente ofrece como parte de su API, no algo que cada quien arma por su cuenta juntando piezas sueltas cada vez que lo usa.
La decisión: dejar que la composición haga el trabajo de la herencia
Proxy me daba intercepción, pero mal tipada. La composición de afuera hacia adentro no me daba esa sensación de “especialización” que andaba buscando. Empecé a preguntarme si podía quedarme con lo bueno de las dos cosas: mantener composición como mecanismo, pero exponerla como una operación explícita del propio componente.
Ahí apareció extend().

La idea es fácil de resumir, aunque no tan fácil de implementar bien: extend() es un método que a partir de un Fader existente construye un objeto nuevo que envuelve al anterior. No lo modifica ni lo reescribe, tampoco le mete mano por atrás. Lo envuelve, nada más. Lo que le pasás (una estrategia de conversión distinta, un interceptor de callback, una capa visual extra, un slot con contenido) se va combinando con lo que el Fader base ya hacía.
Cómo se ve extend() en el Fader
A grandes rasgos, el Fader base tiene una forma parecida a esta:
export interface FaderExtensionConfig {
behavior?: FaderBehaviorExtension;
callbacks?: FaderCallbackExtension;
styles?: FaderStylesExtension;
slots?: FaderSlotExtension;
}
export interface FaderComponent extends FunctionComponent<LinearFaderProps> {
extend(config: FaderExtensionConfig): FaderComponent;
}LinearFader, que es el que más se usa en la práctica, es literalmente el Fader base sin ninguna extensión encima:
const baseFader = createFader();
export const LinearFader = baseFader;
export const Fader = baseFader;No hay una implementación paralela de "Fader lineal" escondida en algún lado, es el mismo componente pasando por extend() con un objeto vacío.

Algunas variantes reales que salieron de esto, tal cual las fui necesitando:
// 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>,
},
});Y como extend() devuelve otro Fader, se puede encadenar:
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"]Cada extend() suma una capa nueva sin pisar la anterior.
Dónde entran Strategy, callbacks, capas y slots
No quiero convertir esto en una clase de Design Patterns. Tampoco salí a implementar patrones porque sí, fueron apareciendo solos porque el problema los iba pidiendo.
Strategy resuelve cómo se convierte un gesto en un valor: curva lineal, logarítmica, cuantizada por pasos, son estrategias que se pueden intercambiar. extend({ strategy }) reemplaza esa pieza puntual sin tocar el resto.
Para la intercepción terminé usando una cadena de callbacks. Cada extensión que agrega un onChange recibe el valor entrante, el anterior, y una función propagate con la que decide si el cambio sigue su curso, se transforma, o se corta ahí mismo.
La presentación quedó resuelta con capas visuales (un medidor de picos, marcas de escala, un tooltip, sin reescribir el render del Fader base) y con slots para el típico “necesito meter esto acá”, como reemplazar el label o el handle del thumb.

Todo esto pasa por extend(). No es un patrón nuevo, es más bien el punto donde la composición dejó de ser algo implícito que armás cada vez que usás el componente, para convertirse en una operación explícita que el componente mismo ofrece.
Por qué me importa que LinearFader sea “Fader sin extensiones”
Esto ya es más una decisión de diseño que un detalle técnico. Si querés algo parecido a especialización de clases sin usar clases, en algún momento tenés que poder demostrar que el caso base y el caso “sin extender” son la misma cosa, literal. Si LinearFader tuviera código propio además del extend({}), ahí se rompería la premisa: habría comportamiento viviendo fuera del mecanismo de extensión, y extend() dejaría de ser el punto de entrada único para especializar el componente.
Que LinearFader sea literalmente Fader.extend({}) es la señal de que el mecanismo se sostiene. El día que deje de serlo, sabré que rompí algo en el diseño.
Una optimización que se coló en el camino: separar el valor en drag del valor confirmado
Esto no estaba en el plan original, apareció mientras probaba las extensiones con arrastre. Si el Fader re-renderiza en cada pixel de movimiento del drag, cualquier extensión que dependa de ese render, una capa visual reactiva por ejemplo, empieza a sentirse pesada bastante rápido. La solución fue separar dos estados: uno que cambia libre mientras el usuario arrastra, y otro que recién se confirma al soltar, que es el que dispara el onChange “oficial”. Cada extensión decide a cuál de los dos reaccionar, según si necesita feedback inmediato (una capa visual) o solo le importa el valor final (persistencia, analítica, lo que sea).
No es una técnica exclusiva de este approach, es bastante común en componentes de interacción continua. La menciono porque terminó siendo parte de cómo quedaron diseñadas las extensiones de callback: onChange recibe el valor ya confirmado, no cada frame del drag.
Qué queda resuelto y qué preguntas quedan abiertas
Con extend() tengo, por ahora, algo que se siente cerca de la especialización de una clase: un Fader base, variantes que heredan su comportamiento por defecto y sobreescriben partes puntuales, y la garantía, mientras el mecanismo se sostenga, de que el caso sin extensiones es el componente original sin envoltorios de más.
Lo que todavía no tengo tan claro:
Qué tan bien aguanta extend() cuando las extensiones empiezan a interactuar entre sí de formas no triviales. Pienso en dos capas visuales que dependen la una de la otra, por ejemplo, y no sé bien cómo se resolvería eso todavía.
Si el mismo mecanismo tiene sentido para componentes bastante más simples que un Fader. A lo mejor ahí el costo de tener un extend() propio no se justifica tanto.
Cómo se pone esto en TypeScript cuando las extensiones se encadenan muchas veces y los tipos inferidos empiezan a volverse un ladrillo de leer.
Tampoco quiero presentar esto como si hubiera inventado algo que no existía antes, porque no es el caso. La composición como mecanismo de extensión ya se usa en la industria de varias formas. Estoy explorando algo más puntual: en vez de dejar que la composición viva del lado de quien consume el componente, armando piezas sueltas desde afuera, hacer que viva del lado del componente mismo y quede expuesta como una operación explícita (extend()) que cualquiera pueda usar para construir una variante sin copiar código ni tocar el original.
Si alguien ya resolvió esto de otra forma, o ve algo que se me está pasando por alto en cómo encadené las extensiones, me interesa mucho escucharlo. El Fader extendido está en el Storybook por si querés verlo funcionando:
https://ui.christiannarvaez.site/?path=/story/atoms-linearfader--extended-variant





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