#impr3siones – #PR5 Contexto de autenticación

En la #PR4 vimos el contexto de diálogos, que nos permitía lanzar diálogos al usuario desde cualquier componente mediante un hook. Ahora, en la #PR5 vamos a ver otro contexto que nos permitirá acceder a la información del usuario. Además veremos el concepto de SSR (server side rendering) y distintos tipos de «imports».

Contexto de autenticación

Este es el objetivo principal de la PR, un contexto que permita que el usuario inicie y finalice la sesión, y que además guarde el estado del usuario, para poder usarlo en los distintos componentes.

La interfaz del contexto es la siguiente:

export interface AuthContext {
  loginWithGoogle: () => Promise<void>;
  logout: () => Promise<void>;
  authState?: UserData | null;
}

Tenemos definidas nuestras dos funciones, una de login, otra de logout y luego un valor opcional que es el estado de la autenticación. Puede que te estés preguntando el por qué de que sea opcional y pueda ser null. No es más que un pequeño truco (puede que no muy elegante) para saber si hemos comprobado ya si el usuario está logeado.

Cuando usamos la autenticación de Firebase, en el navegador del usuario se queda almacenada la información de la sesión, pero para saber si está autenticado hay que comprobar que esta información siga siendo válida, y esto es un proceso asíncrono. Esto quiere decir que hay un pequeño lapso de tiempo entre que el usuario carga toda la página y que sabemos con certeza si el usuario tiene una sesión o no, por eso lo que hacemos es iniciar el estado a null, y cuando se haya comprobado la sesión será undefined, o tendrá los datos del usuario si ya tenía la sesión.

¿Cómo usamos el contexto?

Al igual que en el contexto de diálogos, usamos un hook para tener acceso desde cualquier componente de la aplicación, de esta manera, cuando uno de los componentes lance un cambio de estado de authState, todos los demás componentes recibirán el nuevo estado.

const { authState, loginWithGoogle, logout } = useAuth();

¿Desde dónde se inicia la sesión?

En el contexto solamente exponemos la función de iniciar y de cerrar sesión, pero este proceso se realiza con el SDK de Firebase el cual nos expone el paquete data-connector, y aquí es donde empieza nuestra aventura con los imports y el SSR.

El paquete data-connector expone la instancia del conector de autenticación directamente en vez de la clase para seguir un patrón singleton y además conseguir que la aplicación no necesite saber nada de la configuración necesaria de Firebase. De manera que podríamos sustituir el conector de Firebase por uno de otro proveedor y no habría que hacer ningún cambio en el frontal (siempre que se respete la misma interfaz).

¿Sería necesario el contexto de autenticación si el paquete expone un singleton? Podríamos usar la instancia directamente desde cada componente, el problema sería que necesitaríamos mantener el estado de autenticación dentro de cada uno de los componentes, usando un contexto tenemos un único estado compartido para todos ellos

El problema con el SSR

Este enfoque lo he usado en el resto de mis proyectos, tanto Manasav como Tarjetadefelicitacion.es lo siguen. Pero esta es el primer proyecto en el que uso Next.js, que provee la funcionalidad de renderizado en el lado de servidor que nos lleva al problema con el SSR. Si importamos el paquete data-connector directamente, se crearán las instancias de los conectores dentro del lado de servidor, y es algo que no debería pasar ya que es código pensado para ejecutarse sólo en el frontal.

Usar SSR no es obligatorio, pero ya que tenemos la funcionalidad vamos a intentar hacer las cosas bien por sí en el futuro queremos aprovecharla.

No vamos a entrar en el detalle de cómo funciona el SSR en Next.js, pero una aproximación sería que en el lado del servidor se ejecuta la aplicación de React, se genera el HTML de los componentes y luego este se envía al navegador. El navegador «pinta» el HTML y luego, cuando ya ha cargado el código Javascript hace un proceso llamado «hidratación» con el que hace que la web ya sea totalmente funcional.

Cuando se realiza el renderizado en el lado de servidor, los componentes no llegan a montarse , por lo que el código que va dentro de los efectos no se ejecuta, esto lo veremos más adelante

Tipos de imports en Typescript

Normalmente cuando queremos importar código desde otro archivo en Typescript usamos algo así:

import Button from './button';

