De componentes caóticos a arquitectura limpia: aplicando SOLID en React

En esta entrada me apetecía tocar un tema más teórico y no tan práctico ya que a menudo siento que se da mucha importancia al hecho de escribir código, pero quizá no tanta a cómo hacerlo con cabeza.

Es importante escribir código que funcione, pero también lo es asegurarnos de que el código seguirá funcionando dentro de un año, después de haber añadido funcionalidades extra y de que el proyecto haya pasado por manos de otros desarrolladores.

Ahí fuera me he encontrado auténticos laberintos de código imposibles de domar, bombas de relojería de los que muchas veces el cliente no era consciente. Aparentemente todo parecía funcionar bien...

Claro, hasta que pide hacer algún cambio. Ahí el castillo de naipes se desmorona. ¡Por eso hoy vamos a ver los principios SOLID!

Introducción

Los principios SOLID surgieron como una serie de principios de buenas prácticas dentro de un contexto OOP (Object Oriented Programming).

Aunque React se acerca más a un enfoque funcional, si adaptanos estos principios a React, podrás crear componentes mucho más robustos, mantenibles y escalables.

Vamos a ver estos principios uno por uno y cómo podemos aplicarlos.

1. Principio de Responsabilidad Única (SRP)

¡Este es fácil de adaptar! Cada componente, módulo o función debe tener una única responsabilidad, un único trabajo.

Aquí podríamos entrar a debatir sobre qué entendemos por 'un único trabajo', ya que si atomizar en exceso puede ser también una mala idea. ¡Cuidado con eso!

¿Cómo aplicarlo en React? Una forma muy habitual es separando la lógica del componente usando un Custom Hook:

