Search code examples
javascripthtmlajaxlivereloadinsertadjacenthtml

ajax reload ul/li div and retain event listeners (other than insertAdjacentHTML)


Goal: reload only the div after ajax calls (add or remove tags) without losing event listeners on the reloaded div. Using insertAdjacentHTML does not seem optimal.

Front-end: I have a sidebar with ul/li for tags. There's a modal to add/create tags; there is an icon "X" next to each tag to remove it on click.

<ul>
  <li><span>A</span><span class=li-untag>X</span> 
  <li><span>B</span><span class=li-untag>X</span> 
  <li><span>C</span><span class=li-untag>X</span> 
  <li><span>D</span><span class=li-untag>X</span>
</ul>

My failed setup was:

  1. send an ajax (fetch) call to the php back-end to add or remove tags

  2. if no error, the back-end sends an updated and twig-formatted list of tags for the article

  3. updated the div with innerHTML.

The innerHTML update would lose all the event listeners of the list (such as remove tag when clicking X) until a real refresh of the page. I tried to run the addeventlisteners after the ajax call but it didn't work.

My current setup based on previous SO answers (see below) uses insertAdjacentHTML. That (sort of) works, but it's clunky.

  • to remove a tag is not an insert issue and all I could think of was to hide a tag on click until the page is refreshed.
  • to add tags with insertAdjacentHTML is to append new tags to the list, but it's no longer alphabetically ordered and I have to use some hacky javascript to format the output to match the existing list.

I would really prefer having php send a whole updated and formatted list for the div.

Any suggestions for a more elegant way to do this in vanilla js?

FYI: The main answers I have relied on:
Why can event listeners stop working after using element.innerHTML?
Is it possible to append to innerHTML without destroying descendants' event listeners?
If a DOM Element is removed, are its listeners also removed from memory?
JS eventListener click disappearing

Edit: the listener is attached to the "li-untag" class

Edit 2: answering the comment request for some more code. Here is for the removing tag, using the atomic ajax library:

for(let liUntag of document.querySelectorAll('.li-untag')){
liUntag.addEventListener("click", () => altUntag(liUntag))};

const altUntag = (el) => {
    atomic("/tags", {
        method: 'POST',
        data: {
            type: 'untag',
            slug: el.getAttribute('data-slug'),
            tag: el.getAttribute('data-untag')
        }
    }).then(function (response) {
        el.closest('li').style.display = 'none';
        console.log(response.data); // xhr.responseText
        console.log(response.xhr);  // full response
    })
    .catch(function (error) {
        console.log(error.status); // xhr.status
        console.log(error.statusText); // xhr.statusText
    });
};

Solution

  • The best solution for something like this would be to use event delegation to attach a single listener to the ul instead of attaching multiple listeners to each li-untag:

    document.querySelector('ul').addEventListener('click', ({ target }) => {
      if (target.matches('.li-untag')) {
        target.closest('li').remove();
      }
    });
    

    If you don't want to do that and want to keep a listener on each li-untag element, you can insert the new HTML, then sort the <li>s, and attach listeners to each new li-untag:

    const addListeners = () => {
      document.querySelectorAll('.li-untag').forEach((span) => {
        span.addEventListener('click', ({ target }) => {
          target.closest('ul').remove();
        });
      });
    };
    
    // on document load:
    addListeners();
    
    // after inserting new HTML:
    const lis = [...document.querySelectorAll('ul li')];
    lis.sort((a, b) => a.children[0].textContent.localeCompare(b.children[0].textContent));
    const ul = document.querySelector('ul');
    lis.forEach((li) => {
      ul.appendChild(li);
    });
    addListeners();