javascriptaxiosalpine.js

Alpine.js x-data not reactive


I'm using Alpine.js to render a list of videos from an API. After making the API call, the response data populates my videos array successfully, but the page doesn’t update to show the new data.

Here's a breakdown of what’s happening:

I'm making an Axios API request and pushing the resulting videos to the videos array. I’ve confirmed via console log that videos is populated with the correct data. Despite this, the template does not render the updated array.

Thank you!

<div x-data="app" class="flex flex-wrap justify-between">
     <template x-for="video in videos" :key="video.id">
         <!--video content-->
         <div>Test Video</div>
     </template>
</div>
  const app = {
        triggerElement: null,
        page: 1,
        lastPage: 24,
        itemsPerPage: 24,
        observer: null,
        isObserverPolyfilled: false,
        videos: [],
        debug: true,
        loaded: false,

        init: function () {

            window.addEventListener('urlChange', () => {
                app.getItems();
            });

            app.triggerElement = document.querySelector('#infinite-scroll-trigger');

            document.addEventListener('DOMContentLoaded', function () {
              //  app.getItems();
                app.loaded = true;
            });

            app.infiniteScroll();

            (app.debug) ? console.log('init' )  : '';


        },
        infiniteScroll: function () {

            (app.debug) ? console.log('infiniteScroll' )  : '';

            // Check if browser can use IntersectionObserver which is waaaay more performant
            if (!('IntersectionObserver' in window) ||
                !('IntersectionObserverEntry' in window) ||
                !('isIntersecting' in window.IntersectionObserverEntry.prototype) ||
                !('intersectionRatio' in window.IntersectionObserverEntry.prototype)) {
                // Loading polyfill since IntersectionObserver is not available
                this.isObserverPolyfilled = true

                // Storing function in window so we can wipe it when reached last page
                window.alpineInfiniteScroll = {
                    scrollFunc() {
                        var position = app.triggerElement.getBoundingClientRect()

                        if (position.top < window.innerHeight && position.bottom >= 0) {
                            if (app.loaded) {
                                (app.debug) ? console.log('getItems 1' )  : '';

                                app.getItems();
                            }

                        }
                    }
                }

                window.addEventListener('scroll', window.alpineInfiniteScroll.scrollFunc)
            } else {
                // We can access IntersectionObserver
                this.observer = new IntersectionObserver(function (entries) {
                    if (entries[0].isIntersecting === true) {
                        if (app.loaded) {
                            (app.debug) ? console.log('getItems 2' )  : '';

                            app.getItems();
                        }
                    }
                }, {threshold: [0]})

                this.observer.observe(this.triggerElement)
            }
        },
        getItems: function () {
            // TODO: Do fetch here for the content and concat it to populated items
            // TODO: Set last page from API call - ceil it

            let currentURL = new URL(window.location.href);
            currentURL.hostname = 'api.' + currentURL.hostname;


            axios.post(currentURL, {
                page: this.page,
                perPage: this.itemsPerPage,

            })
                .then(response => {
                    const data = response.data;
                    //  this.lastPage = data.total_pages;
                    app.videos = data.data.videos;
                    //  this.page++;
                    (app.debug) ? console.log(response)  : '';

                })
                .catch(error => {
                    console.error('Error loading videos:', error);
                    (app.debug) ? console.log(error)  : '';
                    this.loading = false;
                });


            // Next page
            this.page++

            // We have shown the last page - clean up
            if (this.lastPage && this.page > this.lastPage) {
                if (this.isObserverPolyfilled) {
                    window.removeEventListener('scroll', window.alpineInfiniteScroll.scrollFunc)
                    (app.debug) ? console.log('alpineInfiniteScroll')  : '';

                } else if (this.observer && this.triggerElement) {
                    try {
                        this.observer.unobserve(this.triggerElement);
                    } catch (e) {
                        console.error('Failed to unobserve element:', e);
                    }
                }

                if (this.triggerElement && this.triggerElement.parentNode) {
                    this.triggerElement.parentNode.removeChild(this.triggerElement);
                    this.triggerElement = null; // Prevent further access to the removed element
                }

            }
        }
    };

