Por qué no debemos utilizar forEach para llamadas asíncronas

En nuestra rutina trabajando con frontend todos los días nos damos cuenta con retos y temas que al principio son sencillos, pero que se complican cuando no tenemos el resultado que esperamos (aunque la lógica del flujo sea perfecta).

Siempre (o casi siempre) que consumimos una API estamos hablando de hacer una requisición que nos devuelve un JSON. Este JSON puede tener 0 o N registros (Generalmente recibimos un array de objetos en caso positivo), además el tiempo de respuesta puede depender de muchos factores que no forman parte de nuestro desarrollo, pero tenemos que contornar los posibles problemas que pueden aparecer.

Sin embargo, una llamada a una API siempre va a ser asíncrona, en otras palabras, si tiene que hacer una requisición, esperamos que la misma conteste y en caso positivo o negativo tenemos que devolver algo en la aplicación (los registros solicitados o una pantalla de error).

Trabajar con tiempo de ejecución en JavaScript nunca fue fácil porque JS tiene un motor Single Thread bloqueante, que quiere decir que ejecuta todo lo que viene, pero que no debería “esperar” hasta que una ejecución esté “terminada”, pero sí devolver la respuesta cuando esté lista (Tal vez esto sea tema para un nuevo artículo, pero tengamos esta base de cómo funciona el event loop).

Event Loop
Ej.: Event loop, cómo funciona el motor del JS.

Teniendo en consideración que llamadas asíncronas y listas forman parte de nuestra rutina, os pongo la siguiente casuística:

  • Tenemos un API donde tenemos que coger un listado de ID’s y para cada ID queremos hacer una llamada a una API.
  • Cada resultado deberá ser añadido en un array de objetos
  • Se debe saber cuándo se han acabado de ejecutar todas las llamadas para hacer algo (Enseñar los registros en la aplicación, procesar los datos o ejecutar otra tarea que depende de la ejecución de la anterior)

Para este ejemplo voy a utilizar la API “PokéApi” porque es completa y sirve perfectamente para los ejemplos que vamos a criar (también por buenos recuerdos).

* Atención: Sabemos que hay un mundo de librerías y frameworks que nos facilita la vida, pero en esta casuística vamos a utilizar apenas JS puro. Pues el intuito es aprender nativamente una solución de un problema en “JS” y seguro que va a ser útil en algún momento mismo utilizando alguna librería o framework ¿Vale?

** Atención 2: Para esta casuística voy a utilizar un archivo.js y otro.html para trabajar en el browser. Si quieres puedes crear solo un archivo.js y probarlo en nodejs. Además (no es obligatorio) estoy utilizando:

- vsCode (puedes utilizar la IDE que quieras)

- Extensión Live Server para ejecutar un simples servidor local (Si quieres puedes utilizar el servidor local que quieras)

¡Creamos un archivo.js y Vamos a empezar!

//Constante con la URL de nuestra api
const baseURL = `https://pokeapi.co/api/v2/pokemon/`;
 
//Ya tenemos los ids en un array! que practico! 
const pokemonList = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
//creamos una función para hacer la llamada a api utilizando el método fetch del Javascript
const getPokemon = async (pokemonId) => {
    const pokemon = await fetch(baseURL + pokemonId);
    return pokemon.json();
};

Disclaimer: Como una llamada a una API siempre es asíncrona, el método fetch nos devuelve una promise (literalmente una promesa), que quiere decir que va a ser resuelta en algún momento y puede devolver un éxito o error (.then, .catch). Para que no caigamos en el famoso “callback from hell” del JavaScript, podemos cambiar el retorno del “callback” por un “await” que devuelve el resultado de la operación asíncrona cuando está lista. Y para eso añadimos “async” en la función contenedora.

 

Imagen:Callback from hell

Ahora continuando…

//Creamos una función que va a recorrer nuestro listado de Ids
const getPokemonList = async (pokemonListId) => {
    const pokemonList = [];
 
    pokemonListId.forEach(async (pokemonId) => {
        const pokemon = await getPokemon(pokemonId);
        console.log(`Capturamos el pokemon ${pokemon.name} | ID: ${pokemon.id}`);
        pokemonList.push({ id: pokemon.id, pokemon: pokemon.name, photo: pokemon.sprites.front_default });
    });
 
    console.log(`fueron procesados todos los pokemons`);
    return pokemonList;
};

Dejamos un “console.log” para comprobar si ha procesado todos los datos. La función al final debería devolver un listado de objetos con el id, nombre y una foto del Pokémon

//por último vamos a crear una función que va inicializar todo el flujo    
const startJourney = async (pokemonList) => {
    const pokedex = await getPokemonList(pokemonList);
    pokedex.length > 0 ? console.table(pokedex) : console.log(`No tenemos ningún pokemon aquí`);
}
 
