-2

Context and goal

I have two fetch()/then() chains that build elements that I need for an event handler.

The first chain loads data to build a <select> element. Some options are selected by default. The function in the then() does not return anything. It only creates the <select> element in the DOM.

fetch("data/select.json")
  .then(response => response.json())
  .then(data => {
    // populate select options
  });

The second chain loads data to draw several charts using Chartist.

fetch("data/chartist.json")
  .then(response => response.json())
  .then(data => Object.keys(data).forEach(x =>
    // draw charts using Chartist
    new Chartist.Line(`#${x}`, { "series": data[x] });
  ));

Finally, I need to add an event handler to each chart object (on the "created" event using Chartist's on method). The handler function needs to get the values selected in the <select> element that was built in the first fetch()/then() chain. Since "created" is emitted each time the charts are (re)drawn (e.g. chart creation, window resize, etc.), the user may have selected different values than the default ones in the meantime. Therefore, I can not just use a static array containing the default values. Instead, I get the selected values from the properties of the <select> element in the DOM (i.e. selectElement.selectedOptions), so I need to wait for the <select> element to be ready.

What I tried

Currently, I add the event handler to each chart object in the second fetch()/then() chain. However, this fails when the code that get the selected options is executed before the <select> element is ready.

fetch("data/chartist.json")
  .then(response => response.json())
  .then(data => Object.keys(data).forEach(x => {
    // draw charts using Chartist
    const chart = new Chartist.Line(`#${x}`, { "series": data[x] });
    // add an event handler to the charts
    chart.on("created", () => {
     // get the values selected in the <select> and use it
    });
  }));

I also tried to nest the second fetch()/then() chain in the first one or to add a timeout. This is not optimal because it waste time by not fetching the data asynchronously.

Question

I guess the cleanest solution would be to extract the event handler from the second fetch()/then() chain, let both fetch()/then() chains run asynchronously, and wait only before adding the event handler. Is it a good approach?

If yes, I think that I need to make the thens return promises and wait (e.g. using Promise.all()). One promise should return the chart objects without missing any event (notably the first "created" event emitted on chart creation), so I could extract the event handler outside of the second fetch()/then() chain.

I read many related questions on SO (e.g. about asynchronous programming and promises), as well as the meta thread about closing such questions. I understand that the theory have already been thoroughly explained several times, but I did not manage to implement a clean solution to my problem despite reading carefully (beginner here). Any hint would be much appreciated.

Thomas
  • 457
  • 2
  • 12
  • 2
    Fetching both and using promise.all would probably be the simplest route. – Kevin B Apr 26 '22 at 17:17
  • You need to be more specific. What values are required from the ` – Phil Apr 27 '22 at 07:06
  • @Phil I updated the question to provide more details. Is it clearer now? – Thomas Apr 27 '22 at 13:05
  • @KevinB Thank you for the advice. I understand the general idea but did not manage to implement it. Could you please provide a code snippet? (accounting for the details I added) – Thomas Apr 27 '22 at 13:54
  • 1
    Async code can be complicated to wrap your head around especially for a **beginner**. This user should be applauded for their efforts to handle this properly instead of contributing to the abysmal user-experience that makes up the majority of the web. – Besworks May 01 '22 at 13:57

1 Answers1

3

You are running into what's called a race condition. The order of resolution for promises is nondeterministic.

You could wait for both fetch requests to resolve using Promise.all() :

Promise.all([
  fetch('data/select.json').then(r => r.json()),
  fetch('data/chartist.json').then(r => r.json())
]).then(results => {
  // populate select options from results[0]
  Object.keys(results[1]).forEach(x => {
    let chart = new Chartist.Line(`#${x}`, { "series": results[1][x] });
    chart.on('created', event => {
      // get the values selected in the <select> and use it
    });
  });
});

But as with nesting the fetches, this method adds unnecessary delay into your application. Your select element data could be received well before the chart data but instead of updating the UI ahead of time you will have to wait for the second promise to resolve. Additionally, both requests must succeed or your code will not run. You could use Promise.allSettled() and handle any error conditions manually but you'd still have to wait for both requests to complete before your code was executed.

A better way to handle this would be to take advantage of the web's event driven nature by extending EventTarget and dispatching a CustomEvent when your select element is populated.

Since we know it's possible for our custom populated event to fire before the Chartist fetch has resolved then we also need to use a couple booleans to track the status. If we missed the event, just configure the chart otherwise attach an EventListener and wait but only if there is not already a listener queued up.

In a new file :

export class YourController extends EventTarget {
  constructor() {
    super();
  }

  populated = false;

  async getData(path) {
    return fetch(path).then(res => res.json());
  }

  async getSelectOptions() {
    let data = await this.getData('data/select.json');
    // populate select options here
    this.populated = true;
    this.dispatchEvent(new CustomEvent('populated'));
  }

  async getChartData() {
    let data = await this.getData('data/chartist.json');
    Object.keys(data).forEach(x => this.createChart(x, data[x]));
  }

  createChart(id, data) {
    let chart = new Chartist.line(`#${id}`, { series:data });
    chart.on('created', event => this.onCreated(chart));
  }
  
  onCreated(chart) {
    if (this.populated) {  this.configure(chart); }
    else if (chart.waiting) { return; } chart.waiting = true;
    this.addEventListener('populated', event => this.configure(chart), { once:true });
  }

  configure(chart) {
    // get and use selected options here
    chart.waiting = false;
    this.dispatchEvent(new CustomEvent('configured', { detail:chart }));
  }
}

In your app :

import { YourController } from './your-controller.js'

const c = new YourController();

c.addEventListener('configured', event => {
  console.log('configured:', event.detail)
});

c.getSelectOptions();
c.getChartData();

Setting the { once:true } option in addEventListener() will automatically remove the listener after the first time the populated event fires. Since the created event can fire multiple times, this will prevent an endless stack of calls to .configure(chart) from piling up. I've also updated the script to prevent extra listeners from being added if created is fired more than once before populated.

Besworks
  • 4,123
  • 1
  • 18
  • 34
  • By the way, all [DOM Nodes](https://developer.mozilla.org/en-US/docs/Web/API/Node) extend EventTarget. – Besworks Apr 30 '22 at 19:34
  • Thank you very much for such an educational answer! I will try as soon as possible. I will award the bounty in a few days to keep the question attractive. Thanks again! – Thomas May 01 '22 at 17:33
  • Your answer allowed me to solve my problem. I finally used the first solution (`Promise.all`) because I do not notice any lag in most cases and the code is more simple/maintainable for me. This is a well-deserved bounty! Thank you again! – Thomas May 03 '22 at 13:19
  • 1
    You should test your app with [network throttling](https://css-tricks.com/throttling-the-network/) to make sure it behaves as you expect on a slow connection. You may find that there is more potential for lag than you realize. – Besworks May 03 '22 at 18:08
  • Thanks for the advice. I just did some tests and it works as expected! – Thomas May 04 '22 at 08:20