Let's scroll to infinity!
Tags: elixir, phoenix, javascript, webdev
Published: 2021-09-24 09:42:54

The feed exercise. At this point in time, most apps have some kind of infinite scrollable feed to keep users interested.

The project I will be developing on here is written in Elixir, and uses the Phoenix Framework and liveview to create a webapp.

### The why

Jascrafts is a project based knitting app I have created for my wife. When finishing a project, the users (my wife and her friends), can add some data about the project they finished, and optionally add an image. Furthermore, they can choose to share the picture on a feed, that the other users can see.

As the use of the feed grew, it was apparent that I could not just pull out all projects and show them, since that list would be long. And I hate the pages solution where you have to click a next page button.

Therefore: the infinite scroll! Luckily, Elixir is a very pragmatic language, and adding such a feature should not be too difficult.

### The behind end

The first query I had looked something like this

```elixir
defp feed_query() do
    from p in ProjectDetail,
      where: p.is_public == true,
      join: sp in assoc(p, :project),
      order_by: [desc: sp.finished_at],
      preload: [:project]
  end
```

When rendering this on the front-end, there is nothing more than a simple loop and renders each element as it comes into view.

The basic idea of the back-end pagination is to fetch a pages worth of data, + 1 element.

```elixir
def feed(%Jascrafts.Feed.Pagination{
        page: page,
        pr_page: pr_page
      }) do
    data = feed_query(page, pr_page) |> Repo.all()
    has_next_page = Enum.count(data) == pr_page + 1

    %{page: Enum.take(data, pr_page), has_next: has_next_page}
  end

  defp feed_query(page, pr_page) do
    from p in ProjectDetail,
      where: p.is_public == true,
      join: sp in assoc(p, :project),
      order_by: [desc: sp.finished_at],
      offset: ^((page - 1) * pr_page),
      limit: ^pr_page + 1,
      preload: [:project]
  end
```

First, let's take a look on the feed query, this now has an offset and limit. The offset part `page -1 * pr_page` will make sure we only take out data from a specific point in our database. The limit is set to `pr_page + 1`. 

Let's assume I have a `pr_page` of 12. If I can fetch `> 12` elements out, I know that there is at least one more page of data, even if that next page only has 1 element. If I get `<= 12` elements out, I know that I am on the last page.

With that logic we can compose this knowledge in the feed function. 

### The ahead end

Now this is where it gets sticky. If you don't know, a Phoenix app is server rendered.

Our object here is to listen to front-end events of a page end, and when we are within a specific scroll position, fetch more elements. But I don't want too much of this logic on the front-end.

Liveview hooks is the way to go. To set up a hook, we need a bit of javascript to create the frontend listener.

[JavaScript interoperability](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks)

```jsx
let Hooks = {};

let scrollAt = () => {
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  let scrollHeight =
    document.documentElement.scrollHeight || document.body.scrollHeight;
  let clientHeight = document.documentElement.clientHeight;

  return (scrollTop / (scrollHeight - clientHeight)) * 100;
};

Hooks.InfiniteScroll = {
  page() {
    return this.el.dataset.page;
  },
  mounted() {
    this.pending = this.page();
    window.addEventListener("scroll", (e) => {
      if (this.pending == this.page() && scrollAt() > 90) {
        this.pending = this.page() + 1;
        this.pushEvent("load-more", {});
      }
    });
  },
  updated() {
    this.pending = this.page();
  },
};
```

![alt text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vtw1sy75kltxgcikfzfc.png)

Above picture shows the container of my feed in the rendered HTML. Notice the data-page field. This is what glues it together with above JavaScript, and when the `scrollAt` position hits 90%, it will trigger the `load-more` and push that event over the liveview web socket connection.

To receive this event on the back-end, we need to implement a `handle_event` function.

```elixir
@impl Phoenix.LiveView
def handle_event("load-more", _, %{assigns: assigns} = socket) do
  {:noreply, socket |> assign(page: assigns.page + 1) |> fetch()}
end

defp fetch(%{assigns: %{page: page, pr_page: per, has_next: true}} = socket) do
  %{page: projects, has_next: has_next} = Feed.feed(%Pagination{page: page, pr_page: per})
  assign(socket, projects: projects, has_next: has_next)
end

defp fetch(socket) do
  socket
end
```

A lot of stuff here, and some of it I will leave to the reader to understand. The important part is that the handle event function triggers on the load-more event that is being sent via the JS snippet we wrote earlier. 

Now that we are back in Elixir land, we can do all the smart things we want. In this case, we fetch an additional page of data, and send it back over the LiveView socket connection. 

The cool thing about this is that the infinite scroll trigger has nothing to do with data. At this point, for any infinite scrollable list I would be inclined to create, all that I needed to do, was to implement the `handle_event("load-more")` function, and do the specific use-case stuff there.