Is it safe to store, access and HTMLElement objects directly inside an object, vs. relying on CSS selectors?

I have a vanilla Javascript class that builds a bunch of HTML, essentially a collection of related HTMLElement objects that form the user interface for a component, and appends them to the HTML document. The class implements controller logic, responding to events, mutating some of the HTMLElements etc.

My gut instinct (coming from more backend development experience) is to store those HTMLElement objects inside my class, whether inside a key/value object or in an array, so my class can just access them directly through native properties whenever it's doing something with them. But everything I look at seems to follow the pattern of relying on document selectors (document.getElementById, getElementsByClassName, etc etc). I understand the general utility of that approach but it feels weird to have a class that creates objects, discards its own references to them, and then just looks them back up again when needed.

A simplified example would look like this:

<html>
<body>
<script>
/* Silly implementation of the "Concentration" memory match game.
   This is just a S/O example but it should actually work =P
 */

class SymbolMatchGame {

  constructor(symbolsArray) {
    this.symbols = symbolsArray.concat(symbolsArray); // we want two of every item
    this.allButtons = [];
    this.lastButtonClicked = null;
  }

  shuffle() {
    for (let i = this.symbols.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      const temp = this.symbols[i];
      this.symbols[i] = this.symbols[j];
      this.symbols[j] = temp;
    }
  }

  build(parentElement) {
    document.body.innerHTML = '';
    this.shuffle();
    const rowSize = Math.floor(Math.sqrt(this.symbols.length));
    for (let i = 0; i < this.symbols.length; i++) {
      const button = document.createElement('input');
      button.type = 'button';
      button.setAttribute('secret-value', this.symbols[i]);
      button.value = ' ';
      button.onclick = (event) => this.turnOver(event);
      this.allButtons.push(button);
      document.body.appendChild(button);
      if ((i+1) % rowSize === 0) {
        const lineBreak = document.createElement('br');
        document.body.appendChild(lineBreak);
      }
    }
  }
  
  turnOver(event) {
    const button = event.target;
    if (this.lastButtonClicked === null) {
        this.allButtons.forEach(button => button.value = button.disabled ? button.value : ' ');
        this.lastButtonClicked = button;
    } else if (button === this.lastButtonClicked) {
        button.value = ' ';
        this.lastButtonClicked = null;
    } else {
        if (button.getAttribute('secret-value') === this.lastButtonClicked.getAttribute('secret-value')) {
            console.log('Match found!');
            button.disabled = true;
            this.lastButtonClicked.disabled = true;
        } else {
            console.log('No match!');
        }
        this.lastButtonClicked = null;
    }
    button.value = button.getAttribute('secret-value');
    if (this.gameIsSolved()) {
        alert('You did it! Game will reset.')
        this.build();
    }
  }
  
  gameIsSolved() {
    const remainingButtons = game.allButtons.filter(button => !button.disabled)
    return remainingButtons.length === 0;
  }

}

const alphabetArray = Array.from(Array(8).keys()).map(k => String.fromCharCode(k+65));
game = new SymbolMatchGame(alphabetArray);
game.build();
</script>
</body> 
</html>

(Note: I'm not expecting you to examine this code in detail; just illustrating what I mean when I talk about storing element references in the class and accessing them directly instead of via document.get* lookups)

I don't want this to be a style/"best practice" question that isn't appropriate for S/O, so I'm more looking for concrete information about whether my approach actually works the way I think it does. My question: what are the implications or side effects of what I'm doing? Is storing references to created elements inside my class instead of of a "just in time" document.get* lookup every time I want to access or modify them unsafe in some way, prone to side effects or stale references, or otherwise lacking implicit guarantees about document state, that might break things on me?


In general, you should always cache DOM elements when they're needed later, were you using OOP or not. DOM is huge, and fetching elements continuously from it is really time-consuming. This stands for the properties of the elements too. Creating a JS variable or a property to an object is cheap, and accessing it later is lightning-fast compared to DOM queries.

Many of the properties of the elements are deep in the prototype chain, they're often getters, which might execute a lot of hidden DOM traversing, and reading specific DOM values forces layout recalculation in the middle of JS execution. All this makes DOM usage slow. Instead, create a simplified JavaScript model of the page, and store all the needed elements and values to the model whenever possible.

A big part of OOP is just keeping up states, that's the key of the model too. In the model you keep up the state of the view, and access the DOM only when you need to change the view. Such a model will prevent a lot of "layout trashing", and it allows you to bind data to elements without actually revealing it in the global namespace (ex. Map object is a great tool for this). Nothing beats good encapsulation when you've security concerns, it's an effective way ex. to prevent self-XSS. Additionally, a good model is reusable, you can use it where ever the functionality is needed, the end-user just parametrizes the model when taken into use. That way the model is almost independent from the used markup too, and can also be developed independently (see also Separation of concerns).

A caveat of storing DOM elements into object properties (or into JS variables in general) is, that it's an easy way to create memory leaks. Such model objects are usually having long life-time, and if elements are removed from the DOM, the references from the object have to be deleted as well in order to get the removed elements being garbage-collected.

In practice this means, that you've to provide methods for removing elements, and only those methods should be used to delete elements. Additionally to the element removal, the methods should update the model object, and remove all the unused element references from the object.

It's notable, that when having methods handling existing elements, and specifically when creating new elements, it's easy to create variables which are stored in closures. When such a stored variable contains references to elements, they can't be removed from the memory even with the aforementioned removing methods. The only way is to avoid creating these closures from the beginning, which might be a bit easier with OOP compared to other paradigms (by avoiding variables and creating the elements directly to the properties of the objects).

As a sidenote, document.getElementsBy* methods are the worst possible way to get references to DOM elements. The idea of the live collection of the elements sounds nice, but the way how those are implemented, ruins the good idea.