3

Suppose I have this data in spanish.json:

[
   {"word": "casa", "translation": "house"},
   {"word": "coche", "translation": "car"},
   {"word": "calle", "translation": "street"}
]

And I have a Dictionary class that loads it and adds a search method:

// Dictionary.js
class Dictionary {
  constructor(url){
    this.url = url;
    this.entries = []; // we’ll fill this with a dictionary
    this.initialize();
  }

  initialize(){
    fetch(this.url)
      .then(response => response.json())
      .then(entries => this.entries = entries)
  }

  find(query){
    return this.entries.filter(entry => 
      entry.word == query)[0].translation
  }
}

And I can instantiate that, and use it to look up ‘calle’ with this little single-page app:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>spanish dictionary</title>
</head>
<body>

<p><input placeholder="Search for a Spanish word" type="">  
<p><output></output>

<script src=Dictionary.js></script>
<script>

  let es2en = new Dictionary('spanish.json')
  console.log(es2en.find('calle')) // 'street'

  input.addEventListener('submit', ev => {
    ev.preventDefault();
    let translation = dictionary.find(ev.target.value);
    output.innerHTML = translation;
  })

</script>


</body>
</html>

So far so good. But, let’s say I want to subclass Dictionary and add a method that counts all the words and adds that count to the page. (Man, I need some investors.)

So, I get another round of funding and implement CountingDictionary:

class CountingDictionary extends Dictionary {
  constructor(url){
    super(url)
  }

  countEntries(){
    return this.entries.length
  }
}

The new single page app:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Counting Spanish Dictionary</title>
</head>
<body>

<p><input placeholder="Search for a Spanish word" type="">  
<p><output></output>

<script src=Dictionary.js></script>
<script>


  let 
    es2en = new CountingDictionary('spanish.json'),
    h1 = document.querySelector('h1'),
    input = document.querySelector('input'),
    output = document.querySelector('output');

  h1.innerHTML = es2en.countEntries();

  input.addEventListener('input', ev => {
    ev.preventDefault();
    let translation = es2en.find(ev.target.value);
    if(translation)
      output.innerHTML = `${translation}`;
  })

</script>

</body>
</html>

When this page loads, the h1 gets populated with 0.

I know what my problem is, I just don’t how to fix it.

The problem is that the fetch call returns a Promise, and the .entries property is only populated with the data from the URL once that Promise has returned. Until then, .entries remains empty.

How can I make .countEntries wait for the fetch promise to resolve?

Or is there a better way entirely to achieve what I want here?

Bergi
  • 630,263
  • 148
  • 957
  • 1,375

3 Answers3

5

The problem is that the fetch call returns a Promise, and the .entries property is only populated with the data from the URL once that Promise has returned. Until then, .entries remains empty.

You would need to make entries a promise. That way, all of your methods had to return promises, but the Dictionary instance is immediately usable.

class Dictionary {
  constructor(url) {
    this.entriesPromise = fetch(url)
      .then(response => response.json())
  }
  find(query) {
    return this.entriesPromise.then(entries => {
       var entry = entries.find(e => e.word == query);
       return entry && entry.translation;
    });
  }
}
class CountingDictionary extends Dictionary {
  countEntries() {
    return this.entriesPromise.then(entries => entries.length);
  }
}

let es2en = new CountingDictionary('spanish.json'),
    h1 = document.querySelector('h1'),
    input = document.querySelector('input'),
    output = document.querySelector('output');

es2en.countEntries().then(len => {
  fh1.innerHTML = len;
});
input.addEventListener(ev => {
  ev.preventDefault();
  es2en.find(ev.target.value).then(translation => {
    if (translation)
      output.innerHTML = translation;
  });
});

Or is there a better way entirely to achieve what I want here?

Yes. Have a look at Is it bad practice to have a constructor function return a Promise?.

class Dictionary {
  constructor(entries) {
    this.entries = entries;
  }  
  static load(url) {
    return fetch(url)
      .then(response => response.json())
      .then(entries => new this(entries));
  }

  find(query) {
    var entry = this.entries.find(e => e.word == query);
    return entry && entry.translation;
  }
}
class CountingDictionary extends Dictionary {
  countEntries() {
    return this.entries.length;
  }
}

