Principios SOLID en React
Cómo podemos aplicar los principios SOLID en React.
Los principios SOLID fueron compilados por Bob Martin en un paper en el año 2000, aunque el acrónimo fue inventando más adelante por Michael Feathers. El “tio Bob” es el autor de los famosos libros Clean Code y Clean Architecture. En este último, en el capítulo 3 empieza a considerar los principios SOLID y después le dedica un capítulo a cada principio.
Los principios SOLID nos ayudan a la hora de organizar funciones y estructuras de datos en clases y determinar cómo estas se interconectan. En React hoy en día se utilizan funciones en vez de clases para los componentes y hooks, sin embargo los principios SOLID no son exclusivos a la Programación Orientada a Objetos (OOP). El tío Bob explica en el libro mencionado anteriormente que cuando habla de una clase él se refiere a un grupo de funciones y datos acoplados, también se refiere a esto como un módulo. La idea es que estos módulos (sean clases o no) puedan soportar los cambios y también sean fáciles de entender.
Como se trata de principios, hay diferentes maneras de aplicarlos. Para que nuestro proyecto o forma de programar se beneficie de ellos, tenemos que entender bien la idea de cada principio. En este artículo consideramos una breve explicación de cada principio y algunas aplicaciones a proyectos de Frontend que utilizan React. También puedes ver este video donde considero este tema:
Single Responsibility Principle (SRP)
El principio de responsabilidad única (SRP) pareciera dar a entender que una función debería hacer una sola cosa. Si bien esto es una buen práctica, el tío Bob explica que SRP no se refiere a eso. La definición que el da es: un módulo debería tener una sola razón para cambiar, o siendo más precisos, un módulo debería ser responsable a un solo actor. En este contexto un actor es una persona o un grupo de personas que piden cambios en el software.
Si bien Bob da un ejemplo usando OOP (más aplicable a backend), considerar este ejemplo puede ayudarnos a entender la idea. Supongamos que tenemos una clase Employee
(Empleado) que tiene tres métodos o funciones:
1
2
3
4
5
6
7
8
9
10
export class Employee {
public calculatePay() {
}
public reportHours() {
}
public save() {
}
}
Pensemos en los actores y los métodos:
calculatePay()
lo utiliza el departamento de contabilidad para calcular el salario de los empleadosreportHours()
es utilizado por el departamento de recursos humanos para saber cuántas horas trabajó cada empleadosave()
es de interés al departamento técnico para guardar la información en la base de datos.
En esta clase tenemos un problema de coupling (acoplamiento) entre diferentes actores. Si el departamento de contabilidad nos pide un cambio y retocamos esta clase, esto pudiera traer cambios inesperados al código que utiliza el departamento de recursos humanos. El resultado pudiera ser bugs que causen pérdidas de millones de dólares a la empresa.
Aplicando SRP podemos separar esta clase pensando en los tres diferentes actores mencionados, así organizamos el código en tres diferentes clases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export class PayCalculator {
public calculatePay() {
}
}
export class HourReporter {
public reportHours() {
}
}
export class EmployeeRepository {
public save() {
}
}
Con esto evitamos que el cambio que nos pide un actor afecte el código que utilice otro actor.
SRP en React
¿Cómo podemos llevar esta idea al Frontend en React? Pensemos en un componente que muestra una lista de personas. Este componente se está encargando de determinar cómo se ve esta lista: el tamaño de la tipografía, los colores, si es una tabla o una lista de tarjetas, etc. Pero también se encarga de traer los datos de estas personas desde un API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import { useEffect, useState } from "react";
import { Person } from "../../types";
import { CircularProgress, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { ActionButton } from "./action-button";
export function PersonsList() {
const [ persons, setPersons ] = useState<Person[]>([]);
const [ isLoading, setIsLoading ] = useState<boolean>(true);
// Load persons from API
useEffect(() => {
async function loadPersons() {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const data = await response.json();
setPersons(data);
setIsLoading(false);
}
loadPersons();
}, []);
return <>
{
isLoading ?
<CircularProgress /> :
(<TableContainer component={Paper}>
<Table sx= aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Username</TableCell>
<TableCell>E-mail</TableCell>
<TableCell>Company</TableCell>
<TableCell>Address</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Website</TableCell>
<TableCell>Edit</TableCell>
<TableCell>Delete</TableCell>
</TableRow>
</TableHead>
<TableBody>
{persons.map((person) => (
<TableRow
key={person.id}
>
<TableCell component="th" scope="row">
{person.name}
</TableCell>
<TableCell>
{person.username}
</TableCell>
<TableCell>
{person.email}
</TableCell>
<TableCell>
{person.company.name}
</TableCell>
<TableCell>
{person.address.street}, {person.address.city}
</TableCell>
<TableCell>
{person.phone}
</TableCell>
<TableCell>
{person.website}
</TableCell>
<TableCell>
<ActionButton text='Edit' />
</TableCell>
<TableCell>
<ActionButton isDelete={true} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>)
}
</>
}
Entonces nos ponemos a pensar… ¿A qué actores responde este código? ¿Quiénes pudieran solicitar cambios de manera independiente? Identificamos a dos actores:
- Diseñadores de UI/UX: determinan cómo se ve la aplicación y cómo el usuario interactúa con ella. Seguramente en el futuro nos pedirán cambios.
- Proveedor de API: si bien pudiera ser uno mismo como programador full-stack, en un proyecto grande pudiera ser un equipo de backend separado, o si el API está tercerizado pudiera ser una compañía externa.
Teniendo en cuenta los actores, ahora decidimos separar el código, por un lado tenemos un Hook que se encarga de traer los datos del API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function usePersons() {
const [ persons, setPersons ] = useState<Person[]>([]);
const [ isLoading, setIsLoading ] = useState<boolean>(true);
// Load persons from API
useEffect(() => {
async function loadPersons() {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
const data = await response.json();
setPersons(data);
setIsLoading(false);
}
loadPersons();
}, []);
return {
persons,
isLoading,
}
}
Y por otro lado un componente que se encarga de determinar cómo se ve la información, consumiendo el hook mencionado:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
export function PersonsList() {
const { persons, isLoading } = usePersons();
return <>
{
isLoading ?
<CircularProgress /> :
(<TableContainer component={Paper}>
<Table sx= aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Username</TableCell>
<TableCell>E-mail</TableCell>
<TableCell>Company</TableCell>
<TableCell>Address</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Website</TableCell>
<TableCell>Edit</TableCell>
<TableCell>Delete</TableCell>
</TableRow>
</TableHead>
<TableBody>
{persons.map((person) => (
<TableRow
key={person.id}
>
<TableCell component="th" scope="row">
{person.name}
</TableCell>
<TableCell>
{person.username}
</TableCell>
<TableCell>
{person.email}
</TableCell>
<TableCell>
{person.company.name}
</TableCell>
<TableCell>
{person.address.street}, {person.address.city}
</TableCell>
<TableCell>
{person.phone}
</TableCell>
<TableCell>
{person.website}
</TableCell>
<TableCell>
<ActionButton text='Edit' />
</TableCell>
<TableCell>
<ActionButton isDelete={true} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>)
}
</>
}
Podemos llevar la idea un paso más adelante. Seguimos pensando en los diferentes departamentos de la compañía que utiliza el software y de ahí surgen diferentes roles:
- Departamento de contabilidad: se encarga de calcular el salario dependiendo de las horas trabajadas y las horas extras ellos necesitan ver la información de esta lista.
- Recursos humanos necesita también ver la cantidad de horas exactas que cada persona trabajó en esta lista para determinar si necesitan contratar más personal en ciertas áreas.
Como se trata de una lista de personas con datos similares en principio decidimos utilizar el mismo componente para ambos. Pero si algo cambia en la manera en que contabilidad calcula las horas extras (como dice el ejemplo del libro de tío Bob), esto pudiera afectar a recursos humanos de manera inesperada. Aplicando SRP podemos separar el código de acuerdo con los actores, en este caso con un componente para cada uno:
1
2
3
4
5
6
7
8
9
10
11
12
13
export function AccountingPersonsList() {
const { persons, isLoading } = usePersons();
// Renderizado de acuerdo al departamento de contabilidad.
return <></>;
}
export function HumanResourcesPersonsList() {
const { persons, isLoading } = usePersons();
// Renderizado de acuerdo al departamento de recursos humanos.
return <></>;
}
Open-Closed Principle (OCP)
El principio de abierto-cerrado (OCP) dice que un módulo debería estar abierto a la extensión pero cerrado a la modificación. Dicho de otra manera, el comportamiento de un módulo debería ser extensible, si tener que modificarlo. Este es un principio muy profundo y hay diferentes maneras de aplicarlo.
OCP en React
Supongamos que nuestra aplicación requiere que el usuario confirme antes de eliminar un registro. Para eso tenemos un componente de modal reutilizable ConfirmationModal
. Este componente utiliza Material UI para dibujar el modal, y recibe props que le van a permitir al código que consume este componente determinar cuál es el título, el texto de la confirmación así como también el texto de los botones:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
type ConfirmationModalProps = {
isOpen: boolean;
title: string;
text: string;
acceptButtonText: string;
cancelButtonText: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmationModal({
isOpen,
title,
text,
acceptButtonText,
cancelButtonText,
onConfirm,
onCancel,
}: ConfirmationModalProps) {
const handleConfirm = () => {
onConfirm();
};
const handleClose = () => {
onCancel();
}
return (
<Dialog
open={isOpen}
onClose={handleClose}
>
<DialogTitle>
{title}
</DialogTitle>
<DialogContent>
<DialogContentText>
{text}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleConfirm}>{cancelButtonText}</Button>
<Button onClick={handleClose} autoFocus>
{acceptButtonText}
</Button>
</DialogActions>
</Dialog>
);
}
El consumidor del componente le está pasando mediante el prop text
el mensaje de confirmación incluyendo el nombre de una persona:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export function Example() {
const [personToBeDeleted, setPersonToBeDeleted] = useState<Person | undefined>();
const { person } = useGetPerson();
const isConfirmDeleteModalOpen = personToBeDeleted !== undefined;
const confirmDeleteModalTitle = `Confirm Person Deletion`;
const confirmDeleteDialogText = personToBeDeleted ? `Are you sure you want to delete ${personToBeDeleted.name}?` : '';
const onPersonDeleteClicked = (person: Person) => {
setPersonToBeDeleted(person);
};
const onPersonDeleteConfirmed = () => {
// Process person deletion
setPersonToBeDeleted(undefined);
};
const onPersonDeleteCancelled = () => {
setPersonToBeDeleted(undefined);
}
return <>
{ person ? <PersonCard person={person} onDeleteClicked={onPersonDeleteClicked} /> : <></>}
<ConfirmationModal
isOpen={isConfirmDeleteModalOpen}
title={confirmDeleteModalTitle}
text={confirmDeleteDialogText}
acceptButtonText='Delete'
cancelButtonText='Cancel'
onConfirm={onPersonDeleteConfirmed}
onCancel={onPersonDeleteCancelled}
/>
</>
}
Un día nos piden que el nombre de la persona se muestre con otro estilo, quizá en bold (negrita) o con otro color, y que también el título de este modal de confirmación se vea diferente. Pero para esto no queremos hacer grandes cambios en nuestro componente original ConfirmationModal
ya que hay otros consumidores que lo utilizan y que no tienen esas necesidades específicas.
Aplicando el principio de OCP podemos ajustar el componente para que reciba el prop children en vez de recibir el texto, y para el título recibimos un nodo de react, en vez de recibir String
en ambos casos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { PropsWithChildren, ReactNode } from 'react';
type ConfirmationModalProps = {
isOpen: boolean;
title: ReactNode;
acceptButtonText: string;
cancelButtonText: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmationModal({
isOpen,
title,
acceptButtonText,
cancelButtonText,
children,
onConfirm,
onCancel,
}: PropsWithChildren<ConfirmationModalProps>) {
const handleConfirm = () => {
onConfirm();
};
const handleClose = () => {
onCancel();
}
return (
<Dialog
open={isOpen}
onClose={handleClose}
>
<DialogTitle>
{title}
</DialogTitle>
{children}
<DialogActions>
<Button onClick={handleConfirm}>{cancelButtonText}</Button>
<Button onClick={handleClose} autoFocus>
{acceptButtonText}
</Button>
</DialogActions>
</Dialog>
);
}
El consumidor ahora puede personalizar cómo se va a mostrar tanto el título como el texto, aplicando la etiqueta <b>
(bold) de HTML:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function Example() {
...
return <>
{ person ? <PersonCard person={person} onDeleteClicked={onPersonDeleteClicked} /> : <></>}
<ConfirmationModal
isOpen={isConfirmDeleteModalOpen}
title={<div>Confirm Person <b>Deletion</b></div>}
acceptButtonText='Delete'
cancelButtonText='Cancel'
onConfirm={onPersonDeleteConfirmed}
onCancel={onPersonDeleteCancelled}
>
<ConfirmationModalContent>
Are you sure you want to delete <b>{personToBeDeleted?.name}</b>?
</ConfirmationModalContent>
</ConfirmationModal>
</>
}
Como se ve en el ejemplo, mediante composición estamos utilizando un componente ConfirmationModalContent
que también recibe children y utiliza componentes de Material UI para mostrar el contenido:
1
2
3
4
5
6
7
8
9
10
11
export function ConfirmationModalContent({
children,
}: PropsWithChildren) {
return (
<DialogContent>
<DialogContentText>
{children}
</DialogContentText>
</DialogContent>
)
}
El punto es que el ConfirmationModal
ya no necesita modificarse para extender la forma en que renderiza su contenido. Ahora entonces cuando mostramos el modal de confirmación, el nombre de la persona está en bold (negrita):
Liskov-Substitution Principle (LSP)
El principio de sustitución de Liskov lleva el nombre de su autora, Barbara Liskov. Este principio está fuertemente basado en la Programación Orientada a Objetos (OOP), dice que los objetos de subtipos deberían ser sustituibles por objetos de supertipos. Estamos hablando de la herencia en la OOP donde hay clases padre (super tipo) y clases hija (subtipos) que heredan métodos y propiedades de su padre. Entonces, el principio podría explicarse como que si una clase B extiende una clase A, entonces deberíamos poder utilizar B en cualquier lugar donde usamos A, sin cambiar la funcionalidad importante de la aplicación.
Primero vamos a ver un ejemplo de backend para entender la idea. La clase padre Database
es lo suficientemente genérica para que la utilice mi aplicación, que sencillamente necesita conectarse a una base de datos. Tenemos dos clases hijas: las implementaciones MySQL y SQLite deben ser compatibles con el método connect()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export class Database {
public connect() {
}
}
export class MySQLDatabase extends Database {
public connect() {
// Specific MySQL Code.
}
}
export class SQLiteDatabase extends Database {
public connect() {
// Specific SQLite Code.
}
}
Cada una tendrá su manera diferente de conectarse. Sin embargo, en el código de la aplicación vemos que cuando esta inicia se conecta a la base de datos, sin importar cuál implementación está utilizando, porque estamos siguiendo el principio de sustitución de Liskov:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Application {
private database: Database;
constructor(database: Database) {
this.database = database;
}
public start() {
this.database.connect();
}
}
const database = new MySQLDatabase();
const application = new Application(database);
application.start();
LSP en React
Vamos a intentar aplicar esta idea en React donde generalmente no se utiliza Programación Orientada a Objetos. Aunque no tenemos herencia, si utilizamos composición.
En este ejemplo tenemos tres componentes para botones:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import styled from '@emotion/styled';
import { Box, Button, ButtonProps } from '@mui/material';
import { FC } from 'react';
export function ButtonsExample() {
const onButtonClicked = (buttonType: string) => {
console.log(`Button ${buttonType} clicked!`);
}
return <>
<Box display='flex'>
<Button variant="contained" onClick={() => onButtonClicked('normal')}>Normal</Button>
<SquareButton variant="contained" onClick={() => onButtonClicked('squared')}>Squared</SquareButton>
</Box>
<ContainedButton variant="contained" onClick={() => onButtonClicked('contained')}>Contained</ContainedButton>
</>;
}
const SquareButton = styled(Button)({
borderRadius: 0,
marginLeft: '1rem',
});
const ContainedButton: FC<ButtonProps> = (props) => {
return <Box marginY={2}>
<Button fullWidth={true} {...props}>{props.children}</Button>
</Box>;
}
Button
viene directamente de la libería Material UI.SquareButton
es compatible con el primero, podemos pasarle los mismos props. En este caso utilizamos styled de emotion para darle estilo al componente con CSS-in-JS.ContainedButton
utiliza composición para personalizar cómo se mostrará el botón, en este caso dentro de un Box, ocupando todo el ancho del mismo. Cuando hacemos este tipo de composición tenemos que pasarle todos los props al componente “padre” (en este caso Button) utilizando el spread operator{...props}
.
Los tres diferentes componentes para botones reciben las mismas propiedades (variant
y onClick
). Al ser compatibles, podemos intercambiarlos sin romper la funcionalidad de la aplicación.
Interface Segregation Principle (ISP)
El principio de segregación de la interfaz dice que un módulo de software no debería depender de interfaces que no utiliza.
Para entender la idea veamos un ejemplo de backend con OOP. Notemos las siguientes tres clases de servicio: uno para generar reportes de usuario, otro para crear usuarios y el último para eliminar usuarios:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export class UserReportService {
constructor(private userRepository: IUserRepository) {
}
public print() {
const users = this.userRepository.getAll();
console.log(`Printing users`, users);
}
}
export class UserCreationService {
constructor(private userRepository: IUserRepository) {
}
public create(user: User) {
return this.userRepository.create(user);
}
}
export class UserDeletionService {
constructor(private userRepository: IUserRepository) {
}
public delete(user: User) {
this.userRepository.delete(user);
}
}
Estamos utilizando el patrón de repository (repositorio). Este es el código de UserRepository
, que se encargará del manejo de la base de datos. Permitirá obtener la lista de usuarios, también crear o eliminarlos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export interface IUserRepository {
getAll(): User[];
create(user: User): User;
delete(user: User): void;
}
export class UserRepository implements IUserRepository {
public getAll(): User[] {
return [];
}
public create(user: User) {
console.log(`Creating user ${user.name}`);
return user;
}
public delete(user: User): void {
console.log(`Deleting user ${user.name}`);
}
}
El repositorio está implementando la interfaz IUserRepository
, esto es importante ya que estamos hablando del principio de segregación de la interfaz.
Si miramos de nuevo a UserReportService
que genera reportes, podemos notar que solamente utiliza el método getAll
del repositorio, claro nunca va a crear o eliminar usuarios como lo hacen UserCreationService
y UserDeletionService
.
Ahora aplicando el principio de segregacion de la interfaz separamos IUserRepository
en dos: una con los métodos de lectura y otra con los métodos de escritura. El repositorio sigue siendo el mismo, solamente que implementa dos interfaces separadas:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export interface IUserReadRepository {
getAll(): User[];
}
export interface IUserWriteRepository {
create(user: User): void;
delete(user: User): void;
}
export class UserRepository implements IUserReadRepository, IUserWriteRepository {
public getAll(): User[] {
return [];
}
public create(user: User) {
console.log(`Creating user ${user.name}`);
}
public delete(user: User): void {
console.log(`Deleting user ${user.name}`);
}
}
Esto permite a los diferentes tipos de clientes consumir solo la interfaz que ellos necesitan. Entonces en vez de tener una interfaz de propósito general, ahora hemos segregado esa interfaz en algunas más específicas. No es necesario crear una interfaz por cada cliente (eso podría resultar en muchas interfaces) sino que la hemos separado por tipo de cliente, uno de lectura y otro de escritura. Esto abre la posibilidad en el futuro de separar el repository, uno de escritura y otro de lectura que se conectan a diferentes instancias de la base de datos. Esto se hace más fácil gracias a que primero hemos segregado la interfaz:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export interface IUserReadRepository {
getAll(): User[];
}
export interface IUserWriteRepository {
create(user: User): void;
delete(user: User): void;
}
export class UserReadOnlyRepository implements IUserReadRepository {
public getAll(): User[] {
return [];
}
}
export class UserWriteRepository implements IUserWriteRepository {
public create(user: User) {
console.log(`Creating user ${user.name}`);
}
public delete(user: User): void {
console.log(`Deleting user ${user.name}`);
}
}
ISP en React
Ahora llevando esta idea al Frontend con React, supongamos que tenemos un componente de reporte de usuario que está trayendo todos los usuarios desde un hook useUsers()
para imprimir los usuarios en pantalla:
1
2
3
4
5
6
7
export function UserReport() {
const { users, isLoadingUsers } = useUsers();
return isLoadingUsers ?
<>Loading Users</> :
users.map((user) => (<div>{user.name}</div>))
}
También tenemos un formulario de creación de usuarios que permite crear un usuario, y en este caso estamos utilizando el mismo hook useUsers()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export function UserCreationForm() {
const { createUser } = useUsers();
const [name, setName] = useState<string>();
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
setName(event.target.value);
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name) {
alert('User name is not valid');
return;
}
createUser(name);
}
return <form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleNameChange} />
</label>
<input type="submit" value="Submit" />
</form>
}
El hook useUsers()
está encapsulando toda la funcionalidad que tiene que ver con los usuarios. Está trayendo los usuarios desde el API y también tiene funciones de creación y eliminación de usuarios:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
export function useUsers() {
const [ users, setUsers ] = useState<User[]>([]);
const [ isLoadingUsers, setIsLoadingUsers ] = useState<boolean>(true);
// Load users from API
useEffect(() => {
async function loadUsers() {
const response = await fetch('/api/user');
const data = await response.json();
setUsers(data);
setIsLoadingUsers(false);
}
loadUsers();
}, []);
const createUser = async (name: string) => {
const user: Partial<User> = {
name,
};
await fetch('/api/user', {
method: 'POST',
body: JSON.stringify(user),
});
}
const deleteUser = async (userId: string) => {
await fetch(`/api/user/${userId}`, {
method: 'DELETE',
});
}
return {
users,
createUser,
deleteUser,
isLoadingUsers,
}
}
Ahora como estamos utilizando el mismo hook, tenemos el efecto no deseado de que en el formulario que se utiliza para crear un usuario, se está llamando al API para traer la lista de usuarios.
Aplicando el principio de segregación de la interfaz, podemos separar el hook que tenía una interfaz más amplia o general, en dos diferentes hooks con interfaces más específicas: uno para obtener los usuarios del API y otro para administrar usuarios (crear o eliminarlos):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
export function useGetUsers() {
const [ users, setUsers ] = useState<User[]>([]);
const [ isLoadingUsers, setIsLoadingUsers ] = useState<boolean>(true);
// Load users from API
useEffect(() => {
async function loadUsers() {
const response = await fetch('/api/user');
const data = await response.json();
setUsers(data);
setIsLoadingUsers(false);
}
loadUsers();
}, []);
return {
users,
isLoadingUsers,
}
}
export function useManageUsers() {
const createUser = async (name: string) => {
const user: Partial<User> = {
name,
};
await fetch('/api/user', {
method: 'POST',
body: JSON.stringify(user),
});
}
const deleteUser = async (userId: string) => {
await fetch(`/api/user/${userId}`, {
method: 'DELETE',
});
}
return {
createUser,
deleteUser,
}
}
Entonces el reporte ahora va a utilizar solamente el hook useGetUsers()
para traer la lista, mientras que el formulario utilizará useManageUsers()
para crear un usuario.
Apliquemos esta misma idea pero ahora a las props (propiedades) de un componente considerándolas como su interfaz. Tenemos una lista de usuarios donde ahora se nos pide mostrar una imagen de perfil para cada uno de ellos.
Entonecs creamos un componente donde inicialmente decidimos pasarle un objeto del tipo User
que tiene varias propiedades, entre ellas profileThumbnail
que tiene la URL de la imagen:
1
2
3
4
5
6
7
8
9
type ThumbnailProps = {
user: User;
}
export function Thumbnail({
user,
}: ThumbnailProps) {
return <img src={user.profileThumbnail} />
}
Como vemos, le estoy pasando más información de la que el componente necesita ya que User
tiene otras propiedades:
1
2
3
4
5
6
7
8
9
10
11
export type User = {
id: number;
name: string;
username: string;
email: string;
company: Company;
address: Address;
phone: string;
website: string;
profileThumbnail: string;
}
Esto trae un problema cuando trabajamos con una lista de compañías donde también necesitamos mostrar una imagen, ya no podemos utilizar el componente Thumbnail
porque estamos trabajando con otro tipo de datos, Company
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export type Company = {
name: string;
catchPhrase: string;
bs: string;
logoThumbnail: string;
}
export function CompanyList() {
const { companies, isLoadingCompanies } = useGetCompanies();
return isLoadingCompanies ?
<>Loading Companies</> :
companies.map((company) => (
<div>
<div>Company Name: {company.name}</div>
{/*
No podemos usar Thumbnail, porque company no es compatible con user
<Thumbnail user={company} />
*/}
</div>
))
}
Aplicando el principio de segregación de la interfaz, el componente Thumbnail
que antes recibía un usuario ahora solamente recibe imageUrl
, ya que por ahora lo único que necesita el componente es una URL para cargar la imagen:
1
2
3
4
5
6
7
8
9
type ThumbnailProps = {
imageUrl: string;
}
export function Thumbnail({
imageUrl,
}: ThumbnailProps) {
return <img src={imageUrl} />
}
Como hemos simplificado la interfaz -los props que recibe el componente- entonces podemos reutilizarlo tanto en la lista de usuarios como en la lista de compañías.
Podríamos resumir esta aplicación del principio de ISP como: un componente solamente debería depender de las props que verdaderamente necesita.
Dependency-Inversion Principle (DIP)
El principio de inversión de dependencias dice que tenemos que depender de una abstracción y no de una implementación.
Primero veamos cómo se aplica en OOP. En el siguiente diagrama tenemos una clase PhotoService
que tiene una dependencia: PhotoRepository
, que se encarga de las llamadas a la base de datos.
Como se está dependiendo directamente de una implementación, existe un alto acoplamiento (coupling) entre ambas clases. Esto pudiera traernos problemas en le futuro si tenemos que cambiar PhotoRepository
(por ejemplo si ahora tiene que comunicarse con una base de datos diferente).
Aplicando el principio de inversión de dependencias, creamos una absracción: en este caso una interfaz IPhotoRepository
que define un contrato.
Ahora PhotoService
depende de esa interfaz y no de la implementación. Por su parte, PhotoRepository
implementa ese contrato. Ahora el sistema es más flexible porque cuando necesite cambiar PhotoRepository
, mientras respete la interfaz (que sirve de contrato) no tendría que haber problemas de compatibilidad.
El código se vería algo así en un proyecto de NestJS que cuenta con un sistema de dependency injection (inyección de dependencias) :
1
2
3
export interface IPhotoRepository {
findAll(): Promise<Photo[]>;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Inject } from '@nestjs/common':
export class PhotoService {
constructor(
@Inject('photoRepository')
private readonly photoRepository: IPhotoRepository,
) {
}
public async findAll(): Promise<Photo[]> {
return await this.photoRepository.findAll();
}
}
PhotoService
depende de la interfaz (una abstracción) y no de una implementación. Con el decorador @Inject
al cual se le pasa el token photoRepository
le estamos diciendo a NestJS se encargue de inyectar la dependencia. ¿Cómo sabe NestJS qué implementación utilizar? Al configurar el módulo se especifican los providers (proveedores) donde se asocia el dependency injection token photoRepository
con una clase que implementa la interfaz:
1
2
3
4
5
6
7
8
9
@Module({
providers: [
{
provide: 'photoRepository',
useClass: PhotoRepository,
}
],
})
export class PhotoModule {}
Otro beneficio de aplicar este principio es que los unit tests son más fáciles, ya que se puede inyectar fácilmente una clase mock en el lugar de la dependencia.
DIP en React
Aunque no se utiliza a menudo en el Frontend, vamos a ver cómo se podría aplicar la idea. Volviendo a nuestro componente de reporte de usuarios, ahora está trayendo la lista de usuarios de un hook pero algo cambió:
1
2
3
4
5
6
7
8
9
import { UserService } from "../service";
export function UsersReport() {
const { users, isLoadingUsers } = UserService.useGetUsers();
return isLoadingUsers ?
<>Loading Users</> :
users.map((user) => (<div>{user.name}</div>))
}
¿Notaste algo diferente? Estamos utilizando un servicio, UserService
. Esto no es muy común en el mundo del Frontend, lo que estamos haciendo es agrupar funciones relacionadas en un objeto como una manera de organizar el código:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const UserService = {
useGetUsers,
useManageUsers,
}
function useGetUsers() {
...
return {
users,
isLoadingUsers,
}
}
function useManageUsers() {
...
return {
createUser,
deleteUser,
}
}
Como vemos los hooks siguen siendo funciones individuales, pero se exportan por medio de un objeto. De esa manera cuando se consume el hook, se lo llama mediante el objeto UserService.useGetUsers()
, comunicando el contexto al cual pertenece el hook. No es que necesitemos agrupar el código de esta manera para aplicar DIP, pero nos ayuda en la comparación con el código que mostramos que está basado en OOP (donde se usan clases para agrupar métodos relacionados).
Ahora veamos el código de useGetUsers()
. Trata de identificar algo nuevo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function useGetUsers() {
const { getAll }: IUserRepository = useUserRepository();
const [ users, setUsers ] = useState<User[]>([]);
const [ isLoadingUsers, setIsLoadingUsers ] = useState<boolean>(true);
// Load users from API
useEffect(() => {
async function loadUsers() {
const data = await getAll();
setUsers(data);
setIsLoadingUsers(false);
}
loadUsers();
}, [getAll]);
return {
users,
isLoadingUsers,
}
}
Estamos llamando a useUserRepository()
que está devolviendo la implementación de una interfaz IUserRepository
que define un contrato de funciones:
1
2
3
4
5
6
export interface IUserRepository {
getAll(): Promise<User[]>;
create(user: Partial<User>): Promise<void>;
update(user: Partial<User>): Promise<void>;
remove(userId: string): Promise<void>;
}
De esta manera estamos dependiendo de una interfaz y no de la implementación. ¿Pero cómo podemos proveer la implementación en React? Una manera de hacerlo es utilizando Context. Originalmente se pensó para compartir estado dentro de un árbol de componentes y así evitar la perforación de props. Pero aquí vemos como usar Context para inyección de dependencias. Primeramente necesitamos un context que hace referencia a la interfaz:
1
2
3
import { createContext } from "react";
export const UserRepositoryContext = createContext<IUserRepository | null>(null);
También necesitamos un Provider, donde asociamos la interfaz con la implementación (en este caso UserFetchRepository
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type UserRepositoryProviderProps = {
children: React.ReactNode;
};
export function UserRepositoryProvider({
children,
}: UserRepositoryProviderProps) {
const contextValue: IUserRepository = new UserFetchRepository();
return (
<UserRepositoryContext.Provider value={contextValue}>
{children}
</UserRepositoryContext.Provider>
);
}
Finalmente agregamos un hook que hace disponible el context que contiene la dependencia IUserRepository
:
1
2
3
4
5
6
7
export function useUserRepository() {
const context = useContext(UserRepositoryContext);
if (!context) {
throw new Error(`useDependencies must be used within UserRepositoryProvider`);
}
return context;
}
Para que esto funcione tenemos que asegurarnos de utilizar el provider en el renderizado de la app, antes de renderizar el componente de reporte (que utiliza el servicio que a su vez hace uso de la dependencia):
1
2
3
4
5
6
export function App() {
return <UserRepositoryProvider>
<UsersReport />
<UserCreationForm />
</UserRepositoryProvider>
}
El punto importante es que estamos dependiendo de la interfaz IUserRepository
(una abstracción) y no de una implementación específica:
1
2
function useGetUsers() {
const { getAll }: IUserRepository = useUserRepository();
Volviendo al Provider, que es donde estamos proveyendo la implementación, podemos cambiarla por otra, en este caso utilizando UserAxiosRepository
en vez de UserFetchRepository
:
1
2
3
4
5
export function UserRepositoryProvider({
children,
}: UserRepositoryProviderProps) {
const contextValue: IUserRepository = new UserAxiosRepository();
Para referencia, esta es la clase de implementación UserAxiosRepository()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import axios from "axios";
import { User } from "../../../types";
import { IUserRepository } from "../interface";
export class UserAxiosRepository implements IUserRepository {
public async getAll(): Promise<User[]> {
return axios.get('/api/user');
}
public async create(user: Partial<User>): Promise<void> {
return axios.post('/api/user', user);
}
public async update(user: Partial<User>): Promise<void> {
return axios.put('/api/user', user);
}
public async remove(userId: string): Promise<void> {
await axios.delete(`/api/user/${userId}`);
}
}
Muchas librerías javascript utilizan clases. Esta es una manera de inyectar una instancia global que puede accederse desde cualquier hook. Ahora si no queremos usar una clase también podemos utilizar un objeto con funciones como habíamos visto anteriormente, en este caso para UserFetchRepository
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { User } from "../../../types";
import { IUserRepository } from "../interface";
export const UserFetchRepository: IUserRepository = {
getAll,
create,
update,
remove,
}
async function getAll(): Promise<User[]> {
const response = await fetch('/api/user');
return await response.json();
}
async function create(user: Partial<User>): Promise<void> {
await fetch('/api/user', {
method: 'POST',
body: JSON.stringify(user),
});
}
async function update(user: Partial<User>): Promise<void> {
await fetch('/api/user', {
method: 'PUT',
body: JSON.stringify(user),
});
}
async function remove(userId: string): Promise<void> {
await fetch(`/api/user/${userId}`, {
method: 'DELETE',
});
}
La diferencia es que al inyectar la dependencia no tenemos que crear una instancia de una clase sino directamente hacer referencia al objeto:
1
2
3
4
5
export function UserRepositoryProvider({
children,
}: UserRepositoryProviderProps) {
const contextValue: IUserRepository = UserFetchRepository;
Sin importar cuál implementación estamos utilizando, los hooks de UserService
no cambian. Esto resulta beneficioso cuando utilizamos una librería que pudiera cambiar en el futuro (ya sea una nueva versión de la misma u otra librería diferente). Como tenemos bajo acoplamiento, solamente necesitamos cambiar una parte del código y el resto (protegido mediante la interfaz) no cambia.
Ahora veamos una manera más sencilla de aplicar el principio de inversión de dependencias. Tenemos un formulario de creación o actualización de usuario. Cuando se hace submit del formulario, se llama a handleSubmit()
que decide si se trata de crear o actualizar dependiendo de si ya existe el usuario que se recibe como prop opcional en UserForm
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type UserFormProps = {
user?: User;
}
export function UserForm({
user,
}: UserFormProps) {
const { createUser, updateUser } = useManageUsers();
const [name, setName] = useState<string>(user ? user.name : '');
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
setName(event.target.value);
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name) {
alert('User name is not valid');
return;
}
if (user) {
const updatedUser = {
...user,
name,
};
updateUser(updatedUser);
} else {
createUser(name);
}
}
return <form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleNameChange} />
</label>
<button type="submit">Create</button>
</form>
}
Comparemos con esta otra implementación. El formulario ahora recibe como prop una función onSubmit
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type UserFormProps = {
user?: User;
onSubmit: (user: Partial<User>) => Promise<void>;
}
export function UserForm({
user,
onSubmit,
}: UserFormProps) {
const [name, setName] = useState<string>(user ? user.name : '');
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
setName(event.target.value);
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name) {
alert('User name is not valid');
return;
}
const updatedUser: Partial<User> = {
...user,
name: name
};
onSubmit(updatedUser);
}
return <form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleNameChange} />
</label>
<button type="submit">Create</button>
</form>
}
De esta manera se invierte el control al padre, quien define qué hacer una vez que el formulario está listo para enviar la información al backend. UserCreate
creará al usuario:
1
2
3
4
5
6
7
8
9
10
11
12
13
export function UserCreate() {
const { createUser } = useManageUsers();
async function handleSubmit(user: Partial<User>) {
if (!user.name) {
return;
}
await createUser(user.name);
}
return <UserForm onSubmit={handleSubmit} />
}
Mientras que UserUpdate
actualizará al usuario:
1
2
3
4
5
6
7
8
9
10
11
12
13
export function UserUpdate() {
const { updateUser } = useManageUsers();
async function handleSubmit(user: Partial<User>) {
if (!user.name) {
return;
}
await updateUser(user);
}
return <UserForm onSubmit={handleSubmit} />
}
Conclusión
Los principios SOLID no solucionan todos los problemas de diseño del código. No se debería forzar su aplicación, sino que es mejor utilizarlos cuando hay una razón. Si después de aplicarlos el código es más entendible y se facilita su mantenimiento (soporta los cambios), entonces lograron su objetivo. Pero si el código se vuelve demasiado complicado sin una razón, si no aportan ningún beneficio, entonces se está forzando los principios SOLID y no valen la pena.
En la programación siempre hay varias maneras de hacer las cosas bien. Estos principios están basados primeramente en la Programación Orientada a Objetos, pero las ideas se pueden aplicar a la programación funcional y a React, incluso en el Frontend.
Aquí puedes acceder al código que se explica en este artículo: iencotech/react-solid.