Interfaz Segregation Principle (ISP)

Interfaz Segregation Principle (ISP)

¿Qué es el Interfaz Segregation Principle? ¿Cómo se aplica en React?

Si tratamos de resumir muy brevemente qué es lo que se pretender lograr el principio Interfaz Segregation Principle (ISP) (la I dentro de los principios SOLID) es que los clientes no deberían tener que poder acceder a interfaces que no necesitan. Si esto lo llevamos a un ejemplo con un componente de React podemos pensar en las props que están asociadas con el componente como si se tratase de su interfaz y es posible que dentro de todas estas props se estén exponiendo alguna que realmente no necesite y por lo tanto se esté violando el ISP.

Por poner un ejemplo, supongamos la siguiente declaración de un componente y el tipo que describe las props que puede recibir:

type TitleProps = {
  buttonText?: string
  href?: string
  onClick?: () => void
  title: string
  type: 'default' | 'withLabelButton' | 'withNormalButton'
}

export const Title: React.FC<TitleProps> = ({
  buttonText,
  href,
  onClick,
  title,
  type
}) => {
  // En función del valor que se reciba en la prop 'type' se llevará a cabo
  // una acción u otra.
}

Este es un ejemplo claro de cómo se está violando el ISP puesto que el conjunto de props que están recogidas en el TitleProps está claro que están vinculadas con el tipo del Title que se quiere mostrar (dicho de otra manera, se han definido props que únicamente se utilizarán cuando type adopta el valor withLabelButton y otras que solamente tendrán sentido en el caso de que type tenga el valor withNormalButton).

Pero el no seguir el ISP no solamente pasa en el caso de que estemos trabajanco con componentes que reciben demasiadas props sino que esto también puede pasar si cuando lo que está viajando como prop es un objeto. Esto lo podemos ver en el siguiente ejemplo:

type Video = {
  coverUrl: string
  duration: number
  title: string
}

type Props = {
  items: Array<Video>
}

const VideoList = ({ items }: Props) => {
  return (
    <ul>
      {items.map(item => (
        <Thumbnail key={item.title} video={item} />
      ))}
    </ul>
  )
}

En el ejemplo anterior tenemos un tipo Video y a partir de su declaración estamos costruyendo las props del componente VideoList donde lo que hacemos es definir una única prop (a la que vamos a denominar items) que será una array de objetos del tipo Video.

Pero ahora fijémonos un poco más en el código que acabamos de construir y más concretamente en el código del componente Thumbnail que tiene la prop video donde se le está pasando todo el objeto que contiene la información de todo el video del que solamente querríamos mostrar la miniatura que tiene asociado. ¿Realmente el componente Thumbnail necesita la información del video completo? Aunque podemos sospechar que no lo necesitará para poder estar seguros al 100% de que esto es así tendremos que mirar su código:

type Props = {
  video: Video
}

const Thumbnail = ({ video }: Props) => {
  return <img src={video.coverUrl} />
}

Donde nuestra sospechas se hacen realidad puesto que pese a que dentro de Thumbnail solamente se está haciendo uso de su atributo coverUrl al final se le está pasando todo el objeto Video siendo esto precisamente lo que habla el principio ISP puesto que el cliente que en este caso será el componente Thumbnail no necesita conocer toda la información de un video y solamente debería recibir lo que necesita.

¿Y esto por qué es así? Pues pensemos que si no acotamos lo que el componente espera recibir lo que estaremos haciendo es que todos aquellos otros componentes que lo quieran usar van a tener que cumplir un contrato (interfaz o props que se le pasarán) muy grande cuando únicamente con saber el coverUrl del video que se quiere mostrar sería más que suficiente.

Pensemos ahora en el testing. Si todos nuestros componentes fuesen desarrollados siguiendo este principio serían mucho más fáciles de testear porque, pensemos en el caso del componete Thumbnail siempre será mucho más sencillo simular que le estamos pasando un string como el valor de su prop coverUrl que el tener que pasarle un objeto Video porque, pese a que en el ejemplo pueden parecer muy pocas, pero pueden ser muchísimas más.