UserDashboard.jsx
// ❌ No cumple SRP
const UserDashboard = () => {
const [users, setUsers] = useState([]);

useEffect(() => {
fetch('/users').then(res => res.json()).then(setUsers);
}, []);

return (
<div>
<h1>Usuarios Activos</h1>
<ul>
{users.filter(user => user.isActive).map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
};

Vemos que el primer componente intenta hacer demasiadas cosas:

  • Manejar el estado
  • Hacer fetching de los datos
  • Filtrar esos datos
  • Formatear los datos y mostrarlos

Como dije más arriba, la forma más habitual para ayudarnos a mantener SRP es usar un Custom Hook que nos ayude a separar la lógica.

Creamos pues un hook useActiveUsers que se encargue de hacer fetch de los usuarios activos (que es lo que queremos mostrar) y lo usamos para enviarle esa información al componente encargado de renderizar la lista.

Podemos incluso separar la lista de usuarios del propio Dashboard. De esta manera el DOM es más cómodo de leer, e incluso podemos reutilizar la lista por si necesitamos mostrarla en cualquier otro sitio de nuestra aplicación.

UserDashboard.jsx
// ✅ Cumple SRP
const useActiveUsers = () => {
const [users, setUsers] = useState([]);
useEffect(() => { fetch('/users').then(res => res.json()).then(setUsers); }, []);
return users.filter(user => user.isActive);
};

const UserList = ({ users }) => (
<ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>
);

const UserDashboard = () => {
const activeUsers = useActiveUsers();
return (
<div>
<h1>Usuarios Activos</h1>
<UserList users={activeUsers} />
</div>
);
};

Para este ejemplo lo dejaremos aquí, pero podríamos seguir separando responsabilidades. Puede que nos interese separar useActiveUsers en dos hooks, uno que se encargue del fetching de datos, y otro que se encargue de filtrar los usuarios para que solo devuelva los que están activos.

Hasta dónde queráis o necesitéis llegar depende de vosotros. ¡Sentido común ante todo! El objetivo no es separar por separar. Se trata de que los componentes sean fáciles de entender, fáciles de testear, modificar y mantener.

2. Principio Abierto/Cerrado (OCP)

Este principio nos dice que los componentes deben estar abiertos a extensiones, pero cerrados a modificaciones.

Básicamete lo que significa es que, a la hora de diseñar nuestros componentes, debemos diseñarlos para que sean extensibles sin necesidad de tener que modificar su código.

Pongamos un ejemplo:

Button.jsx
// ❌ NOPE
const Button = ({ children }) => (
<button onClick={() => console.log('click'))} style={{backgroundColor: 'red'}}>
{children}
</button>
);

Supongamos que un cliente nos pide un botón rojo que haga un console.log al hacer click. Pues perfecto, creamos este componente y listo.

Pero resulta que al día siguiente nos vuelve a escribir. Ahora quiere que al hacer click se abra un modal y que el botón sea azul. Al no ser el principio OCP, nos veríamos obligados a modificar el código de Button para añadirle estas funcionalidades.

Ahora imagínate tener que hacer esto en una aplicación donde Button se usa cientos de veces a lo largo de múltiples sitios. Tienes exactamente un 100% de posibilidades de romper algo 😅.

Y el problemón es que llegados a este punto, ya no hay solución. Necesitamos refactorizar Button y revisar todas sus implementaciones para asegurarnos de que no hay ningún bug. Lo suyo habría sido aplicar OCP desde el principio, tal que así:

Button.jsx
// ✅ Así sí
const Button = ({ onClick, style, children }) => (
<button onClick={onClick} style={style}>
{children}
</button>
);

De esta manera el componente está abierto a extensión, y podemos crear variantes de él sin necesidad de modificar el código original.

PrimaryButton.jsx
const PrimaryButton = ({ onClick, children }) => (
<Button onClick={onClick} style={{ backgroundColor: 'blue' }}>
{children}
</Button>
);

Abierto a extensión, cerrado a modificación. ¡Fácil! ¿No? Verás que siguiendo este principio tus componentes serán mucho más extensibles y, por tanto, reutilizables.

¡Seguimos!

3. Principio de Sustitución de Liskov (LSP)

La definición original nos dice que se deben diseñar los objetos de tal manera que un objeto pueda ser sustituíble por su supertipo. En un contexto OOP esto lo haríamos a través de la herencia de clases.

En React, para entenderlo mejor, vamos a recuperar el ejemplo del principio anterior.

Button.jsx
Sigue LSP
const Button = ({ onClick, style, children }) => (
<button onClick={onClick} style={style}>
{children}
</button>
);

const PrimaryButton = ({ onClick, style, children }) => (
<Button onClick={onClick} style={{ backgroundColor: 'blue', ...style }}>
{children}
</Button>
);

Si nos fijamos estos dos componentes comparten una relación padre-hijo similar a la que podrían compartir dos clases. PrimaryButton incluye algos estilos propios, pero mantiene la misma interfaz que el componente padre (usa los mismos props y la misma estructura).

Si quisiésemos sustituir un PrimaryButton por un Button, todo seguiría funcionando correctamente ¿Verdad? Pues exactamente eso es LSP.

¡Pero ojo! LSP es un principio que yo, personalmente, no recomiendo seguir en todos los casos. A menudo la idea de crear un componente hijo es, justamente, no tener que añadir lógica compleja que el padre no necesita.

Pero esto significaría que el hijo no puede ser intercambiado por el padre. ¿Es eso necesariamente algo negativo? No necesariamente. El concepto detrás de LSP no va tanto sobre intercambiar componentes, sino sobre que sus interfaces sean compatibles.

Button.jsx
No sigue LSP
const Button = ({ onClick, style, children }) => (
<button onClick={onClick} style={style}>
{children}
</button>
);

const PrimaryButton = ({ handleClick, style, text }) => (
<Button onClick={handleClick} style={{ backgroundColor: 'blue', ...style }}>
{text}
</Button>
);

En este ejemplo destrozamos LSP porque los componentes dejan de tener un interfaz común. Cada uno tiene sus propias props y no existe consistencia.

Con el ejemplo inicial, podemos intercambiarlos sin preocuparnos por comportamientos inesperados. Pero aquí en el momento en el que cambiasemos uno por otro, empezaríamos a tener errores.

Pues de eso trata LSP. Consistencia entre componentes.

4. Principio de Segregación de Interfaces (ISP)

ISP nos dice que los componentes no deben depender de interfaces que no usan. En otras palabras, debemos asegurarnos de no pasar props innecesarios a un componente.

Veamos un ejemplo con Typescript:

Avatar.jsx
No sigue ISP
type User = {
name: string
avatar: string
email: string
isActive: boolean
}

const Avatar = ({user}: { user: User }) => <img src={user.avatar} alt={user.name} />

export default function App() {
const user: User = {
name: "Usuario",
avatar: "Avatar",
email: "email",
isActive: true,
};

return (
<div className="App">
<Avatar user={user} />
</div>
);
}

Aquí le estamos pasando a Avatar toda la información del usuario, incluso aquella que no necesita, como email o isActive.

Bueno ¿Y cuál es el problema? Os estaréis preguntando ya algunos. Pues lo váis a ver enseguida.

Imaginemos ahora que queremos reutilizar el componente Avatar para que, además de mostrar el avatar de los usuarios, nos muestre la imagen de perfil de los artistas en un playlist (a lo Spotify).

Avatar.jsx
type User = {
name: string
avatar: string
email: string
isActive: boolean
}

type Musician = {
name: string
image: string
}

const Avatar = ({user}: { user: User | Musician }) => <img src={user.avatar} alt={user.name} />

export default function App() {
const user: User = {
name: "Usuario",
avatar: "Avatar",
email: "email",
isActive: true,
};

const artist: Musician = {
name: "Musico",
image: "Imagen",
};

return (
<div className="App">
<Avatar user={user} />
<Avatar user={artist} />
</div>
);
}

¡Con la implementación actual no podemos hacerlo! 😨

Primero porque User y Musician son incompatibles y TypeScript se nos va a quejar ya que la propiedad avatar no existe en Musician.

Y segundo porque user.avatar sería undefined si le pasamos un Musician como prop, por lo que no se vería la imagen. 😱

Vamos a refactorizar para hacer que Avatar sea mucho más flexible siguiendo el principio de segregación.

Avatar.tsx
type User = {
name: string
avatar: string
email: string
isActive: boolean
}

type Musician = {
name: string
image: string
}

type AvatarProps = {
src: string
alt: string
}

const Avatar = ({src, alt}: AvatarProps) => <img src={src} alt={alt} />

export default function App() {
const user: User = {
name: "Usuario",
avatar: "Avatar",
email: "email",
isActive: true,
};

const artist: Musician = {
name: "Musico",
image: "Imagen",
};

return (
<div className="App">
<Avatar src={user.avatar} alt={user.name} />
<Avatar src={artist.image} alt={artist.name} />
</div>
);
}

Ahora Avatar es mucho más flexible, podemos usarlo para usuarios y para artistas, y además es mucho más fácil de consumir. No necesitamos conocer la estructura de datos que necesita recibir para que el componente sea capaz de obtener la imagen.

Con un simple vistazo podemos entender fácilmente que en src va la url y cualquiera va a ser capaz de utilizarlo.

5. Principio de Inversión de Dependencias (DIP)

Este principio nos indica que los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones.

En el contexto de React podemos entender un módulo como cualquier parte de nuestra aplicación: Un componente, un hook, una helper function...

Veamos algún ejemplo para verlo más claro:

CreateUserForm.jsx
const CreateUserForm = () => {
const handleSubmit = async (e) => {
try {
const formData = new FormData(e.currentTarget);
await axios.post("https://api.com/user", formData);
} catch (err) {
console.error(err.message);
}
};

return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Create User</button>
</form>
)
}

