SOLID en React
Si bien conocemos SOLID como un compendio nacido a partir de los Principios de Diseño de Clase Orientado a Objetos que vieron la luz en el documento Design Principles and Patterns de Robert Martin allá por el año 2000, razón por la cual lo relacionamos con el Paradigma Orientado a Objetos,, esto no significa que no pueda ser extrapolado a otros paradigmas, claro, esto implica tomar algunas libertades del lenguaje y paradigma no nativo al que se implemente. Por consiguiente, tendremos que hacer algunas adecuaciones para implementarlo en el Paradigma Funcional con JS y React.
Antes de continuar hagamos un repaso rápido sobre SOLID
Solid es el acrónimo de 5 principios:
Single Responsibility Principle (SRP).
Open/Closed Principle (OCP).
Liskov Substitution Principle (LSP).
Interface Segregation Principle (ISP).
Dependency Inversion Principle (DIP).
Como nota adicional debemos recordar que seguir ortodoxamente cualquier estandar, recomendación o principio, daña más de lo que ayuda, por lo que se recomienda no excederse en su uso.
Single Responsibility Principle (SRP)
Toda función, clase o componente, debe hacer sólo una cosa
Este seguramente es el principio más importante, ya que nos motiva a fragmentar el código de archivos monolíticos enormes, a archivos pequeños de 50 a 150 líneas. Esto mediante la extracción de funcionalidad a funciones separadas, volviendo así el desarrollo más modular y mantenible. Hablar de 50 a 150 líneas no es una regla escrita en piedra, sólo una medida con la que podemos comenzar a evaluar si el código puede ser fragmentado o no.
Robert Martin define "hacer una cosa" de forma sencilla << Una función hace una cosa si no puedes extraer significativamente otra función de ella. Si una función contiene código, y puedes extraer otra función de ella entonces esa función original hizo más de una cosa>>.
Como vemos esto es absolutamente independiente del paradigma de programación que utilicemos.
Para ilustrar este principio, supongamos que tenemos un componente en React llamado UserList que muestra una lista de usuarios.
const UserList = ({ userIds }) => {
const [users, setUsers] = useState([]);
useEffect(() => {
const fetchUsers = async () => {
const users = await fetchUsersFromServer(userIds);
setUsers(users);
};
fetchUsers();
}, [userIds]);
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
En este ejemplo, el componente UserList se encarga tanto de obtener los datos de usuario como de mostrarlos en la interfaz de usuario. El código que hace la llamada a la API y actualiza el estado del componente se encuentra dentro del useEffect.
Aunque este código es corto, no cumple con el principio SRP ya que el componente tiene dos responsabilidades: obtener los datos y mostrarlos. Esto hace que el componente sea más difícil de entender y mantener a medida que el código crece y cambia con el tiempo.
Además, al utilizar useEffect para actualizar el estado del componente, estamos introduciendo un efecto secundario que puede hacer que nuestro componente sea menos predecible y más difícil de probar.
Para aplicar SRP, podríamos dividir este componente en dos partes: una parte que se encarga de obtener los datos de usuario, y otra parte que se encarga de mostrarlos en la interfaz de usuario.
// Este componente se encarga de obtener los datos de usuario
const UserListData = ({ userIds, fetchUsers }) => {
useEffect(() => {
fetchUsers(userIds);
}, [userIds]);
return null;
};
// Este componente se encarga de mostrar los datos de usuario
const UserListUI = ({ users }) => (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
// Este es el componente principal que une ambas partes
const UserList = ({ userIds }) => {
const [users, setUsers] = useState([]);
const fetchUsers = async (ids) => {
const users = await fetchUsersFromServer(ids);
setUsers(users);
};
return (
<>
<UserListData userIds={userIds} fetchUsers={fetchUsers} />
<UserListUI users={users} />
</>
);
};
En este ejemplo, el componente UserListData se encarga de obtener los datos de usuario haciendo una llamada a la API del servidor y actualizando el estado del componente principal UserList a través de la función fetchUsers. Por otro lado, el componente UserListUI se encarga de mostrar los datos de usuario en la interfaz de usuario.
Open/Closed Principle (OCP)
Hacer componentes grandes a partir de más pequeños
Este principio se refiere a que se debe hacer código extensible, esto significa tener la posibilidad de añadir nuevas características sin modificar el código fuente, en POO este principio se define directamente como Polimorfismo, pero dado que en programación funcional no contamos con herencia, este principio lo toma la composición en su lugar.
React tiene como principio básico el uso de la composición, que nos permite hacer componentes grandes a partir de pequeños; y utilizando propiedades y callbacks se puede extender la funcionalidad de un componente sin necesidad de modificar el código fuente de este.
Por nada del mundo se debería hacer uso de props.children ya que esto hace que el componente dependa de hijos y rompe el principio de OCP, props.children puede utilizarse sólo en componentes simples con un hijo o que no procesan o manipulan los elementos secundarios.
Continuando con el ejemplo anterior, a nuestro componente UserList queremos añadirle la capacidad de filtrar usuarios por nombre, por lo que necesitamos extenderlo para así no modificar el componente principal.
// Este componente se encarga de obtener los datos de usuario
const UserListData = ({ userIds, fetchUsers }) => {
useEffect(() => {
fetchUsers(userIds);
}, [userIds]);
return null;
};
// Este componente se encarga de mostrar los datos de usuario
const UserListUI = ({ users }) => (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
// Este es el componente principal que une ambas partes
const UserList = ({ userIds, filter }) => {
const [users, setUsers] = useState([]);
const fetchUsers = async (ids) => {
const users = await fetchUsersFromServer(ids);
setUsers(users);
};
const filteredUsers = useMemo(() => {
if (!filter) {
return users;
}
return users.filter(user => user.name.includes(filter));
}, [users, filter]);
return (
<>
<UserListData userIds={userIds} fetchUsers={fetchUsers} />
<UserListUI users={filteredUsers} />
</>
);
};
// Este es el componente que agrega la funcionalidad de filtrado a UserList
const FilterableUserList = ({ userIds }) => {
const [filter, setFilter] = useState('');
const handleFilterChange = (event) => {
setFilter(event.target.value);
};
return (
<>
<input type="text" value={filter} onChange={handleFilterChange} />
<UserList userIds={userIds} filter={filter} />
</>
);
};
Hemos creado un nuevo componente llamado FilterableUserList que envuelve el componente UserList y agrega la funcionalidad de filtrado. Para hacer esto, hemos agregado un nuevo estado para el filtro y hemos creado una función handleFilterChange que actualiza este estado cuando cambia el valor del input de texto.
Recomendado por LinkedIn
Luego, hemos utilizado la función useMemo para calcular la lista de usuarios filtrados basados en el estado del filtro. Finalmente, hemos pasado la lista de usuarios filtrados al componente UserList como una prop llamada filter.
Anteriormente, también se mencionó el uso de callbacks como un medio para cumplir con el OCP, así que para ello consideraremos un componente Button que acepta un callback onClick como argumento para manejar el evento de clic del botón:
const Button = ({ onClick, children }) => {
return (
<button onClick={onClick}>
{children}
</button>
);
}
const App = () => {
const handleClick = () => {
console.log("Hola mundo!");
}
return (
<Button onClick={handleClick}>
Click me!
</Button>
);
}
En este ejemplo, el componente Button no depende de la implementación del callback handleClick, lo que significa que cualquier cambio en la implementación del callback no requerirá modificar el código original del componente Button. En lugar de eso, podríamos definir una interfaz específica para el callback onClick y permitir que cualquier función que implemente esa interfaz pueda ser utilizada como argumento del componente Button.
Liskov Substitution Principle (LSP)
Hacer clases sustituibles por subclases
El LSP dicta que cada clase que hereda de otra puede usarse como su padre sin necesidad de conocer las diferencias entre ellas, probablemente esto lo convierte en el principio mas arraigado a POO que podamos encontrar dentro de los 5, fácilmente ejemplificable si usamos pure components pero en componentes funcionales se vuelve complejo, lo que para algunos autores lo convierte en un principio que puede quedar para la posteridad, sin embargo no es imposible hacer una aproximación desde la composición.
En este ejemplo, tenemos dos componentes: uno para mostrar una lista de tareas y otro para agregar nuevas tareas a la lista.
Nuestro componente de lista de tareas es una función que toma un array de tareas como argumento y devuelve una lista de elementos <li> que representan cada tarea. La función se ve así:
function TaskList({ tasks }) {
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
Supongamos que queremos agregar una funcionalidad para filtrar las tareas por su estado (completado o no completado). Crearemos una función filterTasks que tome un array de tareas y un estado como argumentos y devuelva un nuevo array con solo las tareas que cumplen el estado dado.
function filterTasks(tasks, completed) {
return tasks.filter((task) => task.completed === completed);
}
Ahora podemos usar esta función dentro de nuestro componente de lista de tareas para filtrar las tareas según su estado:
function TaskList({ tasks, completed }) {
const filteredTasks = filterTasks(tasks, completed);
return (
<ul>
{filteredTasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
Con esto, seguimos el LSP ya que al crear nuestra función de filtrado, esta puede ser sustituida por otra función que tome los mismos argumentos y devuelva el mismo tipo de resultado. En lugar de depender directamente de los datos de entrada, nuestro componente de lista de tareas depende de una función de filtrado genérica que puede ser reemplazada por otra función sin afectar el comportamiento del componente.
Interface Segregation Principle (ISP)
Pasar a un componente sólo propiedades necesarias
El ISP establece que un cliente sólo debería conocer los métodos que utiliza y no los que no necesita. Esto significa que los detalles de implementación no deberían importar a ninguna función específica de alto nivel.
Un detalle de implementación es la decisión del ¿Con qué? y no del ¿Cómo? se resuelve una tarea. Por ejemplo, "almacenar facturas en una base de datos", la elección de la base de datos a usar es un detalle de implementación; también podría ser "obtener las facturas desde un API" la elección de la librería a utilizar como axios, react query, etc.., sería el detalle de implementación.
La idea de este principio extrapolado en React considerando un componente de formulario que permite al usuario ingresar datos de contacto, como nombre, dirección y número de teléfono. En lugar de tener una interfaz de formulario gigante con todos los campos incluidos, podemos crear interfaces específicas para cada tipo de campo y luego definir componentes para cada una de esas interfaces:
// Interfaces específicas para los campos del formulario
const NameField = ({ name, onChange }) => {
return (
<label>
Name:
<input type="text" value={name} onChange={e => onChange(e.target.value)} />
</label>
);
}
const AddressField = ({ address, onChange }) => {
return (
<label>
Address:
<input type="text" value={address} onChange={e => onChange(e.target.value)} />
</label>
);
}
const PhoneField = ({ phoneNumber, onChange }) => {
return (
<label>
Phone:
<input type="text" value={phoneNumber} onChange={e => onChange(e.target.value)} />
</label>
);
}
// Componente del formulario que utiliza los campos
const ContactForm = () => {
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const handleNameChange = (value) => {
setName(value);
}
const handleAddressChange = (value) => {
setAddress(value);
}
const handlePhoneNumberChange = (value) => {
setPhoneNumber(value);
}
return (
<form>
<NameField name={name} onChange={handleNameChange} />
<AddressField address={address} onChange={handleAddressChange} />
<PhoneField phoneNumber={phoneNumber} onChange={handlePhoneNumberChange} />
</form>
);
}
En este caso, los componentes NameField, AddressField y PhoneField son interfaces específicas que representan campos de formulario individuales y se utilizan en el componente ContactForm. Al separar los campos en interfaces específicas, estamos asegurándonos de que los clientes (en este caso, el componente ContactForm) solo dependan de las interfaces que necesitan para funcionar.
Dependency Inversion Principle (DIP)
Siempre ten una interfaz de código de alto nivel con una abstracción, en lugar de un detalle de implementación
El DIP establece que se debe "ocultar el cableado detrás de la pared" interactuando siempre con detalles de bajo nivel a través de abstracciones. Esto tiene fuertes vínculos con el SRP y el ISP detallados anteriormente. En la práctica con React, esto significa que a las funciones de alto nivel en nuestro código no debería importarles cómo se realiza una tarea específica. Por ejemplo, supongamos que queremos llamar a una API para obtener datos necesarios en un componente:
// Función de acceso a la API
const getData = () => Promise.resolve();
// Componente de React que necesita acceder a los datos de la API
function MyComponent({ getData }) {
const [data, setData] = useState(null);
useEffect(() => {
async function fetchData() {
const result = await getData();
setData(result);
}
fetchData();
}, [getData]);
// Renderizado del componente con los datos obtenidos de la API
// ...
}
// Función de orden superior (HOF) que recibe una función de acceso a la API y devuelve un componente con la función inyectada
function withApi(Component, getData) {
return (props) => <Component {...props} getData={getData} />;
}
// Uso de la HOF para crear un componente con la función de acceso a la API inyectada
const MyComponentWithApi = withApi(MyComponent, getData);
// Uso del componente con la función de acceso a la API inyectada
function App() {
return <MyComponentWithApi />;
}
En este caso, getData es una función que representa la funcionalidad que necesitamos de la API. En MyComponent, el componente depende de la función getData en lugar de depender directamente de la implementación concreta de la API. withApi es una función de orden superior (HOF) que recibe la función getData y devuelve un componente con la función inyectada. Esto hace que el componente sea más flexible y fácil de probar, ya que podemos inyectar diferentes funciones de acceso a la API para diferentes casos de uso cumpliendo así con lo establecido por el DIP.
Con esto concluimos esta pequeña guía sobre Cómo utilizar SOLID en nuestros proyectos con React y su Paradigma funcional, espero les sea de utilidad y les ayude a promover las buenas prácticas y el código limpio.