Taller: React con hooks IV

Cuarta entrada del taller, empezamos con los hooks y terminamos la vista principal.

Catrin Welz-Stein

archivado en: JavaScript / 30 marzo, 2020

Seguimos con el taller de reac con hooks. En esta entrada vamos ya con la vista del listado de chuchos, en la cual vamos a manejar los dos hooks más habituales:

1. El hook de estado (useState): sirve para manejar el "estado" de un componente, esto es, el conjunto de datos internos. Se importa al principio:

import React, { useState } from 'react';

Y luego ya lo podemos usar dentro de nuestro componente. Copio el ejemplo de la documentación y luego lo explico:

import React, { useState } from 'react';

function Example() {
  // Declaración de una variable de estado que llamaremos "count"
  const [count, setCount] = useState(0);

Utilizando la asignación mediante desestructuración (destructuring assignment) definimos una variable (count), que podemos cambiar mediante un método (setCount) y la inicializamos con el valor 0.

2. El hook de efecto (useEffect), que equivaldría a los antiguos métodos del ciclo de vida. Se dispara mágicamente cada vez que cambian los valores del componente.

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

Si se le pasa un valor (en un array), solo watchearía ese valor. Por ejemplo, en este caso, solo se dispararía si cambiase foo.

 useEffect(() => {
  // hacemos algo
  }, [foo]);

Para que se ejecute solo una vez al principio, el equivalente al componentDidMount, le pasamos un array vacío.

useEffect(() => {
  // hacemos algo
  }, [foo]);

Y para conseguir el mismo efecto del onDestroy de Angular o el componentWillUnmount clásico de React, es decir, hacer algo cuando el compo se va a destruir, retornamos una función.

useEffect(() => {
  window.addEventListener('mousemove', () => {});

  /* esto se ejecuta cuando el compo se destruye */
  return () => {
    window.removeEventListener('mousemove', () => {})
  }
}, [])

Vamos a llevar todo esto a la práctica con la vista del listado de perros, la cual se compone de los siguientes componentes ordenados de menor a mayor:

dog-card: la ficha individual con la imagen de cada perro.

dog-list: el listado de las fichas.

dogs-select: dos select para seleccionar la especie de perro cuyas imágenes vamos a mostrar y, si tuviera, sus subespecies, que también se pueden seleccionar individualmente.

view-dog: el componente que servirá de contenedor principal, desde el cual se gestiona toda la lógica. Debe ser el único con capacidad para modificar los datos compartidos entre los demás componentes, que por el contrario deben limitarse a recibir y emitir datos sin poder modificarlos. Volveré sobre esto más adelante, ahora toca terminar de darle estilos al tinglado.

Una capa de pintura

Podemos empezar añadiendo alguna fuente a la aplicación.

.dogTheme {
  font-family: "dogs-theme", "sans-serif";
}

Seguimos con los elementos de formulario que vamos a necesitar: el select y el button.

/* src/assets/scss/components/forms.scss */

.dog-form {
  &__select {
    cursor: pointer;
    background-color: transparent;
    border: none;
    border-bottom: 1px solid $base-theme-darken-02;
    outline: none;
    color: $base-theme-darken-02;
    height: 3rem;
    line-height: 3rem;
    width: 100%;
    font-size: 1rem;
    margin: 0 0 8px 0;
    padding: 0;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    opacity: 1;
    transition: opacity 1s;

    &--disabled {
      opacity: 0.5;
      cursor: not-allowed;
      pointer-events: none;
    }
  }

  &__button {
    border: none;
    border-radius: 5px;
    display: inline-block;
    height: 36px;
    line-height: 36px;
    padding: 0 16px;
    text-decoration: none;
    color: #fff;
    background-color: $base-theme-darken-03;
    text-align: center;
    letter-spacing: .5px;
    transition: .3s ease-out;
    @include depth-02;

    &--disabled {
      opacity: .5;
      pointer-events: none;
    }
  }

  &__button:hover {
    background-color: $base-theme-darken-02;
    @include depth-03;
  }
}

Y terminamos con la vista de los chuchos, que es la más complicada. Para armar el grid del listado podemos usar flex y en las fichas podemos añadir alguna animación. Además, prepararemos una ficha gris que nos servirá de preload para la llamada de las imágenes.

/* src/assets/scss/pages/_view-dogs.scss */

.view-dogs {
  .dog-select {
    &__container {
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      flex-flow: nowrap;
    }

    &__item {
      margin: 1rem;
    }
  }

  @media (max-width: 600px) {
    .dog-select__container {
      flex-flow: wrap;
    }
  }

  .container-dog {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: flex-start;
  }

  .card-dog {
    width: 300px;
    height: 300px;
    text-align: center;
    @include depth-02;
    transition: .3s ease-out;
    box-sizing: border-box;
    margin: 10px;
    padding: 10px;
    box-sizing: border-box;
    background-color: #ffffff;

    &:hover {
      @include depth-03;
      background-color: $base-theme-lighten-02;
      transform: rotate(2deg);
    }

    &__preload {
      background:rgb(255,249,249);
      background: linear-gradient(90deg, rgba(255,249,249,1) 0%, rgba(236,236,236,0.7791491596638656) 67%, rgba(244,244,244,1) 100%);
      transition: .3s ease-out;
    }

    &__figure {
      width: 280px;
      height: 280px;
      overflow: hidden;
    }

    &__image {
      &--horizontal {
        width: 280px;
        height: auto;
      }
      &--vertical {
        width: auto;
        height: 280px;
      }
    }
  }
}

Importamos los archivos nuevos en el index.scss y con esto habríamos terminado con los estilos. Vamos ya con el javaScript.

/* src/assets/scss/index.scss */

@import 'base/reset';
@import 'base/colors';
@import 'base/fonts';
@import 'base/shadows';

@import 'layout/pages';
@import 'layout/header';

@import 'components/forms';
@import 'components/message';

@import 'pages/view-dogs';

@import 'themes/theme';

Componentes transversales

Primero preparamos los dos componentes transversales relacionados con los formularios. Al igual que hicimos con el componente de las notificaciones, tipamos las propiedades que pueden recibir para que la aplicación sea más robusta. Las "props" que recibe la función se podrían destructurar, pero a mí me gusta dejarlas así para identificar luego rápidamente los datos que llegan de fuera al ir precedidos por "props.algo".

Comenzamos con el botón, que se podrá disablear desde fuera, y que llama al método definido en el componente padre al ser pulsado (handleClick). El atributo data-qa, recordemos, nos servirá más adelante para los tests e2e.

// src/components/forms/app-button.tsx

import React from 'react';

interface IntAppButton{
  label: string;
  disabled?: boolean;
  tabIndex?: number;
  idQa?:string;
  handleClick?(): any;
}

const AppButton: React.FC<IntAppButton> = (props)  => {
  return (
    <button
      disabled={props.disabled}
      tabIndex={props.tabIndex}
      className={`dog-form__button dog-select__item  ${ props.disabled === true ? 'dog-form__button--disabled': ''}`}
      onClick={props.handleClick}
      data-qa={props.idQa}
    >
      {props.label}
    </button>
  );
};

export default AppButton;

Seguimos con el select. Se podría complicar haciendo un falso select, un dropdown, que siempre queda más mono, pero para este taller nos vale con un select normal. Observad que al tipar la lista que recibe con IntAppSelect.list y IntItemList.IntItemList estamos forzando que el array de ítems que van a formar el select tenga siempre el mismo formato.

// src/components/forms/app-select.tsx

import React from 'react';

interface IntItemList {
  key: string;
  value: string;
}

interface IntAppSelect{
  required?: boolean;
  label?: string;
  noSelectionLabel?: string;
  valueNoSelection?: string;
  disabled?: boolean;
  tabIndex?: number;
  idQa?:string;
  handleOnChange(value: string): any;
  list: Array<IntItemList>;
}

const AppSelect: React.FC<IntAppSelect> = (props)  => {
  return (
    <>
    {props.label && <label>props.label</label>}
    <select
      aria-required={props.required}
      tabIndex={props.tabIndex}
      className={`dog-form__select dog-select__item ${ props.disabled ? 'dog-form__select--disabled': ''}`}
      disabled={props.disabled}
      onChange={e => props.handleOnChange(e.target.value)}
      data-qa={props.idQa}
    >

      {props.noSelectionLabel && <option value={props.valueNoSelection}>{props.noSelectionLabel}</option>}

      {Array.isArray(props.list) && props.list.map((item: IntItemList) => {
        return (
          <option value={item.key} key={item.key}>{item.value}</option>
        )
      })}

    </select>
    </>
  );
};

export default AppSelect;

El listado de chuchos

Vamos ahora con los componentes de la vista principal. Primero, preparamos una interface para los datos internos que se manejan en esta vista.

src/views/dogs/schemas/view-dogs-interfaces.ts

// Interfaces que solo se usan en esta vista
export interface IntHandleSelectDog {
  dog: string;
  specie: string;
}

Seguimos con los componentes. Conviene prepararlos del más pequeño al más grande, así que comenzamos con la ficha. Como las imágenes tienen medidas distintas, llamamos en el evento onLoad, cuando se carga, a un método que calcula si la imagen recibida es más ancha que alta o viceversa para que se renderice con una clase u otra.

// src/views/dogs/components/dog-card.tsx

import React, { useState } from 'react';

interface IntDogCardProps {
  dogImg: string;
  key?: number;
}

const DogCard: React.FC<IntDogCardProps> = (props)  => {
  const [classImg, setClassImg] = useState('card-dog__image--horizontal');

  const setStyle = (img: any) => {
    if (img.naturalHeight > img.naturalWidth) {
      setClassImg('card-dog__image--vertical');
    }
  };

  return (
    <div className="card-dog">
      <figure className="card-dog__figure">
        <img
          onLoad={e => setStyle(e.target)}
          className={'card-dog__image ' + classImg} src={props.dogImg} alt={props.dogImg}  />
      </figure>
    </div>
  );
};

export default DogCard;

El listado no tiene mayor misterio. Recibe la lista de imágenes por props y las mapeamos en lo que sería el equivalente del ngFor de angular.

// src/views/dogs/components/dog-list.tsx

import React from 'react';
import { IntDogSelected } from '../../../schemas/dogs';
import DogCard from './dog-card';

interface IntDogListProps {
  dogSelected: IntDogSelected;
}

const DogList: React.FC<IntDogListProps> = (props)  => {
  return (
    <div className="container-dog" role="main" data-qa="dogsList">
      {props.dogSelected.images.map((img: string, index: number) => {
        return <DogCard dogImg={img} key={index} />
      })}
    </div>
  );
};

export default DogList;

Además, podemos preparar un componente preload para mostrar mientras se realiza la llamada de las imágenes.

// src/views/dogs/components/dog-list-preload.tsx

import React from 'react';

const DogListPreload: React.FC = ()  => {
  return (
    <div className="container-dog" data-qa="dogsListPreload">
      <div className="card-dog card-dog__preload"></div>
      <div className="card-dog card-dog__preload"></div>
      <div className="card-dog card-dog__preload"></div>
    </div>
  );
};

export default DogListPreload;

El selector de perros es algo más complejo. Necesitamos definir dos hooks con los que gestionar el valor seleccionado. Si no se ha escogido ningún perro ('noDogSelected'), desactivaremos el botón, que, en cambio, llamará a la función que enviamos desde el padre (onSelectDog) si hay un perro seleccionado y sus subespecies en caso de que las tenga y se haya escogido una.

// src/views/dogs/components/dogs-select.tsx

import React, { useState }  from 'react';
import { IntDog } from '../../../schemas/dogs';
import { IntHandleSelectDog } from '../schemas/view-dogs-interfaces';

import AppButton from '../../../components/forms/app-button';
import AppSelect from '../../../components/forms/app-select';

interface IntDogsSelectProps {
  readonly list: Array<IntDog>;
  readonly disabledActions: boolean;
  onSelectDog(dogData: IntHandleSelectDog): any;
}

const DogsSelect: React.FC<IntDogsSelectProps> = (props)  => {
  const [dogPreselect, setDogPreselect] = useState('noDogSelected');
  const [speciePreselect, setSpeciePreselect] = useState('noDogSelected');

  // user actions
  const handleSelectDog = (val: string) => {
    setDogPreselect(val);
    setSpeciePreselect('noDogSelected');
  };

  const selectDog = () => {
    if (dogPreselect !== 'noDogSelected' && !props.disabledActions) {
      props.onSelectDog({
        dog: dogPreselect,
        specie: speciePreselect
      });
    }
  };

  // render
  const mapDogsToSelect = () => {
    return props.list.map((dog: IntDog) => {
      return {
        key: dog.name,
        value: dog.name  + (dog.species.length > 0 ? ' *' : '')
      }
    });
  };

  const mapSpeciesToSelect = () => {
    const species = props.list.filter((dog) => {
      return dog.name === dogPreselect;
    });

    if (!species.length) {
      return [];
    }
    return species[0].species.map((dog: string) => {
      return {
        key: dog,
        value: dog
      }
    });
  };

  return (
    <nav className="dog-form dog-select__container" role="navigation">

      <AppSelect
        required={true}
        noSelectionLabel="Select"
        valueNoSelection="noDogSelected"
        tabIndex={1}
        idQa="selectDog"
        handleOnChange={handleSelectDog}
        list={mapDogsToSelect()}
      ></AppSelect>

      <AppSelect
        required={false}
        noSelectionLabel="Select"
        valueNoSelection="noDogSelected"
        tabIndex={2}
        idQa="selectSpecie"
        handleOnChange={setSpeciePreselect}
        list={mapSpeciesToSelect()}
        disabled={!mapSpeciesToSelect().length}
      ></AppSelect>

      <AppButton
        label="Images"
        disabled={dogPreselect === 'noDogSelected' || props.disabledActions}
        tabIndex={3}
        handleClick={selectDog}
        idQa="submitSelectDog"
      ></AppButton>
    </nav>
  );
};

export default DogsSelect;

Y ahora ya lo juntamos todo en el contenedor principal. Mediante el hook useState modificaremos el "estado" del componente y con el useEffect haremos la llamada inicial.

/* src/views/dogs/view-dogs.tsx */

import React, { useState, useEffect } from 'react';
import dogsService from '../../core/services/dogs-services';

import DogsSelect from './components/dogs-select';
import DogList from './components/dog-list';
import DogListPreload from './components/dog-list-preload';
import Message from '../../components/ui/message';

import { IntDog, IntDogSelected } from '../../schemas/dogs';
import { IntHandleSelectDog } from './schemas/view-dogs-interfaces';


const ViewDogs: React.FC = () => {
  /* estado interno: control de la llamada rest:
  1. la llamada de los perros,
  2. la llamada de las imágenes de la especie o subespecie seleccionada,
  3. los errores */
  const [ isLoad, setIsLoad ] = useState(false);
  const [ isLoadImages, setIsLoadImages ] = useState(false);
  const [ hasError, setHasError ] = useState(false);
  const [ errorMessage, setErrorMessage ] = useState('');

  /* estado interno: listado de perros. Más adelante lo
  gestionaremos mediante el store */

  /* Valor inicial listado de perros */
  const dogsRaw: Array<IntDog> = [];
  /* Hook */
  const [ dowsList, setDowsList] = useState(dogsRaw);

  /* Valor inicial perro seleccionado */
  const dogSelectedRaw: IntDogSelected = {
    dog: 'noDogSelected',
    specie: 'noDogSelected',
    species: [],
    images: []
  };
  /* Hook */
  const [ dogSelected, setDogSelected] = useState(dogSelectedRaw);

  /* servicios */
  const { getDogs, getDogImages } = dogsService();

  /* Este efecto equivale al "onInit" clásico */
  useEffect(() => {
    /* Seteamos is load a true para que se muestre el preload */
    setIsLoad(true);

    /* Cargamos los perros, que es una promesa asíncrona */
    getDogs().then(
      (dogs) => {
        setIsLoad(false);
        /* Si lleva bien la respuesta, seteamos el listado de perros */
        if (Array.isArray(dogs)) {
          setDowsList(dogs);
        } else {
          /* de lo contrario seteamos el error */
          setHasError(true);
          setErrorMessage('error cargando el listado');
        }

      }).catch( (err) => {
        setIsLoad(false);
        setHasError(true);
        setErrorMessage(err);
      });
  }, [getDogs]);

  /* Con este método chequeamos qué ha escogido el usuario:
  una especie,
  una subespecie
  o nada  */
  const handleSelectDog = (data: IntHandleSelectDog) => {
    if ( data.dog === 'noDogSelected' ||
        (data.dog === dogSelected.dog && dogSelected.specie === 'noDogSelected') ||
        (data.dog === dogSelected.dog && dogSelected.specie === data.specie) ) {
          return;
        }
    let url = data.dog;
    /* Si es una subespecie, la añadimos al endpoint */
    if (data.specie !== 'noDogSelected') {
      url += '/' + data.specie;
    }
    /* Lanzamos el método que cargará las imágenes */
    selectDogAndImages(url, data);
  };

  /* Con este método cargamos las imágenes del perro seleccionado */
  const selectDogAndImages = (url: string, data: IntHandleSelectDog) => {
    setIsLoadImages(true);
    getDogImages(url).then(
      (images) => {
        setIsLoadImages(false);
        /* Si llegan los datos buenos, seteamos el perro seleccionado */
        if (Array.isArray(images)) {
          const temp = dowsList.filter( (dog: IntDog) => dog.name === data.dog ).map( (dog: IntDog) => dog.species );
          console.log(temp)
          const dogSelected = {
            dog: data.dog,
            specie: data.specie,
            species: [],
            images: images
          }
          setDogSelected(dogSelected);

        } else {
          /* de lo contrario, lanzamos el error */
          setHasError(true);
          setErrorMessage('error cargando las imágenes');
        }

      }).catch( (err) => {
        setIsLoad(false);
        setHasError(true);
        setErrorMessage('Error loading data ' + err);
      });

  }

  return (
    <section className="page view-dogs">
      { isLoad !== true && hasError === true && <Message type="error" message={errorMessage} aria-hidden={!hasError} /> }
      <DogsSelect list={dowsList} disabledActions={isLoadImages} onSelectDog={handleSelectDog} />
      { isLoadImages === true && <DogListPreload aria-hidden={!isLoadImages} /> }
      { isLoadImages === false && <DogList dogSelected={dogSelected} aria-hidden={isLoadImages} /> }
    </section>
  );
}

export default ViewDogs;

Bueno, pues llegados a este punto nuestra vista debería lucir más o menos así.

Una entrada larga, pero ya tenemos casi toda la aplicación lista. Nos falta el store, la carga lazy de las vistas y los tests, pero eso lo vemos ya en la siguiente sesión de este taller de react en tiempos de la cuarentena : )

|| Tags: , , ,

Este artículo aún no ha sido valorado.

¿Te ha parecido útil o interesante esta entrada?
dormido, valoración 1 nadapensativo, valoración 2 un poco sonrisa, valoración 3 a medias guiño, valoración 4 bastante aplauso, valoración 5 mucho

Tú opinión es muy importante, gracias por compartirla!

Los comentarios están cerrados.