DEMOS / Live Component

Infinite Scroll - 2/2

The second and final part of the Infinite Scroll Serie, with a new range of (lovely) T-Shirts!
Now with automatic loading on scroll, a new trick and amazing loading animations!

ux.symfony.com
🐻
0.99
🐨
1.99
🐼
2.99
🦥
3.99
🦦
4.99
🦨
5.99
🦘
6.99
🐾
7.99
🐓
8.99

So... how does it work?

Let's focus on two aspects. But first, did you scroll all the way to the end?
You might have missed some important details about performance... 🍿

Morphin' Trick

This trick is very similar to the one used in the Part 1/2 of this "Infinite Scroll" demo with LiveComponent.

Previous Results

🐯 For the previous page results, we add one fake item corresponding to the last item of the previous page, with the same id. This allows the addition of new elements after the existing ones.

1 <div class="ProductGrid" {{ attributes.defaults(stimulus_controller('appear')) }}>
2 
3     <div id="results" style="display: flex; gap: 1rem; flex-direction: column;" class="p-4">
4         <div class="ProductGrid_items">
5 
6             {# 🐯- Last result from previous page #}
7             {% if page > 1 %}
8                 <article id="item--{{ page - 1 }}-{{ per_page }}"></article>
9             {% endif %}

Current Page Results

🦊 For the current page results, we use both id and data-live-ignore. This allows the conservation of previous elements in the DOM.

11             {# 🦊 - Current page #}
12             {% for item in this.items %}
13                 <article
14                     id="item--{{ page }}-{{ loop.index }}"
15                     class="ProductGrid_item"
16                     data-live-ignore
17                     style="--i: {{ item.id }};"
18                 >
19                     <div class="ProductGrid_media">

Next Page

🐼 Finally, for the next page results, we add placeholders with an id.

This, combined with the "previous page" trick, force the order of insertion of the next elements

... with no div in between!

31                 {# 🐼 - Next page #}
32                 {% for i in 1..per_page %}
33                     <article id="item--{{ page + 1 }}-{{ i }}"
34                          class="ProductGrid_item"
35                          style="--i: {{ (page * per_page) + i - 1 }};"

Intersection Observer

We want to load the next results automatically when the user scrolls to the bottom of the page.

We create a small Stimulus controller that leverages the IntersectionObserver API to trigger a custom appear event when its target element becomes visible in the viewport.

By making a dedicated controller, we can keep the logic separate from the product grid. And we can reuse it in other components!

import { Controller } from '@hotwired/stimulus';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static targets = ['loader'];

    loaderTargetConnected(element) {
        this.observer ??= new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting) {
                    entry.target.dispatchEvent(new CustomEvent('appear', {detail: {entry}}));
                }
            });
        });
        this.observer?.observe(element);
    }

    loaderTargetDisconnected(element) {
        this.observer?.unobserve(element);
    }
}

LiveComponent + Stimulus

We now add the appear controller to our ProductGrid component, thanks to the stimulus_controller method.

1 <div class="ProductGrid" {{ attributes.defaults(stimulus_controller('appear')) }}>
2 
3     <div id="results" style="display: flex; gap: 1rem; flex-direction: column;" class="p-4">

Event -> LiveAction

Finally, we add the loader target to the first "item" placeholder we added for the next page.

🦁 And we configure the appear event to call the more action of the live controller, when the event is triggered.

Doing so, we can load the next page when the first item of the next page is visible.

With no need for a "Load More" button!

32                 {% for i in 1..per_page %}
33                     <article id="item--{{ page + 1 }}-{{ i }}"
34                          class="ProductGrid_item"
35                          style="--i: {{ (page * per_page) + i - 1 }};"
36 
37                         {# 🦁 - The trigger #}
38                         {% if loop.first %}
39                             data-appear-target="loader"
40                             data-action="appear->live#action"
41                             data-live-action-param="debounce(750)|more"
42                         {% endif %}
43 
44                     >
45                         <div class="ProductGrid_media">