Aquí tenemos un componente que renderiza un formulario y envía la información recibida a una api para crear un usuario.

El primer problema que tenemos es que este componente no se puede reutilizar. Supongamos que queremos crear otro formulario para editar usuarios. Exactamente la misma UI, pero una lógica diferente.

Para evitar esto vamos a eliminar esta dependencia sobre la lógica y recibirla vía props en su lugar.

UserForm.jsx
const UserForm = ({onSubmit}) => {
return (
<form onSubmit={onSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
<button type="submit">Create User</button>
</form>
)
}

¡Queda mucho más limpio! Ahora vamos a crear el componente CreateUserForm y EditUserForm

CreateUserForm.jsx
const CreateUserForm = () => {
const handleCreateUser = async (e) => {
try {
const formData = new FormData(e.currentTarget);
await axios.post("https://api.com/user", formData);
} catch (err) {
console.error(err.message);
}
};

return <UserForm onSubmit={handleCreateUser} />
}

const EditUserForm = () => {
const handleEditUser = async (e) => {
try {
const formData = new FormData(e.currentTarget);
//Logica para editar usuarios
} catch (err) {
console.error(err.message);
}
};

return <UserForm onSubmit={handleEditUser} />
}

De esta manera invertimos la dependencia y le pasamos la responsabilidad al padre. Separamos presentación de lógica y queda todo mucho más mantenible y limpio.

Conclusiones

¿Qué te ha parecido? Al principio cuesta un poquito, pero una vez te acostumbras a la lógica detrás de estos 5 principios te garantizo que tus componentes serán mucho más sencillos de gestionar.