'React custom hooks: diseño y testing'

20-04-2020

Por Adrián Ferrera González

React es una de las librerías utilizadas para desarrollar aplicaciones front-end más conocidas a día de hoy. Esta librería se caracteriza por ser una de las más estables a lo largo de los últimos años y al mismo tiempo, de ofrecer * nuevas características* sin dar por obsoletas las ya existentes anteriormente.

No es hasta la versión 16.8.0 que fueron introducidos los hooks de forma oficial.

¿Qué son los hooks?

Los hooks no son más que una función utilizada por un componente, cuya responsabilidad se quiere extraer del componente. Solo los Functional component pueden usarlas y como regla no escrita, todas ellas deben empezar por el sufijo use.

Por ejemplo, la librería ya nos ofrece un conjunto de hooks, como:

  • useState
  • useEffect
  • useContext

Puedes encontrar tanto que hace cada uno de ellos, como otros hooks que pueden ser de utilidad en la documentación oficial.

¿Cómo se usan?

Lo importante de los hooks es que, además de existir un conjunto de ellos ya definidos por la librería, nosotros podemos crear uno propio. El caso más típico suele ser el de hacer una petición asíncrona a una api:

export const MyComponent = ({ repository }) => {
// Define multiple internal states
const [loading, setLoading] = React.useState(false);
const [profile, setProfile] = React.useState({});
const [error, setError] = React.useState(null);

// Define the code to execute component will mount
React.useEffect(() => {
setLoading(true);
repository.getProfile()
.then(setProfile)
.catch(setError)
.finally(() => setLoading(false));
},[repository]);

return (
<div className="myComponent">
{loading ? <Spinner /> : <Profile value={profile}/>}
{error ? <ErrorMessage>{error.message}</ErrorMessage>}
</div>
)
};

Analicemos el siguiente código:

  • En primer lugar, definimos e inicializamos el estado/información de nuestro componente. Si está cargando o no, si ha habido un error y la información del perfil de usuario haciendo uso de useState
  • En segundo lugar, definimos un useEffect que se lanzará cada vez que nuestro repository, el cual recibimos a través de props, cambie (esto incluye el montaje del componente). Este hook recuperará el perfil del usuario y modificará los múltiples estados en función de la ejecución.
  • Por último, visualizamos el componente, basándonos en la información que tenemos en el estado.

Ahora bien, este código puede coincidir de forma directa con el de otros componentes que también consuman el perfil de usuario. Por lo que no tiene sentido que tengamos este código repetido en todos ellos y lo conveniente sería llevarlo a una función común.

const useProfile = (repository) => {
// Define multiple internal states
const [loading, setLoading] = React.useState(false);
const [profile, setProfile] = React.useState({});
const [error, setError] = React.useState(null);

    // Define the code to execute component will mount
    const getProfile = () => {
        setLoading(true);
        repository.getProfile()
            .then(setProfile)
            .catch(setError)
            .finally(() => setLoading(false));
    };
    return {loading, profile, error, getProfile }

};

Esta función recibe por parámetro el repository y devuelve un objeto con una funcion que hace uso de un estado ( useState). Dicho estado, será el del componente que consuma esta función. El objeto devuelto expone también los valores del estado, por lo que además le hemos ocultado los setters a los componentes que consumen dicha función.

Para entender esto, tenemos que entender el ciclo de vida de los Function Components. Cada vez que cambie el estado (en este caso los valores devueltos por el hook) o que las props que el componente recibe cambien: el componente será renderizado nuevamente. Este estado se gestiona dentro del hook y es único por componente. Es decir, no es un singleton, sino que es simplemente una función helper que reutilizamos. Traducción: El componente no almacena ningún valor, solo lo recibe del hook y lo pinta. Si hay un cambio en alguna de sus props (en este caso repository), el componente se pinta de nuevo. No es el caso, pero si tuviésemos alguna acción dentro del componente que cambiase el valor de repository, este ejecutaría el useEffect nuevamente.

Si quisiésemos compartir el mismo estado entre distintos componentes, tendríamos que usar dicho hook en un componente que esté por arriba y pasarlo a través de las props a los hijos (Higher Order Component).

O si quisiéramos ir un paso más allá: crearíamos un contexto que envolviese a nuestros componentes y recuperaríamos el valor de dicho contexto, a través de useContext(MyContext), independientemente de la jerarquía (siempre y cuando dicho componente o sus ancestros estén envueltos en dicho contexto).

Nuestro componente usaría así la función/customHook:


export const MyComponent = ({ repository }) => {
const {loading, profile, error, getProfile} = useProfile(repository);
React.useEffect(() => { getProfile() }, [repository]);
return (
<div className="myComponent">
{loading ? <Spinner /> : <Profile value={profile}/>}
{error ? <ErrorMessage>{error.message}</ErrorMessage>}
</div>
)
};

Testing

Ahora bien, ¿es necesario testear nuestros hooks? La respuesta es depende. Si podemos probar nuestra funcionalidad de forma plena directamente desde el test del propio componente, no sería necesario.

Sin embargo, no siempre es así. O no siempre tenemos por qué desarrollar el hook a partir de un componente, por lo que en estos casos, si puede resultar interesante el desarrollar unos test para el mismo.

Ahora bien, aunque un hook sea una función, dentro va a utilizar determinadas funcionalidades definidas por *react ** que no podemos emular de forma simple. Es por ello que @testing-library/react tiene un complemento llamado * @testing-library/react-hooks. Con esta herramienta, seremos capaces de llamar al comportamiento y verificar su estado.

Para ello, primero debemos instalar las dependencias:


npm i --save-dev jest @testing-library/react-hooks

Y definiremos el test de la forma más simple posible. En este caso, llamaremos a dicha función pasándole un mock del repository que simule el comportamiento real del fetch y comprobaremos que los valores retornados han cambiado en base a dicha respuesta:

import { act, renderHook } from '@testing-library/react-hooks';
import { useProfile } from './MyComponent';
import { buildProfile } from './builders'

describe('useProfile', () => {
  it('retrieves the profile', async () => {
    // Define data
    const expectedProfile = buildProfile();
    const repository = {
      getProfile: jest.fn(() => Promise.resolve(expectedProfile)),
    };
    // Mount hook
    const {
      loading, profile, error, getProfile,
    } = renderHook(() => useProfile(repository));
    // Execute the action
    await act(async () => {
      await getProfile();
    });
    // Check the values
    expect(loading).toBeFalsy();
    expect(profile).toEqual(expectedProfile);
    expect(error).toBeNull();
  });
});

Lo que ocurre en el ejemplo anterior:

  • En primer lugar, definimos el profile que queremos que devuelva y falseamos el repository
  • A continuación, montamos el hook valiéndonos de renderHook
  • Ejecutamos el método. Para ello, es obligatorio que la acción se envuelva en la función act de la librería, para que pueda tener contexto de la mutación.
  • Verificamos que el estado final de los estados es el que esperábamos.

De la misma forma, podríamos probar también casos de error o llevarnos esta misma mecánica a casos más complicados, como podría ser un hook que envuelve la lógica para gestionar el estado de una aplicación redux, a través de * redux-thunk*.

Espero que este ejemplo os sirva de ayuda en el futuro, no solo para perderle el miedo a los hooks, sino también para poder crear tests de forma sencilla y dar mayor robustez a vuestro código.