Patrones de Composición en React: Alternativas a la Herencia

Patrones de Composición en React: Alternativas a la Herencia
Photo by Pankaj Patel / Unsplash

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:

  1. Flexibilidad: La composición permite combinar comportamientos de formas que la herencia dificulta
  2. Claridad: Las relaciones entre componentes son explícitas y visibles en el punto de uso
  3. Testabilidad: Los componentes compuestos son más fáciles de probar en aislamiento
  4. 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:

  1. Composición: Un HOC no modifica el componente original, sino que lo envuelve
  2. Convención de nombres: Prefijo with para identificar claramente la función como HOC
  3. Passtrough props: Pasar todas las props no utilizadas al componente envuelto
  4. 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:

  1. Wrapper hell: Múltiples niveles de anidamiento pueden dificultar la depuración
  2. Props collision: Diferentes HOCs pueden intentar usar el mismo nombre de prop
  3. 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 necesariamente render. Muchas implementaciones usan la prop children 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:

  1. Tener un nombre claro que comunique su propósito
  2. Aceptar parámetros configurables cuando sea necesario
  3. Devolver valores y funciones con nombres descriptivos
  4. 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