None of these approaches resolved the issue. I'd appreciate any insight into why Alpine.js might not be re-rendering the updated data or any alternative solutions.


Solution

  • I tried to reproduce your issue on my local machine and ended up writing a fully working example. I hope it will help you to compare with your actual codebase and see what's wrong:

    index.html

    Make sure you wrap everything into div with x-data and x-init. You will also notice I've created a function that returns an app object with some changes to your code as follows in js example below. By defining init within your component and calling it via x-init, you ensure that your setup code runs when the component is initialized.

    <div x-data="app()" x-init="init">
      <!-- Video List -->
      <div id="video-list">
        <template x-for="video in videos" :key="video.id">
          <div class="video">
            <h3 x-text="video.title"></h3>
            <p x-text="video.description"></p>
          </div>
        </template>
      </div>
    
      <!-- Infinite Scroll Trigger -->
      <div id="infinite-scroll-trigger"></div>
    </div>
    

    app.js

    function app() {
      return {
        // Data properties
        triggerElement: null,
        page: 1,
        lastPage: null,
        itemsPerPage: 10,
        observer: null,
        isObserverPolyfilled: false,
        videos: [],
        debug: true,
        loading: false,
    
        // Initialization method
        init() {
          this.triggerElement = document.querySelector("#infinite-scroll-trigger");
          this.infiniteScroll();
    
          if (this.debug) console.log("Initialization complete.");
        },
    
        // Method to set up infinite scroll
        infiniteScroll() {
          if (this.debug) console.log("Setting up infinite scroll.");
    
          const supportsIntersectionObserver = "IntersectionObserver" in window;
    
          if (supportsIntersectionObserver) {
            // Use IntersectionObserver for better performance
            this.observer = new IntersectionObserver(
              this.handleIntersection.bind(this),
              {
                threshold: 0,
              }
            );
            this.observer.observe(this.triggerElement);
          } else {
            // Fallback for browsers without IntersectionObserver support
            this.isObserverPolyfilled = true;
            this.scrollFunc = this.handleScroll.bind(this);
            window.addEventListener("scroll", this.scrollFunc);
          }
        },
    
        // Handler for IntersectionObserver
        handleIntersection(entries) {
          if (entries[0].isIntersecting && !this.loading) {
            if (this.debug)
              console.log("Trigger element intersected. Loading items...");
            this.getItems();
          }
        },
    
        // Handler for scroll event (polyfill)
        handleScroll() {
          const position = this.triggerElement.getBoundingClientRect();
    
          if (
            position.top < window.innerHeight &&
            position.bottom >= 0 &&
            !this.loading
          ) {
            if (this.debug)
              console.log("Trigger element visible. Loading items...");
            this.getItems();
          }
        },
    
        // Method to fetch items from the mock API
        async getItems() {
          if (this.loading) return; // Prevent multiple calls
          this.loading = true;
    
          try {
            const response = await axios.get("http://localhost:3000/videos", {
              params: {
                _page: this.page,
                _limit: this.itemsPerPage,
              },
            });
    
            const totalItems = response.headers["x-total-count"];
            this.lastPage = Math.ceil(totalItems / this.itemsPerPage);
    
            this.videos = this.videos.concat(response.data);
    
            if (this.debug) console.log(`Loaded page ${this.page}.`, response.data);
    
            this.page++;
    
            // Check if we've reached the last page
            if (this.page > this.lastPage) {
              this.cleanup();
            }
          } catch (error) {
            console.error("Error loading videos:", error);
          } finally {
            this.loading = false;
          }
        },
    
        // Cleanup method to remove observers and event listeners
        cleanup() {
          if (this.isObserverPolyfilled && this.scrollFunc) {
            window.removeEventListener("scroll", this.scrollFunc);
            if (this.debug) console.log("Removed scroll event listener.");
          }
    
          if (this.observer) {
            this.observer.disconnect();
            if (this.debug) console.log("Disconnected IntersectionObserver.");
          }
    
          if (this.triggerElement) {
            this.triggerElement.remove();
            this.triggerElement = null;
            if (this.debug) console.log("Removed trigger element.");
          }
        },
      };
    }
    

    Key updates to JavaScript file: