Dependency Inversion Principle (DIP)

Dependency Inversion Principle (DIP)

¿Qué es el Dependency Inversion Principle? ¿Cómo se aplica en React?

Vamos con último de los principio SOLID que no es otro que el Principio de Inversión de Dependencias (Dependency Inversion Principle, DIP) que se corresponde con la D de SOLID.

Este principio nos viene a decir que nuestros componentes, nuestras clases, nuestras funciones deberían depender de abstracciones y no de implementaciones concretas. Una vez más esta definición nos puede sonar muy genérica por lo que como vamos a partir de un ejemplo:

import useSWR from 'swr'

const fetcher = async (url: string) => {
  const res = await fetch(url)
  return res.json()
}

export const Todo = () => {
  const { data } = useSWR('http://jsonplaceholder.typicode.com/todos', fetcher)

  if (!data) {
    return <p>loading...</p>
  }

  return (
    <ul>
      {data.map(todo: any) => {
        return (
          <li key={todo.id}>
            <span>{todo.id}</span>
            <span>{todo.title}</span>
          </li>
        )
      }}
    </ul>
  )
}

El código del componente anterior estaría bien desde el punto de vista funcional porque efectivamente está haciendo lo que se espera que haga que no es otra cosa que traerse una lista de todo (tareas que se tienen que realizar) y renderizarlas en la página.

Ahora bien, si nos fijamos bien veremos que el componente en cuestión tiene una dependicia con useSWR que no es más que un custom hook que implementa de una forma concreta la conectividad con una API para traerse información el cual necesita de un fetcher que nosotros le tenemos que pasar y del cuál conocemos el código. Pero es más, es el componente el que tiene que saber la URL a la que tiene que acudir para traerse dicha información cosa que no parece del todo que esté bien.

¿Qué podemos hacer para arreglar esto? Pues deberíamos crear una abstracción que no dependiense para nada de saber la url de la API a la que se tiene que conctar para poder traerse la información de los todo ni tampoco de es necesario el fetcher para el correcto funcionamiento de dicha comunicación (aunque en última instancia el saber que vamos a necesitar un fetcher para podernos traer los datos es algo que sí que vamos a necesitar como veremos un poco más adelante).

¿Y cómo podemos extraer toda (abstraer) toda esta información del componente Todo? Pues gracias al uso de un custom hook.

Custom hook

Teniendo en cuenta lo que acabamos de mencionar lo que vamos a hacer es crear el fichero useData.ts en el que vamos a crear nuestro custom hook que nos ayude a cumplir con el DIP. Dentro de este hook lo primero que haremos será importar la librería swr porque es la que vamos a usar para realizar la comunicación con la API:

import swr from 'swr'

Ahora vamos a definir la interfaz que nos servirá para definir los parámetros que le pasaremos a nuestro hook y para ello es necesario que conozcamos muy a alto nivel como funciona swr puesto que es quién va a definir qué es lo que necesitamos. Así necesitaremos una key para establecer la clave dentro de la caché de resultados de la consulta a la API que gestiona swr donde se guardarán los resultados de nuestra consulta a la API (generalmente esta key será el segmento de la URL de la API a la que tenemos que lanzar nuestra consulta) y un fetcher que no es más que un callback que se ejecutará para obtener la información que se ha de guardar dentro de esa clave de la cache.

Nota: para todos aquellos que estén más interesados en saber cómo funciona swr le recomendamos que se vayan a la página oficial del hook

Es más, como no tenemos ni idea del tipo de datos que se guardará en cada una de las claves de la caché que se puedan ir generando lo que haremos será hacer uso de un genérico de TypeScript en la definición para así que poder tipar mejor nuestros resultados.

En definitiva que definimos el siguiente tipo:

interface UseDataArgs<T> {
  key: string
  fetcher: () => Promise<T>
}

¿Qué estamos definiendo con esto? Pues si lo pensamos detenidamente gracias a esta interface lo que estamos declarando es la abstracción que servirá para que le podamos comunicar a nuestro custom hook useData cuáles son los tipos de datos con los que va a tener que trabajar tratando de cumplir el DIP.

Es más llevando esta idea a la respuesta que nos proporcionará nuestro hook vamos a definir la interfaz en la que se declarará el tipo de datos con el que nos responderá la ejecución de nuesto hook que será la información que se ha obtenido, el posible error que se haya producido y toda aquella información adicional que nosotros consideremos necesaria para poder llevar a cabo nuestro proceso. Por ejemplo, nosotros vamos a hacer que además de los datos y el error esperamos recibir un boolean donde se nos diga si nuestros datos se están validados o no.