Además es muy fácil extender la funcionalidad de nuestros componentes siempre y cuando sigan este principio. Vamos a verlo nuevamente con un ejemplo:

type Props = {
  items: Array<Video | LiveStream>
}

const VideoList = ({ items }) => {
  return (
    <ul>
      { items.map(item => {
        if ('coverUlr' in item) {
          // Estamos frente a un video.
          return <Thumbnail coverUrl={item.coverUrl} />
        } else {
          //  Se trata de un live stream.
          return <Thumbnail coverUrl={item.previewUrl}>
        }
      })}
    </ul>
  )
}

Como Thumbnail está esperando recibir en su prop coverUrl un string este valor podrá venir de un atributo de objeto Video (en concreto el coverUrl) o LiveStream (en cuyo caso la prop se extraerá del atributo previewUrl) lo que supone que estemos minimizando las dependencias entre componentes además de simplificar el contrato que tienen nuestros componentes.

Otro ejemplo

Vamos centrarnos en otro ejemplo en el que se puede ver claramente que no se está cumpliendo el principio ISP:

const Post = ({ post }: { post: PostType }) => {
  return (
    <div>
      <PostTitle post={post} />
      <span>author: {post.author.name}</span>
      <PostDate post={post}>
    </div>
  )
}

Aquí vemos que no se cumple el ISP porque el objeto PostType que se está recibiendo como prop por parte del componente Post está pasando todo el rato hacia abajo en la jerarquía de componentes puesto que se le pasa también a los componentes PostTitle y PostDate y esto no tiene mucho sentido porque en principio no parece que ninguno de estos componentes tenga que tener acceso a toda la información de un post para llevar a cabo su cometido. Por ejemplo:

const PostTitle = ({ post }: { post: PostType }) => {
  return <h1>{post.title</h1>
}

Vemos como el PostTitle solamente precisa de la información que está contenida dentro del atributo title del objeto PostType que se está recibiendo como parámetro por lo que una mejor definición del mismo (que cumpla además con el ISP) sería la siguiente:

const PostTitle = ({ title }: { title: string }) => {
  return <h1>{title}</h1>
}

Y este mismo refactor podríamos llevarlo a cabo en el código de componente PostDate donde partiríamos del un código como el siguiente:

const PostDate = ({ post }: { post: PostType }) => {
  return <time>{post.createAt.toString()}</time>
}

donde para hacer el refactor podemos pensar en dos opciones o bien pasarle el valor del atributo createAt del objeto PostType o bien pasarle el string que queremos que sea renderizado. En nuestro caso vamos a optar por la primera opción lo que nos dejaría con un código como el siguiente:

const PostDate = ({ createAt }: { createdAt: Date }) => {
  return <time>{createAt.toString()}</time>
}

Con este refacto ahora en el código de partida del objeto Post en lugar de hacer que viaje constantemente el objeto PostType en las props que se le asignan a sus objetos hijos lo que hacemos será pasar las props que necesitan realmente para lograr su cometido. Esto nos deja un código como el siguiente:

const Post = ({ post }: { post: PostType }) => {
  return (
    <div>
      <PostTitle title={post.title} />
      <span>author: {post.author.name}</span>
      <PostDate createAt={post.createdAt}>
    </div>
  )
}

Notas finales

Esto muchas veces a mucha gente lo que no le gusta es el tener que hace uso de la definición de las props que los objetos utilizan y lo que prefieren es hacer una asignación gracias al stread operator. Es decir, que en vez de hacer algo como:

<PostTitle title={post.title} />

Lo que prefieren es hacer algo como:

<PostTitle {...post} />

Pero ¿cuál es el problema que puede aparecer a veces con esto? Lo primero que tenemos que entender es que no tiene que considerarse una mala práctica en general porque hay ocasiones en las que estará bien pero hay otras muchas en las que estará mal.

¿Por qué puede estar bien? Pues porque pensemos que el contrato que se define en PostTitle para recoger sus props solamente se espera un objeto que tenga el atributo title y el objeto PostType lo tiene así que no estaremos rompiendo nada pero por otra parte puede estar mal puesto que estamos pasando al componente PostTitle información que no necesita pese a que la interfaz nos define que solamente necesitamos el string del title.

¿Pero qué pasa cuando hacemos {...post}? Pues internamente lo que sucede es que se está haciendo una copia de un objeto que puede ser enorme que en la mayoría de las ocasiones puede que hasta sea innecesaria porque, si suponemos que el PostType tienen además atributos que recogen arrays de cosas, objetos anidados, etc. lo que sucederá es que estaremos creando una nueva copia en cada render del componente.

Nota: si que es cierto que esta nueva copia es un shallow (o lo que viene a ser una copia de los valores de las propiedades del objeto original, pero no de los objetos a los que esas propiedades hacen referencia) pero aunque así sea en última instancia estaremos creando un nuevo objeto en cada render.

Por otra parte tenemos que caer en la cuenta de que es posible que antes esta técnica de utilización del spread operator para pasar las props nos de la falta sensación de que estamos pasando un objeto inmutable cuando, como acabamos de mencionar, se trata de una shallow copy (copia superficial) por lo que si en el componente hijo se modifica un atributo de alguno de los objetos que están asociado a los atributos del PostType se verán reflejados en el objeto original.

Esto se ve muy bien, aunque no sea en código React, en el siguiente ejemplo donde se puede observar cómo funciona el shallow copy:

// Objeto original
const objetoOriginal = {
  a: 1,
  b: { c: 2 }
}

// Crear una shallow copy del objeto original
const shallowCopy = { ...objetoOriginal }

// Modificar una propiedad del objeto anidado en la copia superficial
shallowCopy.b.c = 3

// Ver los cambios en la copia superficial y el objeto original
console.log('Copia Superficial:', shallowCopy) // { a: 1, b: { c: 3 } }
console.log('Objeto Original:', objetoOriginal) // { a: 1, b: { c: 3 } }

Esto no quiere decir que el uso del spread operator sea una mala decisión para pasar las props pero siempre deberemos tener en cuenta las consecuencias que acabamos de describir porque si todo esto se acaba haciendo de forma incontrolada acabará siendo, como se puede esperar, una fuente importante de bugs en nuestro código.

A nuestro juicio, en el ejemplo que hemos desarrollado, parece que tiene mucho más sencillo extraer las dos props que se necesitan del objeto PostType y pasársela a los componentes hijos PostTitle y PostDate:

const Post = ({ post }: { post: PostType }) => {
  return (
    <div>
      <PostTitle title={post.title} />
      <span>author: {post.author.name}</span>
      <PostDate createAt={post.createdAt}>
    </div>
  )
}

¿Qué sucedería en el caso de que el PostTitle precisase, por la razón que fuese de 10 atributos del objeto PostType? Pues acabaríamos decándonos por hacer uso del spread operator para que nuestro código fuese mucho más claro. Con esto al final, de lo que se trata es que entendamos que cualquiera de las técnicas de desarrollo que vayamos aplicando tendrán su propio caso de uso y es bueno que las conozcamos siempre.

Nota: recordemos que el contexto en el que se esté desarrollando nuestro software es muy importante y que todos los principio SOLID en función de lo que estemos construyendo pueden tener más o menos sentido.

Resumen

Con el ISP lo que estaremos tratando de lograr es intentar enviar a nuestros componentes en sus props información que estos no necesiten. Así como el componente PostDate solamente precisaba de la información de la fecha de creación de post (fecha recogida en el atributo createdAt) solamente le pasaremos esa información y por lo tanto evitaremos pasarle todo el objeto PostType.

¿Qué pasa con el contexto de React?

Sabemos que los componentes de nuestras aplicaciones pueden hacer uso del Contexto de React para poder recibir inforamción que les provoque que se re-renderice y, en el caso que estamos desarrollando podríamos pensar en pasar este PostItem dentro del contexto y esto podría no estar mal.

Ahora bien, pensemos en el componente PostDate ¿no es mucho más sencillo probarlo haciendo que reciba una prop de tipo Date que representa a la fecha de creación del post que si tenemos que repurar el PostType del contexto y luego renderizar su fecha de creación? Intuitivamente se puede ver que el uso del contexto complica bastante la implementación y parece que estaríamos ante un caso de sobreingeniería.