let es2enPromise = CountingDictionary.load('spanish.json'),
    h1 = document.querySelector('h1'),
    input = document.querySelector('input'),
    output = document.querySelector('output');

es2enPromise.then(es2en => {
  fh1.innerHTML = es2en.countEntries();
  input.addEventListener(…);
});

As you can see, this appraoch requires less overall nesting compared to an instance that contains promises. Also a promise for the instance is better composable, e.g. when you would need to wait for domready before installing the listeners and showing output you would be able to get a promise for the DOM and could wait for both using Promise.all.

Community
  • 1
  • 1
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • Thanks for your comment. The problem with this approach as I see it is that you still end up with a load method which is essentially external to the class. While there is some encapsulation in putting the the .load in a static method on the class, the pattern for calling that method is still little different from just running a fetch(url) in the global namespace, and then instantiating the class with that data. –  Sep 09 '16 at 14:42
  • Yes, it's little different, but it's the right™ thing to do :-) You always have to wait "externally". – Bergi Sep 09 '16 at 14:44
  • Is there a reason that the `load` method must be static? –  Sep 15 '16 at 19:47
  • @pat It returns a promise for an instance, it cannot be invoked on an instance that doesn't yet exist. – Bergi Sep 15 '16 at 19:51
0

You have to assign the result of the fetch() call to some variable, for example:

initialize(){
  this.promise = fetch(this.url)
    .then(response => response.json())
    .then(entries => this.entries = entries)
}

Then you can call the then() method on it:

let es2en = new CountingDictionary('spanish.json'),
    h1 = document.querySelector('h1'),
    input = document.querySelector('input'),
    output = document.querySelector('output');

es2en.promise.then(() => h1.innerHTML = es2en.countEntries())

input.addEventListener('input', ev => {
  ev.preventDefault();
  let translation = es2en.find(ev.target.value);
  if(translation)
    output.innerHTML = `${translation}`;
})
Michał Perłakowski
  • 88,409
  • 26
  • 156
  • 177
  • This is quite similar to Frxstrem’s response, and thus has the same drawback. –  Sep 09 '16 at 15:10
0

A simple solution: Keep the promise after you do fetch(), then add a ready() method that allows you to wait until the class has initialized completely:

class Dictionary {
  constructor(url){
    /* ... */

    // store the promise from initialize() [see below]
    // in an internal variable
    this.promiseReady = this.initialize();
  }

  ready() {
    return this.promiseReady;
  }

  initialize() {
    // let initialize return the promise from fetch
    // so we know when it's completed
    return fetch(this.url)
      .then(response => response.json())
      .then(entries => this.entries = entries)
  }

  find(query) { /* ... */ }
}

Then you just call .ready() after you've constructed your object, and you'll know when it's loaded:

let es2en = new CountingDictionary('spanish.json')
es2en.ready()
  .then(() => {
    // we're loaded and ready
    h1.innerHTML = es2en.countEntries();
  })
  .catch((error) => {
    // whoops, something went wrong
  });

As a little extra advantage, you can just use .catch to detect errors that occur during loading, e.g., network errors or uncaught exceptions.

Frxstrem
  • 38,761
  • 9
  • 79
  • 119
  • 1
    What's the purpose of the `ready()` method, when you can access `promiseReady` directly? – Michał Perłakowski Sep 08 '16 at 15:40
  • @Gothdo: Honestly it's mostly personal convention, but the idea is that exposed only through a getter (so it can't be externally modified) and I think it makes more sense to have a function that returns a promise as an "action", (i.e., "wait for this class to be ready") than directly accessed. – Frxstrem Sep 08 '16 at 15:45
  • This reminds me of this approach: https://quickleft.com/blog/leveraging-deferreds-in-backbonejs/ . While is “self-contained” in the sense that the fetch call is inside the class (and here the Dictionary consequently uses a url as the constructor parameter), the drawback is the weirdness of any client that uses an instance of the class having to refer to the .ready() method “forever”… and that, to me, makes it seems like .initialize() isn’t really initializing… –  Sep 09 '16 at 15:09