interface Response<T> {
  data: T | undefined
  error: string | undefined
  isValidating: boolean
}

¿Qué logramos con todo esto? Pues en vez de que nuestro componente Todo tenga que saber que tiene que proporcionar unos datos concretos para una implementación concreta del código que se encargará de traer la información con la que se está trabajando lo que estamos haciendo es separarlo para poderlo cambiar en el caso de que sea necesario y así que Todo pueda hacer uso de una implementación totalmente diferente del proceso/librería para traerse la información de la API.

Nota: no nos quedemos únicamente con el hecho de que podemos cambiar la implementación de la comunicación con la API puesto que si el DIP está bien implementado al componente Todo le tiene que dar igual dónde está la información de los todo que tiene que implenentar (podría estar, por ejemplo, en el localstorage en vez de la API) sino que simplemente lo que hará será pedir los todo que tiene que renderizar y mostrarlos.

Ahora ya podemos comenzar con la implementación de useData que definimos de la siguiente manera:

export const useData = <T>({ key, fetcher }: UseDataArgs<T>): Response<T> => {}

y tras la definición del mismo vamos a podernos centrar en la implementación propia del código que se ejecutará dentro de nuestro custom hook usando swr:

export const useData = <T>({ key, fetcher }: UseDataArgs<T>): Response<T> => {
  const { data, error, isValidating } = useSWR<T, string>(key, fetcher)
  return { data, error, isValidating }
}

El código completo del custom hook useData después de todos los pasos que acabamos de mencionar sería algo parecido a lo que podemos ver a continuación:

import swr from 'swr'

interface UseDataArgs<T> {
  key: string
  fetcher: () => Promise<T>
}

interface Response<T> {
  data: T | undefined
  error: string | undefined
  isValidating: boolean
}

export const useData = <T>({ key, fetcher }: UseDataArgs<T>): Response<T> => {
  const { data, error, isValidating } = useSWR<T, string>(key, fetcher)
  return { data, error, isValidating }
}

Así pues hemos creado una abstacción porque aunque estamos usando swr para comunicarnos con la API de tal manera que los componentes que necesiten los todo (como es el caso del componente Todo del que hemos partido) van a poder utilizarlo sin que en ningún momento tengan la necesidad de saber cómo se va a realizar el fetch de los datos.

Volviendo por lo tanto al código de nuestro componente Todo lo que vamos a hacer es definir un nuevo tipo de datos que nos sirva para poder definir cómo son los datos que esperamos obtener en la respuesta de la API (es decir cuál es el tipo que está asociado a cada uno de los todo):

type ResponseType {
  id: number
  title: string
}

Nota: lo seguimos dejando lo más sencillo posible haciendo que para el componente Todo la respuesta que se espera recibir de la API sean objetos formados por el atributo id con el identificador del todo y title con el títulod el todo.

Con estos cambios vamos a ver cómo queda el código completo de nuestro componente para que pase a usar el hook useData:

import { useData } from './useData.ts'

type ResponseType {
  id: number
  title: string
}

const fetcherFromApi = async (): Promise<ResponseType[]> => {
  const url = 'http://jsonplaceholder.typicode.com/todos'
  const res = await fetch(url)
  return res.json()
}

export const Todo = () => {
  const { data } = useData<ResponseType[]>({
    key: '/todos',
    fetcher: fetcherFromApi
  })

  if (!data) {
    return <p>loading...</p>
  }

  return (
    <ul>
      {data.map(todo: any) => {
        return (
          <li key={todo.id}>
            <span>{todo.id}</span>
            <span>{todo.title}</span>
          </li>
        )
      }}
    </ul>
  )
}

En el código anterior tenemos que fijarnos que ahora ya no tenemos la función fetcher que teníamos originalmente donde se esperaba recibir como parámetro la url de la API a la que teníamos que conectarnos para poder obtener la respuesta y en su lugar hemos definido la función fetcherFromApi y es esta función la que tiene como responsabilidad saber la API a la que se tiene que conectar para poder obtener la información de los todo.

¿Dónde está la clave de todo esto?

