30%dto
Oferta Cyber Monday

Cómo crear mejores componentes con TypeScript y React Hooks (II)

Publicada en WordPress.

En la primera parte de este tutorial vimos qué eran los tipos de unión de TypeScript y cómo discernir los unos de los otros usando predicados de tipo. Si no leíste esa entrada, te recomiendo que lo hagas ahora, pues sienta las bases necesarias para entender lo que te voy a contar hoy.

En esta entrada, veremos cómo «hacer imposibles los estados imposibles» usando tipos de unión y cómo un mejor modelo de datos nos lleva a escribir un código más robusto y fiable. Además, también veremos, con el ejemplo real de Nelio Popups, cómo TypeScript nos ayuda a generar componentes React sin fallos y mejor documentados.

Cómo hacer imposibles los estados imposibles

Empecemos con un ejemplo sencillo. Imagina que quieres crear un componente PostList que muestre una lista de entradas. Cuando el componente se muestra por primera vez, hace una petición al servidor para cargar la lista de entradas (supongamos que tienes un hook llamado usePosts para ello). Mientras está cargando los datos, debe mostrar una animación y cuando ya tiene las entradas, mostrarlas. Esto es algo que podríamos implementar de la siguiente forma:

export const PostList = ( { postIds } ) => {
  const { isLoading, posts } = usePosts( postIds );
  if ( isLoading ) return <Spinner />;
  if ( ! posts.length ) {
    return <p>No posts</p>
  }//end if
  return (
    <div>
      { posts.map( ( post ) => ... ) }
    <div>
  );
}

Viendo el fragmento de código anterior, podemos deducir que el tipo de retorno del hook usePosts será algo así:

type PostResult = {
  readonly isLoading: boolean;
  readonly posts?: ReadonlyArray< Post >;
}

Es decir, un objeto con un atributo booleano isLoading y una lista (opcional) de entradas posts. Supongo que la lista de entradas es opcional porque, bueno, hemos dicho que mientras las cargamos no tenemos nada, ¿no?

Pues bien, con una definición de esto tipo, tenemos que los siguientes resultados son posibles:

// Result 1
{ isLoading: true }
// Result 2
{ isLoading: true, posts: [] }
// Result 3
{ isLoading: true, posts: [ {…}, {…}, … ] }
// Result 4
{ isLoading: false }
// Result 5
{ isLoading: false, posts: [] }
// Result 6
{ isLoading: false, posts: [ {…}, {…}, … ] }

Como puedes ver en el Playground de TypeScript, todos los resultados anteriores son instancias válidas del tipo PostResult, pero en realidad hay resultados que no tienen ningún sentido. En concreto, los resultados 2 y 3, por un lado, y el resultado 4, por otro, parece estar mal, pues está incompleto.

Si nos fijamos en los resultados 2 y 3, vemos que las entradas aún se están cargando (eso es lo que indica el atributo isLoading) y, sin embargo, al mismo tiempo nos consta que ya tenemos cargada la lista de entradas… O al revés: en el resultado 4, se supone que ha hemos cargado los resultados, pero resulta que no tenemos el atributo posts disponible. ¡Un claro error!

Lo que está pasando aquí es un claro ejemplo de un mal modelo de datos. El tipo PostResult nos permite representar estados imposibles. Debería ser imposible estar cargando resultados y a la vez tener resultados. También debería ser imposible tener los resultados cargados y que el atributo posts no esté definido. Pero ambas situaciones son posibles en nuestro modelo de datos.

Nuestro objetivo como buenos programadores debe ser «hacer imposibles los estados imposibles». Y en un ejemplo como este, podemos conseguirlo usando el siguiente tipo de unión (que, por cierto, es discriminado por el atributo isLoading):

type PostResult = LoadingPostResult | LoadedPostResult;
type LoadingPostResult = {
  readonly isLoading: true;
};
type LoadedPostResult = {
  readonly isLoading: false;
  readonly posts: ReadonlyArray< Post >;
}

Si implementamos esta nueva definición de PostResult en el Playground de TypeScript, veremos cómo, efectivamente, los resultados 2, 3 y 4 pasan a ser inválidos. Ahora sí tenemos una definición que captura a la perfección la realidad, y podemos desarrollar con la tranquilidad de que TypeScript nos garantizará que los datos estarán disponibles si, y solo si, se han cargado del servidor.

Si quieres saber más sobre el tema, te recomiendo esta charla de Richard Feldman. Aunque habla de otro lenguaje de programación (Elm), comparte ejemplos y consejos que pueden ser muy interesantes también:

Definiendo (parte de) el modelo de datos de Nelio Popups

Al empezar la entrada te prometí que hoy usaríamos un ejemplo real, así que ya va siendo hora de ver dicho ejemplo, ¿no? Si echas un vistazo al código fuente de Nelio Popups en WordPress.org, verás que hay una carpeta en src/common llamada types donde, a través de diferentes ficheros, se definen los tipos que caracterizan a un popup creado con nuestro plugin. Si cogemos uno de esos ficheros al azar (por ejemplo, src/common/types/popups/style.ts) y miramos su contenido, veremos que está lleno de tipos como el que hemos visto en el ejemplo anterior:

// …
export type OverlaySettings =
  | {
      readonly isEnabled: false;
    }
  | {
      readonly isEnabled: true;
      readonly color: Color;
    };
// …
export type BorderSettings =
  | {
      readonly isEnabled: false;
    }
  | {
      readonly isEnabled: true;
      readonly radius: CssSizeUnit;
      readonly color: Color;
      readonly width: CssSizeUnit;
    };

en los que tenemos flags que nos indican si una cierta propiedad del popup está activa o no y únicamente cuando lo está aparecen los atributos adicionales con los que configurarla.

Componentes React

Como ves, los tipos de Nelio Popups no encierran ninguna sorpresa: simplemente siguen la máxima de «hacer imposible lo imposible». Pero gracias a seguir este principio, la creación de componentes y hooks en React se vuelve muchísimo más sencilla. Veámoslo con un pequeño ejemplo.

En los tipos anteriores, hemos visto que podemos configurar el fondo (overlay) de un popup. Se trata de un ajuste extremadamente sencillo: cuando lo activamos, el plugin añade un fondo detrás del popup el componente que nos permite configurar el fondo (overlay) de un popup. Cuando lo activas, puedes escoger qué color debe tener dicho fondo:

Ajustes de overlay de un popup
Ajustes de overlay de un popup.

Dicho componente podría tener una implementación parecida a la siguiente:

import * as React from '@wordpress/element';
import { ToggleControl } from '@wordpress/components';
import { _x } from '@wordpress/i18n';
import { ColorControl } from '@nelio/popups/components';
import { usePopupMeta } from '@nelio/popups/hooks';
export const OverlayControl = (): JSX.Element => {
  const [ overlay, setOverlay ] = usePopupMeta( 'overlay' );
  const { isEnabled } = overlay;
  const onChange = ( isChecked: boolean ) =>
    isChecked
      ? setOverlay( { color: '#000000cc', isEnabled: true } )
      : setOverlay( { isEnabled: false } );
  return (
    <>
      <ToggleControl
        label={ _x(
          'Add overlay behind popup',
          'command',
          'nelio-popups'
        ) }
        checked={ isEnabled }
        onChange={ onChange }
      />
      { isEnabled && (
        <ColorControl
          color={ overlay.color }
          onChange={ ( newColor ) =>
            setOverlay( {
              ...overlay,
              color: newColor,
            } )
          }
        />
      ) }
    </>
  );
};

de la que cabe la pena destacar varias cosas.

En primer lugar, fíjate cómo, dado un overlay, somos capaces de acceder al atributo isEnabled sin ningún tipo de comprobación adicional. Esto es debido a que, tal y como puedes ver en la definición de OverlaySettings, el atributo siempre está definido… ya que, de hecho, es el discriminador de este tipo de unión. Así que podemos desestructurarlo sin miedo alguno.

