javascriptasynchronouspromisefetchchartist.js

Build an event handler using elements from two `fetch()`/`then()` chains


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.


Solution

  • 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.