Pues nos tenemos que fijar en la definición de la interfaz UseDataArgs<T> porque es aquí donde le estamos inyectando a nuestro custom hook cómo vamos a realizar el fetching de los datos sin saber de dónde lo va a sacar (en nuestro ejemplo de una API) siendo lo importante de verdad desde el punto de vista de la abstracción que la respuesta que retorne este fetcher ha de ser una promisa del tipo genérico T.

En nuestro ejemplo la T que le estamos pasando a useData es un array de objetos ResponseType por lo que al final lo que tendremos en el atributo data del objeto con el que response el custom hook lo que tendríamos sería un array de objetos que están formados por un id y un title (la definición recogida en el tipo ResponseType).

Nota: cada uno de los fetcher que vayamos creando se considera una buena práctica separarlo en su propio archivo afuera del componente donde se está utilizando para que pueda llegar a ser reutilizado en más sitios siempre que se diese ese caso.

Otro fetcher

Veamos ahora cómo podemos crear otro fetcher y para ello lo que tenemos que aseguirar es qeue sea una función que cumpla con la interfaz (es decir, en nuestro caso que retorne un array de objetos ResponseType) en donde puede sacar la información de un objeto:

const fetchFromObject = async (): Promise<ResponseType[]> => {
  return [
    { id: 1, title: 'Hello' },
    { id: 2, title: 'World' }
  ]
}

y ahora simplemente en el código del componente cuando llamamos al custom hook useData escribiríamos algo como:

const { data } = useData<ResponseType[]>({
  key: '/todos',
  fetcher: fetchFromObject
})

O bien podríamos usar un fetcher que obtenga la información de los todo del localstorage como sigue:

const fetchFromLocalStorage = async (): Promise<ResponseType[]> => {
  const todos = localStorage.getItems('todos')
  return todos ? JSON.parse(todos) : []
}

y nuevamente usarlo como sigue en el código del componente Todo:

const { data } = useData<ResponseType[]>({
  key: '/todos',
  fetcher: fetchFromLocalStorage
})

Lo realmente interesante aquí es independientemente de cual sea el fetcher que se vaya a utilizar a la hora de llamar a useData en ningún momento vamos a tener que modificar ninguna otra parte del código y todo funcionaría correctamente.

Importante: cuando estamos definiendo los diferentes tipos de fetcher en nuestro código los pasamos a usar con useData lo que estamos haciendo es inyectar la dependencia de este fetcher de forma que todo nuestro código es independiente del lugar del que nos extraemos los datos.

Es más podríamos ir una paso más adelante y hacer que el componente Todo no necesite ni tan siguiera saber nada del fetcher esperando recibirlo como una prop:

export const Todo = ({ fetcher }) => {
  const { data } = useData<ResponseType[]>({
    key: '/todos',
    fetcher
  })
  // ..... resto del código .....

o incluso que este fetcher saliense del context de React haciendo algo como lo siguiente:

export const Todo = ({ fetcher }) => {
  const { fetchTodoFromApi: fetcher } = useContext(ContextTodo)

  const { data } = useData<ResponseType[]>({
    key: '/todos',
    fetcher
  })
  // ..... resto del código .....

Nota: obviamente el código anterior está muy simplicado porque lo que es importante aquí es que seamos capaces de entender la potencia de este principio no centrarnos en cómo funciona el context de React.

Es más, podemos ir un paso más allá y definir algo así como un context de React general para toda nuestra aplicación (todos los componentes que la forman tendrían acceso al mismo) el cual podríamos recuperar con un custom hook al que podríamos llamar useGlobalContext ahora bien, dentro de este contexto global podemos tener el atributo fetchTodos donde le asignáriamos el fetcher global a toda la aplicación de tal manera que en el caso de tener que cambiarlo solamente lo tuviésemos que hacer en un único sitio, facilitándonos mucho el mantenimiento de nuestro código:

export const Todo = ({ fetcher }) => {
  const { fetchTodos: fetcher } = useGlobalContext()

  const { data } = useData<ResponseType[]>({
    key: '/todos',
    fetcher
  })
  // ..... resto del código .....

Nota: de esta manera nosotros como desarrolladores de los componentes de la aplicación cuando estamos haciendo uso del fetcher no nos estamos enterando de dónde se obtiene la información (no sabemos si se va a una API, si se va al localstorage, si sale de las cookies, si es un valor mockeado, etc.) siendo esto lo que hace que la inyección de dependencias sea tan sumamente potente.