//Claro que la función inicial debe ser iniciada de alguna manera xD 
document.addEventListener('DOMContentLoaded', startJourney(pokemonList));

Por fin creamos una función que va a iniciar todo el flujo. Invocamos esta función en el “EventListener” “DOMContentLoad”, que ejecuta la función después de cargar todo el HTML. Si estás probando directamente en el terminal del NodeJS, puedes ejecutar directamente la función.

Ejecutando el código en el browser (o terminal) tenemos el siguiente resultado en la consola:

Ejecutando el código podemos percibir algunas cosas:

El mensaje “fueron procesados todos los pokemons” fue ejecutado primero, o sea, antes de recorrer el array.

Nuestra función inicializadora ha ejecutado sin esperar el procesamiento del forEach, luego ha identificado (por el ternario que hemos puesto) que no hay ningún resultado, que no es verdad (¿pues capturamos algunos pokemons no?).

Al final vemos que fueron procesados todos los registros, pero nuestro código no fue capaz de saber en qué momento tiene que esperar hasta que sea llamada la próxima función.

Si te fijas, todas las funciones que hemos creado son asíncronas y luego en su llamada añadimos el await, ¿pero por qué no ha “respetado” el orden y momento de cada ejecución?

Los ID’s siguen una orden (1,2,3) en su índice, pero en la consola nos ha pintado en una orden diferente (en el pantallazo 1, 5, 2, 6)

¡La respuesta está en nuestro forEach! Lo mismo añadiendo async en nuestro forEach lo mismo no va a esperar que todo sea procesado antes de “pasar” a la próxima función.

Podríamos incluso dejar así si no nos interesa controlar cuándo termina la ejecución de las llamadas, pero si es necesario controlarla nace un problema.

¿Y cómo lo arreglamos? Sencillo. Refactorizamos la función “getPokemonList”, quitando el forEach y cambiándolo por un for of.

const getPokemonList = async (pokemonListId) => {
    const pokemonList = [];
    
    /* Intentando capturar pokemons con for of */
    for await (pokemonId of pokemonListId) {
        const pokemon = await getPokemon(pokemonId);
        console.log(`Capturamos el pokémon ${pokemon.name} | ID: ${pokemon.id}` );
        pokemonList.push({ id: pokemon.id, pokemon: pokemon.name, photo: pokemon.sprites.front_default });
    };
 
    console.log(`fueron procesados todos los pokémons`);
    return pokemonList;
}

Ejecutamos nuevamente y a ver lo qué nos pinta

¡Éxito! Ejecutando el código podemos concluir:

  1. El procesamiento del for ocurrió primero
  2. El mensaje “fueron procesados todos los pokemons” ha ejecutado solo cuando terminó la ejecución del loop.
  3. Se nota que la orden de los ID’s fue respetada de acuerdo con el índice (1, 2, 3)
  4. El listado con los datos pillados fue pintado con éxito. Luego podemos hacer lo que necesitamos hasta este punto.

¿Hemos visto que si cambiamos el forEach por el for of resolvemos nuestro problema, pero pasa lo mismo con el for classic? Vamos a probarlo:

const getPokemonList = async (pokemonListId) => {
    const pokemonList = [];
 
    /* Intentando capturar pokemons con clasic for */
    for (let i = 0; i < pokemonListId.length; i++) {
        const pokemon = await getPokemon(pokemonListId[i]);
        console.log(`Capturamos el pokemon ${pokemon.name} | ID: ${pokemon.id}`);
        pokemonList.push({ id: pokemon.id, pokemon: pokemon.name, photo: pokemon.sprites.front_default });
    };
 
    console.log(`fueron procesados todos los pokemons`);
    return pokemonList;
}

Después de ejecutar en el browser tenemos…

El resultado es exactamente igual, aunque “for classic” sea más lioso tenemos más control sobre la recurrencia.

Disclaimer 2: Si estas preguntándote “¿Sino utilizamos las salidas de callback (then y catch) de una promesa, como voy a saber cuándo nos devuelve un error y como puedo tratar eso?”. Para solucionar este tema es muy sencillo, utilices el try catch en su función. ¡Ojo! Try catch va a devolver catch cualquier error, o sea, si hay un error de syntax y no en la llamada va a petar igual. Tenga eso en cuenta ;)

En nuestro código quedaría así:

const getPokemon = async (pokemonId) => {
    try {
        const pokemon = await fetch(baseURL + pokemonId);
        return pokemon.json();
    } catch (error) {
        console.error(`Algo no ha ido bien: `, error);
    }
};

Conclusión

