If you’re making a real app™, you’re pretty likely to need some sort of paginated list. The two main ways people do this are simple pagination and infinite scrolling.
With Phoenix LiveView, pagination is pretty obvious. You just throw in a <.link patch={"?page=#{@page + 1}"}>}, make sure your handle_params event works, and Bob’s your uncle.
On the backend, infinite scrolling is also pretty simple. Use a stream instead of an assign, store a cursor in the socket or url, and load elements after the cursor in your query. On the frontend, however, you’re going to need some JavaScript.
The “official” solution
Since the year is 2025, and scroll events are stable, the obvious choice is to use phx-viewport-bottom. Here’s an example ripped directly from the linked documentation page
<ul
id="posts"
phx-update="stream"
phx-viewport-top={@page > 1 && "prev-page"}
phx-viewport-bottom={!@end_of_timeline? && "next-page"}
phx-page-loading
class={[
if(@end_of_timeline?, do: "pb-10", else: "pb-[calc(200vh)]"),
if(@page == 1, do: "pt-10", else: "pt-[calc(200vh)]")
]}
>
<li :for={{id, post} <- @streams.posts} id={id}>
<.post_card post={post} />
</li>
</ul>
<div :if={@end_of_timeline?} class="mt-5 text-[50px] text-center">
🎉 You made it to the beginning of time 🎉
</div>
Why I don’t like this
The InfiniteScroll hook is implemented using a scroll event. This is generally fine if you only ever want to use Phoenix’s scroll event handling or just want to have a big long empty space at the end, but if you want to have control over what exactly triggers loading new data, you’re out of luck. Scroll events also used to have major performance issues, as they used to completely block the main thread whenever the user scrolled
Enter: the Intersection Observer API
The Intersection Observer API is great. It’s very performant as it’s fully asynchronous and baked into the browser’s layout engine, has pretty fine control over when it gets triggered (for example, you can set it to fire at any given percentage on screen), automatically debounces itself and is really great to work with in comparison with doing maths on el.offsetHeight and figuring out where the scrolling is coming from (Is it window, document, or document.scrollingElement’s scrollTop value? It depends!)
My solution
Phoenix works really well with HTML custom elements. They’re a great way of sprinkling in a bit of interactivity on the largely server-driven liveview, work really well with the built in esbuild bundling and are going to add just about fuck all to your bundle size. A few years ago browser support was such that you’d have to think about using them and maybe think about polyfills but according to MDN they’ve been in all major browsers since Jan 2020 and with everything auto-updating these days I’d consider them stable and production-ready.
Anyway, enough story about how my grandmother used to make this stew — This is a simple custom element that triggers the click event on the first button it wraps. Because we’re just blindly calling the click event on said button, everything else from LiveView works just fine, including loading state, debouncing and optimistic updates.
class InfiniteScrollTrigger extends HTMLElement {
observer = new IntersectionObserver(([entry]) => this.trigger(entry), {
threshold: Number(this.getAttribute("data-intersection-ratio") ?? 1),
});
trigger(entry) {
this.querySelector("button")?.click();
}
connectedCallback() {
this.observer.observe(this);
}
}
customElements.define("infinite-scroll-trigger", InfiniteScrollTrigger);
// Usage:
<infinite-scroll-trigger>
<button phx-click="load_next_page" phx-disable-with="Loading..." disabled={@end_of_timeline?}>
Load more
</button>
</infinite-scroll-trigger>
In my opinion, this is better to work with. Want to block loading more? Disable the button. Reached the bottom? Just don’t render the whole block! The custom elements API handles all of the mounting and unmounting and morphdom won’t know or care.