Esto hace que podamos usar lo que exporta el otro archivo dentro del que lo importa, pero tiene un pequeño matiz, y es que el código que hay dentro de ese archivo es ejecutado una vez y luego se cachea para que cuando se vuelva a importar no se vuelva a ejecutar. Esto es un problema porque si importamos directamente el paquete de data-connector el código se va a ejecutar y se crearán las instancias de los conectores. Por suerte existen otros tipos de imports que nos van a ayudar.

Importación dinámica

Esta técnica permite importar código en tiempo de ejecución, de manera que podamos escoger en código cuándo queremos importar el módulo y evitar que se cargue cuando el código se ejecuta en el lado del servidor. Este código sale directamente desde la PR:

const loadAuth = async (): Promise<AuthConnector> => {
  return (await import('@impr3siones/data-connector')).auth;
};

Es muy sencillo, simplemente hacemos un import del paquete que queremos y el import devuelve el módulo dentro de una promesa, puesto que la carga es asíncrona. Este módulo no será empaquetado en el mismo paquete que va el resto de la aplicación, si no que se generará un fichero Javascript nuevo que será descargado por el navegador cuando sea necesario 🤯.

Esta técnica de dividir el código con importaciones dinámicas se conoce como code splitting y permite reducir el tiempo de carga inicial de una aplicación web, así que tenemos un 2×1, evitamos ejecutar el código en la parte del servidor y la carga inicial de la página es mas rápida.

Ahora puede que pienses que, aunque hayamos usado importación dinámica, si el código se ejecuta tanto en cliente como servidor, la importación se seguirá haciendo, y en parte tienes razón pero… ¿te acuerdas de que en el lado del servidor no se montan los componentes?¿sólo se renderizan? Tenemos dos opciones para ejecutar código sólo en el lado del cliente.

Importando las dependencias dentro de efectos

Los efectos en React son hooks que se ejecutan al montar los componentes o cuando algún elemento del array de dependencias cambia. De esta manera, si cargamos nuestra librería dentro de un useEffect, solamente se cargará cuando el código se ejecute en el navegador, puesto que es el único sitio donde se montan los componentes:

  React.useEffect(() => {
    loadAuth().then((auth) => {
      setAuthInstance(auth);
    });
  }, []);

El problema que tenemos ahora es que tenemos que guardar la instancia del conector de autenticación para poder usarla, y tenemos que añadir los listeners correspondientes una vez tengamos cargada la instancia, para esto la guardamos dentro de un estado que lanzará otro efecto que se encargará de poner el listener

 React.useEffect(() => {
    if (authInstance) {
      return authInstance.onAuthDataReceived(setAuthState);
    }
  }, [authInstance]);

¿Porqué dentro de otro efecto? Para poder devolver la función que eliminará el listener de la instancia de autenticación. Tal vez podríamos hacer algo más complicado para usar un único efecto pero creo que de esta manera el código es mas limpio y legible.

Comprobar la existencia de window

Esta es otra técnica para saber si estamos en el servidor o en el cliente. En el lado de servidor no tenemos definido el objeto window propio del navegador, de manera que con una simple comprobación podemos saber si estamos en cliente o en servidor:

if (typeof window !== "undefined") {
  // código en lado de cliente
}

if (typeof window === "undefined") {
  // código en lado de servidor
}

Importación de tipos

Una vez tenemos claro que no podemos importar nuestro paquete de manera normal, ¿qué hacemos con los tipos? Nuestro código está en TypeScript y necesitamos importar los tipos/interfaces pero estos desaparecen en ejecución así que tampoco tiene sentido importarlos dinámicamente.

Para eso tenemos las importaciones de tipos, que nos permiten importar los tipos e interfaces pero que, a la hora de la transpilación son eliminados completamente del código.

import type { AuthConnector, UserData } from '@impr3siones/data-connector';

Aunque se pueden importar tipos con los import normales, creo que es una buena práctica hacerlo de esta manera, tanto por rendimiento como para que alguien que venga luego a trabajar con nuestro código pueda ver rápidamente que lo que se importa son solo tipos.


Al final lo que parecía un simple contexto de autenticación nos ha llevado a tener que profundizar en el tema de SSR y de los imports dinámicos, code splitting e import de tipos. Si algo no ha quedado suficientemente claro, hay dudas, he metido la pata con algo o tienes alguna idea mejor de cómo hacer algo ¡Escribe un comentario! Estaré encantado de leerlo

Deja una respuesta

Tu dirección de correo electrónico no será publicada.