El método forEach es un buen sugar sintax del Javascript moderno y tiene el mismo objetivo que el for of o el classic for (recorrer un array), pero no es lo mismo cuando trabajamos con funciones asíncronas.

En funciones asíncronas, si quieres trabajar con listados como en esta casuística siempre elije el for of o classic for.

Hay muchas maneras de llegar a esta solución sin necesariamente utilizar el for. (como por ejemplo el uso de Promise.all o Promise.allSetled), pero esta es una de las técnicas tal vez menos complejas para aplicar en una situación similar.

BONUS

Ya que has llegado hasta aquí. ¿Qué tal pintar el listado que tenemos en una tabla chula con Javascript? Además, vamos a generar los ID’s de manera aleatoria y de acuerdo con la cantidad que quieras.

* Atención: Si has llegado hasta aquí probando todo en un terminal node. Debemos ahora crear un archivo .html y añadir nuestro script en la etiqueta header. No olvides añadir este trozo de código abajo en nuestro archivo. Además, tienes que ejecutar todo en un servidor local (sugerencia: utilizar la extensión Live Server para vsCode)

//Claro que la función inicial debe ser iniciada de alguna manera xD 
document.addEventListener('DOMContentLoaded', startJourney(pokemonList));

Primero, creamos una función llamada showMyPokedex, que recibe un listado de objetos obtenidos de la función getPokemonList. Esta función tiene como objetivo crear un template con el HTML de una tabla y su contenido va a ser el listado que hemos pasado como argumento. Al final añadimos el template en el body de nuestro HTML.

const showMyPokedex = (pokedexList) => {
 
    const generateBody = (pokedexList) => {
        const tdList = pokedexList.map(pokemon => 
            `
            <tr>
                <td>${ pokemon.id }</td>
                <td>${ pokemon.pokemon }</td>
                <td><img src="${ pokemon.photo }" width="100"/></td>
            </tr>
            `);
            return tdList.join(``);
    }
 
    const template = `
    <table class="GeneratedTable">
      <thead>
        <tr>
          <th>ID</th>
          <th>Nombre</th>
          <th>Foto</th>
        </tr>
      </thead>
      <tbody>
        ${ generateBody(pokedexList) }
      </tbody>
    </table>`;
 
    document.body.insertAdjacentHTML('afterend', template);
};

Creamos la función getPokemonIdList que nos devuelve un listado de ID’s aleatoriamente (ID’s entre 1 y 905, pues hay 905 pokemóns) en la cantidad que es pasado como argumento en su llamada.

* Detalle: Hay una condición para que nunca sea añadido un ID repetido.

const getPokemonIdList = (quantity) => {
    const listOfIds = [];
    const randomId = _ => Math.floor(Math.random() * 905) + 1;
    for (let index = 0; index < quantity; index++) {
        const id = randomId();
        listOfIds.includes(id) ? listOfIds.push(randomId()) : listOfIds.push(id);        
    }
    return listOfIds;
}

 

Cambiamos nuestro constante de array pokemonList para recibir el valor de la función que hemos creado. Pasamos por parámetro la cantidad que nos gustaría generar.

const pokemonList = getPokemonIdList(15);

Cambiamos la función inicializadora startJourney y añadimos una condición para llamar nuestra showMyPokedex si hay algún registro en el listado.

const startJourney = async (pokemonList) => {
    const pokedex = await getPokemonList(pokemonList);
    pokedex.length > 0 ? showMyPokedex(pokedex) : console.log(`No tenemos ningún pokemon aquí`);
}

Para el estilo de nuestra tabla, añadimos al archivo .html, dentro de la etiqueta head el siguiente trozo de código (puedes cambiar los estilos como quieras)

    <script src="demo.js" type="text/javascript"></script>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
    <style>
        table.GeneratedTable {
          width: 90%;
          background-color: #ffffff;
          border-collapse: collapse;
          border-width: 2px;
          border-color: #c13e06;
          border-style: dashed;
          margin: 14px auto;
          font-family: 'Press Start 2P', cursive;
        }
        
        table.GeneratedTable td, table.GeneratedTable th {
          border-width: 2px;
          border-color: #c13e06;
          border-style: dashed;
          padding: 3px;
          color: #ffffff;
        }
 
        table.GeneratedTable tbody td {
            font-size: 12px;
            text-align: center;
            color: #4a0fa8;
        }
        
        table.GeneratedTable thead {
          background-color: #fd4949;
        }
        </style>

Probamos en el browser a ver el resultado.

Código fuente utilizado en este articulo.

Y esto es todo. ¡Hasta la próxima!

 

 

He leído y acepto la política de privacidad
Acepto recibir emails sobre actividades de recruiting NTT DATA