En segundo lugar, el JSX que devolvemos en la función tiene una parte fija y otra opcional. La parte fija es obvia: nuestro componente siempre tiene que devolver el interruptor para activar o desactivar los ajustes del overlay. La segunda parte es la que depende del atributo isEnabled. Si está a true, debemos mostrar los controles adicionales con los que definiremos los valores de los demás atributos (en este caso, un único control para el color del overlay).

Llegados a este punto puedes estar pensando que, «muy bien, todo muy bonito, pero no veo que esto que me estás contando sea mejor que haber hecho un tipo como este»:

type OverlaySettings = {
  readonly isEnabled: true;
  readonly color: Color;
};

¡Me encanta que pienses así! Porque con un ejemplo tan simple y conciso como este me basta para explicarte la utilidad de la alternativa que te propongo. Con tu propuesta, podríamos implementar el método onChange de múltiples formas, y no todas serían correctas:

const onChange1 = ( isChecked: boolean ): void =>
  isChecked
    ? setOverlay( { color: '#000c', isEnabled: true } )
    : setOverlay( { color: '#000c', isEnabled: false } );
const onChange2 = ( isChecked: boolean ): void =>
  setOverlay( { color: '#000c', isEnabled: isChecked } );
const onChange3 = ( isChecked: boolean ): void =>
  setOverlay( { ...overlay, isEnabled: isChecked } );

Por ejemplo, en la tercera opción no tenemos ni idea de cómo está definido actualmente overlay. Cuando activamos el ajuste (es decir, cuando se invoca onChange3 con el parámetro isChecked a true), es posible que el atributo color de overlay sea un string vacío o vete tú a saber qué… con lo que se inicializaría con un estado inconsistente. Porque, claro, nadie nos impide tener estados imposibles:

const overlay = {
  isEnabled: false,
  color: '',
};

Sin embargo, en la solución que te propongo yo, TypeScript nos obliga a definir de forma explícita el atributo color. Un método onChange como el siguiente no funcionaría, tal y como puedes ver aquí:

const onChange3 = ( isChecked: boolean ): void =>
  setOverlay( { ...overlay, isEnabled: isChecked } );

Es por ello que estamos obligados a definir un color por defecto para nuestro overlay cuando lo activamos por primera vez:

const onChange = ( isChecked: boolean ): void =>
  isChecked
    ? setOverlay( { color: '#000c', isEnabled: true } )
    : setOverlay( { isEnabled: false } );

Mola, ¿eh? Y, oye, esta forma de crear plugins de calidad debe ser realmente útil cuando los usuarios te dejan opiniones así:

Me está gustando mucho Nelio Popups (v1.0.6). Los desarrolladores hicieron un buen trabajo con este plugin de popups. Me gusta la atención al detalle que emana de su interfaz de usuario. Ojalá más desarrolladores hicieran plugins de bloques como Nelio Popups, que hacen una única cosa y la hacen bien y, además, encajan de forma natural en el ecosistema de bloques de WordPress.

TheFrameGuy en WordPress.org

Conclusión

Un buen modelo de datos es aquel que captura de forma fidedigna la realidad que intentamos representar en nuestro código. Es decir, necesitamos un modelo lo suficientemente expresivo como para representar toda la realidad, pero nada más. Y es que haciendo imposibles los estados imposibles conseguimos que el compilador se anticipe a posibles problemas y se convierta en nuestro mejor aliado para crear código de calidad.

Espero que te haya gustado la entrada de hoy y hayas aprendido algo útil. Si tienes algún apunte o duda, dímelo en los comentarios y te respondo.

Imagen destacada de Martin Wyall en Unsplash.

Deja una respuesta

No publicaremos tu correo electrónico. Los campos obligatorios están marcados con: •

He leído y acepto la Política de privacidad de Nelio Software

Al marcar la casilla de aceptación estás dando tu legítimo consentimiento para que tu información personal se almacene en SiteGround y sea usada por Nelio Software con el propósito único de publicar aquí este comentario. Contáctanos para corregir, limitar, eliminar o acceder a tu información.