Patrones de Composición en React: Alternativas a la Herencia
Introducción
Si alguna vez has construido una aplicación React de cierta complejidad, probablemente te hayas encontrado con el siguiente dilema: tienes varios componentes que comparten funcionalidad similar y quieres evitar duplicar código. Tu instinto inicial, especialmente si vienes de lenguajes orientados a objetos como Java o C#, podría ser recurrir a la herencia — crear una clase base y extenderla. Sin embargo, en React, este enfoque tiene limitaciones significativas.
El problema de la reutilización de código en React va más allá de simplemente evitar la duplicación. Se trata de crear abstracciones que sean:
- Fáciles de entender
- Flexibles para adaptarse a diferentes casos de uso
- Mantenibles a largo plazo
- Componibles con otras abstracciones
La limitación del modelo de herencia se hace evidente cuando intentamos representar interfaces de usuario, que raramente siguen una jerarquía de especialización limpia ("es un") y más frecuentemente requieren combinaciones flexibles de comportamientos ("tiene un").
💡 Pro tip
En la documentación oficial de React, hay una sección titulada "Composition vs Inheritance" que recomienda explícitamente usar la composición en lugar de la herencia para reutilizar código entre componentes.
En este artículo, exploraremos diversos patrones de composición que React promueve como alternativas más flexibles a la herencia tradicional. Veremos cómo estos patrones nos permiten construir componentes más modulares, reutilizables y mantenibles.
Composition vs Inheritance: El enfoque de React
Por qué React prefiere la composición
React adopta un modelo de composición por varias razones fundamentales:
- Flexibilidad: La composición permite combinar comportamientos de formas que la herencia dificulta
- Claridad: Las relaciones entre componentes son explícitas y visibles en el punto de uso
- Testabilidad: Los componentes compuestos son más fáciles de probar en aislamiento
- Evita problemas clásicos: Esquiva problemas como el diamante de la muerte o el acoplamiento frágil
La filosofía de React se alinea con el principio de programación "Composition Over Inheritance" (Composición sobre Herencia), que sugiere que la composición de objetos ofrece mayor flexibilidad que la herencia.
Limitaciones de la herencia para UI components
La herencia funciona bien cuando existe una clara relación jerárquica "es un" entre conceptos. Por ejemplo, un "Gato" es un "Animal", y hereda todas sus características. Sin embargo, las interfaces de usuario rara vez siguen este modelo tan limpio.
Consideremos este ejemplo:
// ❌ Enfoque problemático con herencia
class Button extends React.Component {
// ... lógica común de botones
}
class SubmitButton extends Button {
// ... lógica específica de botón de envío
}
class MenuButton extends Button {
// ... lógica específica de botón de menú
}
¿Qué sucede cuando queremos un SubmitButton
que también tenga comportamiento de MenuButton
? La herencia simple no nos permite combinar estas clases fácilmente.
Modelo mental para pensar en composición
Un modelo mental útil para la composición es pensar en términos de "cajas dentro de cajas" en lugar de "tipos especializados de cajas". Cada componente puede contener otros componentes, delegándoles parte de su renderizado o comportamiento.
Esta forma de pensar nos permite crear componentes que:
- Se centran en hacer una sola cosa bien (responsabilidad única)
- Se pueden combinar de formas flexibles e imprevistas
- Son explícitos sobre sus dependencias
Pattern: Component Composition
El patrón más básico y fundamental en React es la composición directa de componentes. React proporciona mecanismos nativos para que los componentes se compongan entre sí, creando estructuras más complejas a partir de piezas simples.
Children props como mecanismo básico de composición
La prop children
es la forma más elemental de composición en React. Permite que un componente reciba y renderice contenido anidado dentro de él, sin conocer ese contenido de antemano.
// Componente contenedor que aplica un estilo común
const Card = ({ children }) => {
return (
<div className="card">
{children}
</div>
);
};
// Uso del componente
const App = () => {
return (
<Card>
<h2>Título de la tarjeta</h2>
<p>Contenido de la tarjeta</p>
<button>Acción</button>
</Card>
);
};
Este patrón permite una separación clara entre:
- El contenedor (
Card
), que define la estructura y el estilo - El contenido, que es flexible y determinado por el componente padre
Slots pattern para layouts flexibles
El patrón de slots extiende el concepto de children
para permitir múltiples puntos de inserción nombrados:
// Componente de layout con slots
const PageLayout = ({ header, sidebar, main, footer }) => {
return (
<div className="page-layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{main}</main>
<footer>{footer}</footer>
</div>
);
};
// Uso del componente
const Page = () => {
return (
<PageLayout
header={<NavBar />}
sidebar={<SideMenu />}
main={<ContentPanel />}
footer={<Footer />}
/>
);
};
Este patrón es particularmente útil para:
- Componentes de layout complejos
- Interfaces que requieren múltiples áreas de contenido
- Casos donde necesitamos control más específico sobre la ubicación del contenido
🔍 Detalle técnico
Este patrón también se conoce como "named children" o "explicit children" y es común en frameworks de componentes como Material-UI o Chakra UI para crear layouts complejos.
Composición mediante renderizado condicional
La composición también puede incluir lógica condicional que determine qué componentes se renderizan:
const AuthenticationScreen = ({ isRegistering }) => {
return (
<Card>
<CardHeader>
{isRegistering ? "Crear cuenta" : "Iniciar sesión"}
</CardHeader>
<CardBody>
{isRegistering ? <RegistrationForm /> : <LoginForm />}
</CardBody>
<CardFooter>
<PolicyLinks />
</CardFooter>
</Card>
);
};
Este enfoque permite:
- Componentes que adaptan su estructura según props o estado
- Reutilización de componentes contenedores con diferentes contenidos
- Interfaces dinámicas que responden a las acciones del usuario
Pattern: Higher-Order Components (HOCs)
Los Higher-Order Components representan un patrón más avanzado para compartir lógica entre componentes. Un HOC es una función que toma un componente y devuelve un nuevo componente con capacidades adicionales.
Definición y principios de los HOCs
Un HOC sigue el patrón del decorador de la programación orientada a objetos, permitiendo añadir comportamiento a componentes existentes sin modificarlos directamente:
// Ejemplo sencillo de HOC
function withLogging(WrappedComponent) {
return function WithLogging(props) {
console.log(`Rendering: ${WrappedComponent.displayName || WrappedComponent.name}`);
return <WrappedComponent {...props} />;
};
}
// Uso
const ButtonWithLogging = withLogging(Button);
Los principios que guían el diseño de buenos HOCs son:
- Composición: Un HOC no modifica el componente original, sino que lo envuelve
- Convención de nombres: Prefijo
with
para identificar claramente la función como HOC - Passtrough props: Pasar todas las props no utilizadas al componente envuelto
- Encapsulación: El HOC debe encapsular su lógica interna, sin filtración de detalles
Implementación de HOCs comunes
Veamos algunos ejemplos prácticos de HOCs:
// HOC para cargar datos
function withData(WrappedComponent, fetchFunction) {
return function WithData(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
setLoading(true);
fetchFunction()
.then(result => {
if (isMounted) {
setData(result);
setLoading(false);
}
})
.catch(err => {
if (isMounted) {
setError(err);
setLoading(false);
}
});
return () => {
isMounted = false;
};
}, []);
if (loading) return <Loader />;
if (error) return <ErrorDisplay error={error} />;
return <WrappedComponent data={data} {...props} />;
};
}
// HOC para autenticación
function withAuth(WrappedComponent, requiredRole = null) {
return function WithAuth(props) {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <LoginRedirect />;
}
if (requiredRole && user.role !== requiredRole) {
return <AccessDenied />;
}
return <WrappedComponent {...props} />;
};
}
Estos HOCs abstraen patrones comunes como:
- Carga y manejo de datos y estados asociados
- Verificación de permisos y autenticación
- Funcionalidades transversales como logging o analytics
Gestión de múltiples HOCs y problemas comunes
Los HOCs pueden componerse para agregar múltiples comportamientos a un componente:
// Composición de múltiples HOCs
const EnhancedDashboard = withAuth(
withData(
withAnalytics(Dashboard),
fetchDashboardData
),
'admin'
);
Sin embargo, este enfoque presenta varios desafíos:
- Wrapper hell: Múltiples niveles de anidamiento pueden dificultar la depuración
- Props collision: Diferentes HOCs pueden intentar usar el mismo nombre de prop
- Static methods: Los métodos estáticos del componente original no se propagan automáticamente
⚠️ Advertencia
Aunque los HOCs son poderosos, su uso ha disminuido desde la introducción de Hooks en React 16.8. Considera si Hooks o Render Props podrían ser más apropiados para tu caso de uso.
Soluciones a problemas comunes con HOCs
Para mitigar estos problemas, podemos:
- Usar la función
compose
de bibliotecas como Redux o Recompose para mejorar la legibilidad - Nombrar y prefixar las props para evitar colisiones
- Usar
hoist-non-react-statics
para preservar métodos estáticos
// Usando compose para mejorar legibilidad
import { compose } from 'redux';
const EnhancedDashboard = compose(
withAuth('admin'),
withData(fetchDashboardData),
withAnalytics
)(Dashboard);
Pattern: Render Props
El patrón Render Props ofrece una alternativa flexible a los HOCs, permitiendo compartir código entre componentes React mediante una función prop.
El patrón render props explicado
Una "render prop" es una prop que recibe una función que retorna un elemento React, permitiendo al componente compartir código con otros componentes:
// Componente que utiliza render props
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMouseMove(event) {
setPosition({
x: event.clientX,
y: event.clientY
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
// Utiliza la render prop para determinar qué renderizar
return render(position);
}
// Uso del componente
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
<h1>Mueve el cursor!</h1>
<p>La posición actual del cursor es: ({x}, {y})</p>
</div>
)}
/>
);
}
Este patrón proporciona:
- Flexibilidad sobre qué y cómo renderizar
- Separación clara entre la lógica (seguimiento del ratón) y la presentación
- La capacidad de componer múltiples comportamientos de manera explícita
Implementación para compartir lógica compleja
El patrón render props es particularmente útil para comportamientos complejos que necesitan estar integrados con la jerarquía de componentes:
// Implementación de una Form con validación usando render props
function Form({ initialValues, validate, onSubmit, render }) {
const [values, setValues] = useState(initialValues || {});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (event) => {
const { name, value } = event.target;
setValues(prevValues => ({
...prevValues,
[name]: value
}));
};
const handleBlur = (event) => {
const { name } = event.target;
setTouched(prevTouched => ({
...prevTouched,
[name]: true
}));
// Validar al perder el foco
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
}
};
const handleSubmit = async (event) => {
event.preventDefault();
setTouched(
Object.keys(values).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {})
);
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return; // No enviamos si hay errores
}
}
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
};
return render({
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit
});
}
// Uso
function SignupForm() {
return (
<Form
initialValues={{ email: '', password: '' }}
validate={values => {
const errors = {};
if (!values.email) errors.email = 'Email es requerido';
if (!values.password) errors.password = 'Contraseña es requerida';
return errors;
}}
onSubmit={async (values) => {
await api.createUser(values);
alert('¡Usuario creado!');
}}
render={({
values, errors, touched, isSubmitting,
handleChange, handleBlur, handleSubmit
}) => (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<div className="error">{errors.email}</div>
)}
</div>
<div>
<label>Contraseña:</label>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<div className="error">{errors.password}</div>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Registrarse'}
</button>
</form>
)}
/>
);
}
Este ejemplo muestra cómo render props permite:
- Encapsular lógica compleja de formularios
- Proporcionar una API clara para el componente consumidor
- Mantener flexibilidad en cómo se presenta el formulario
💡 Pro tip
La prop no tiene que llamarse necesariamenterender
. Muchas implementaciones usan la propchildren
como función, lo que permite una sintaxis más limpia con JSX.
Comparativa con HOCs: ventajas y desventajas
Ventajas de Render Props sobre HOCs:
- Más explícito sobre qué datos se están utilizando
- Evita problemas de colisión de props
- Facilita el paso de parámetros adicionales
- Permite acceso directamente en el scope del componente
Desventajas:
- Puede llevar a callback hell con múltiples render props anidadas
- Dificulta la optimización con React.memo/PureComponent
- Mayor complejidad visual en el árbol de componentes
// Ejemplo de anidamiento excesivo (callback hell)
<MouseTracker
render={mousePosition => (
<WindowSize
render={windowSize => (
<ThemeContext.Consumer>
{theme => (
// Componente final con todos los datos
<MyComponent
mousePosition={mousePosition}
windowSize={windowSize}
theme={theme}
/>
)}
</ThemeContext.Consumer>
)}
/>
)}
/>
Pattern: Custom Hooks
Los Hooks personalizados, introducidos en React 16.8, representan una forma más natural y elegante de compartir lógica entre componentes sin introducir componentes adicionales en el árbol.
Extracción de lógica con hooks personalizados
Un Hook personalizado es simplemente una función JavaScript que usa otros Hooks de React y sigue las reglas de los Hooks. Por convención, los nombres de estos Hooks comienzan con "use":
// Hook personalizado para seguimiento del ratón
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMouseMove(event) {
setPosition({
x: event.clientX,
y: event.clientY
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return position;
}
// Uso del hook
function App() {
const { x, y } = useMousePosition();
return (
<div>
<h1>Mueve el cursor!</h1>
<p>La posición actual del cursor es: ({x}, {y})</p>
</div>
);
}
Este enfoque ofrece varias ventajas:
- Código más limpio y directo
- No introduce nodos adicionales en el árbol de componentes
- Permite componer múltiples hooks sin anidamiento excesivo
- Mantiene la lógica relacionada junta
Diseño de APIs intuitivas para hooks
Un buen hook personalizado debe:
- Tener un nombre claro que comunique su propósito
- Aceptar parámetros configurables cuando sea necesario
- Devolver valores y funciones con nombres descriptivos
- Manejar estados de carga y error cuando corresponda
// Hook personalizado con API bien diseñada
function useLocalStorage(key, initialValue) {
// Estado para almacenar nuestro valor
const [storedValue, setStoredValue] = useState(() => {
try {
// Obtener del localStorage por key
const item = window.localStorage.getItem(key);
// Analizar JSON almacenado o devolver initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Función para guardar tanto en estado como en localStorage
const setValue = value => {
try {
// Permitir que value sea una función para manejar situaciones como stateValue => newValue
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Guardar en estado
setStoredValue(valueToStore);
// Guardar en localStorage
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
// Función para eliminar el valor
const removeValue = () => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
};
return [storedValue, setValue, removeValue];
}
Esta API es intuitiva porque:
- Imita la API de
useState
para facilitar la adopción - Devuelve funciones adicionales relacionadas (
removeValue
) - Maneja errores internamente sin romper la experiencia de usuario
- Permite que el valor sea tanto directo como una función de actualización
Combinación de hooks para funcionalidades complejas
Uno de los beneficios más poderosos de los Hooks es la capacidad de componer varios hooks en uno más complejo:
// Hook personalizado para manejo completo de formularios
function useForm({ initialValues = {}, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isValid, setIsValid] = useState(false);
// Validación cada vez que los valores cambian
useEffect(() => {
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
setIsValid(Object.keys(validationErrors).length === 0);
}
}, [values, validate]);
// Reset form to initial values
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
// Handle field change
const handleChange = useCallback((event) => {
const { name, value, type, checked } = event.target;
setValues(prevValues => ({
...prevValues,
[name]: type === 'checkbox' ? checked : value
}));
}, []);
// Handle field blur
const handleBlur = useCallback((event) => {
const { name } = event.target;
setTouched(prevTouched => ({
...prevTouched,
[name]: true
}));
}, []);
// Handle form submission
const handleSubmit = useCallback(async (event) => {
if (event) event.preventDefault();
// Mark all fields as touched
setTouched(
Object.keys(values).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {})
);
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
return; // Don't submit if there are errors
}
}
setIsSubmitting(true);
try {
if (onSubmit) {
await onSubmit(values);
}
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
}, [values, validate, onSubmit]);
return {
values,
errors,
touched,
isSubmitting,
isValid,
handleChange,
handleBlur,
handleSubmit,
reset,
setValues
};
}
// Uso del hook en un componente
function SignupForm() {
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit
} = useForm({
initialValues: { email: '', password: '' },
validate: values => {
const errors = {};
if (!values.email) errors.email = 'Email es requerido';
if (!values.password) errors.password = 'Contraseña es requerida';
return errors;
},
onSubmit: async (values) => {
await api.createUser(values);
alert('¡Usuario creado!');
}
});
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<div className="error">{errors.email}</div>
)}
</div>
<div>
<label>Contraseña:</label>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<div className="error">{errors.password}</div>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Registrarse'}
</button>
</form>
);
}
Este patrón de combinación de hooks permite:
- Crear abstracciones poderosas sin complejidad anidada
- Mantener la lógica relacionada junta y reutilizable
- Proporcionar una experiencia de desarrollo más natural y clara
- Facilitar el testing de la lógica de manera aislada
Pattern: Compound Components
El patrón de Componentes Compuestos (Compound Components) permite crear APIs declarativas y expresivas para componentes complejos, ofreciendo flexibilidad al usuario final mientras mantiene la encapsulación de la lógica interna.
Creando APIs declarativas con componentes compuestos
Los componentes compuestos son un conjunto de componentes que trabajan juntos para proporcionar una funcionalidad cohesiva. El componente principal actúa como un coordinador, mientras que los componentes secundarios (hijos) manejan partes específicas de la interfaz:
// Uso de un patrón de componentes compuestos
<Tabs>
<Tabs.Tab id="profile">Perfil</Tabs.Tab>
<Tabs.Tab id="account">Cuenta</Tabs.Tab>
<Tabs.Tab id="settings">Configuración</Tabs.Tab>
<Tabs.Panel id="profile">
<ProfileContent />
</Tabs.Panel>
<Tabs.Panel id="account">
<AccountContent />
</Tabs.Panel>
<Tabs.Panel id="settings">
<SettingsContent />
</Tabs.Panel>
</Tabs>
Este patrón proporciona varias ventajas:
- API declarativa y clara para el usuario
- Flexibilidad en la composición y orden de los elementos
- Encapsulación de la lógica compartida
- Control preciso sobre qué elementos se renderizan
Comments ()