Manual Recipes Infinite Scroll Feed

Infinite Scroll Feed

elements man recipes/infinite-scroll-feed Read as markdown

A feed page that shows the most recent posts at the top, prepends new posts live as they arrive, and pages older posts in as the user scrolls down. Two primitives compose: a LiveTable opened as a window (orderBy: "createdAt desc", limit: 20) holds the loaded posts in order and knows whether more exist, and an IntersectionObserver on a sentinel element at the bottom of the feed calls posts.more() when it scrolls into view.

The window is a keyset cursor over (createdAt, id). A new post sorts before the loaded range, so it is admitted live and placed at the top. Older posts arrive a page at a time from more(), after the last row already loaded, so a flood of new posts during a long scroll neither shifts nor duplicates what the user is reading.

Migration

elements create migration 'add posts' -tables=posts

app/migrations/<timestamp>-add-posts.migration.sql:

-- add posts

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table posts (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  userName text not null,
  body text not null
);

create index postsCreatedAtIdx on posts (createdAt desc, id desc);

create trigger postsTouchUpdatedAt
  before update on posts
  for each row execute function touchUpdatedAt();

postsCreatedAtIdx matches the window's order. orderBy: "createdAt desc" is always tie-broken on id, so every page reads order by createdAt desc, id desc and the keyset clause on later pages walks the same index. Without it, every "load more" would scan the table.

Page setup

elements create page feed

The Post interface and the posts LiveTable are used only by the /feed page, so they live in the page's own services.ts. The route opens the window; the template renders it as one list with a sentinel at the bottom that loads the next page.

app/pages/feed/services.ts:

import { LiveTable } from "@elements/app";

export interface Post {
  id: string;
  createdAt: Date;
  userName: string;
  body: string;
}

export let posts = new LiveTable<Post>();

export const PAGE_SIZE = 20;

Nothing to configure. Auto-SQL handles the select, the ordering, and each later page; the order and page size are decided where the view is opened.

app/pages/feed/index.ts:

import feed from "./template";
import { posts, PAGE_SIZE } from "./services";

export default function route(req, res) {
  return new feed({
    posts: posts.view({}, { orderBy: "createdAt desc", limit: PAGE_SIZE }),
  });
}

view(partition, window). The partition is empty because the feed is the whole table. The window loads the newest PAGE_SIZE rows, holds them in that order, and carries hasMore. The first page is server-rendered into the HTML, so the first paint shows posts, and the browser receives a live, subscribed view of the same window.

app/pages/feed/template.html:

import "./style.css";
import { LiveView } from "@elements/app";
import { Post } from "./services";

function onSentinelInsert(el: HTMLElement, posts: LiveView<Post>) {
  let observer = new IntersectionObserver((entries) => {
    let entry = entries[0];

    if (!entry) {
      return;
    }
    if (!entry.isIntersecting) {
      return;
    }

    posts.more();
  });

  observer.observe(el);
}

function onCompose(posts: LiveView<Post>, userName: string, body: { value: string }) {
  if (userName.trim().length === 0) {
    return;
  }
  if (body.value.trim().length === 0) {
    return;
  }

  posts.insert(
    { userName: userName.trim(), body: body.value },
    () => body.value = "",
  );
}

<html class="feed"
      (posts: LiveView<Post>,
       private userName: string = "",
       private body: { value: string } = { value: "" })>
  <main>
    <h1>feed</h1>

    <form onsubmit={() => onCompose(posts, userName, body)}>
      <input type="text" value={userName} placeholder="your name" required>
      <input type="text" value={body.value} placeholder="say something" required>
      <button type="submit">post</button>
    </form>

    <ul class="feed">
      <li e:for={p of posts}>
        <strong>{p.userName}</strong>
        <span class="body">{p.body}</span>
      </li>
    </ul>

    <div e:if={posts.hasMore}
         class="sentinel"
         oninsert={(e) => onSentinelInsert(e.target as HTMLElement, posts)}>
      loading more…
    </div>

    <p e:else class="end">you've reached the bottom.</p>
  </main>
</html>

One iteration. The view already holds every loaded post in window order, newest first, so the e:for iterates posts directly and a page from more() extends the same list.

onSentinelInsert runs on the sentinel <div>'s oninsert and attaches an IntersectionObserver. When the sentinel scrolls into view, posts.more() loads the next page: the server runs the same ordered query after the last loaded row and the rows join the view. A second call while one is in flight joins it, so a sentinel that stays visible through a slow page does not double-load.

posts.hasMore is reactive. It flips to false when a page comes back short, the sentinel unmounts, and the observer goes with it. The e:else then shows the end marker.

The composer at the top uses posts.insert(...). The optimistic row has no createdAt yet; a missing sort key sorts as newest, so it lands at the top, and the server's now() replaces it when the broadcast arrives. The broadcast then reaches every other browser's view, where it sorts before the loaded range and is placed at the top too.

Routes

Register the page in index.ts:

import feed from "#app/pages/feed";

// ...
app.route("/feed", feed);

Notes

  • Keyset over offset. The cursor is the sort key of the last loaded row, (createdAt, id), not a row count, so new inserts at the top during a long scroll do not shift the older boundary. The user keeps reading the posts they expected.

  • Admission. A row arriving over the wire joins the view if it sorts before the last loaded row, or if hasMore is false. A backfilled post with an old createdAt stays out until the user scrolls to where it belongs, which is what a chronological feed should do.

  • Auth. This recipe is open-access for clarity. To require sign-in, add insert: (item) => { session.isLoggedInOrThrow(); return posts.insert(item); } to the declaration (which then needs the explicit LiveTable<Post> annotation, since it refers to itself) and session.isLoggedInOrThrow() at the top of the route handler.

  • Display a timestamp? Pass one at insert. If the feed shows {formatTime(p.createdAt)}, pass createdAt: new Date() in the insert payload so the optimistic row has a value to render. The server's now() reconciles it a moment later.

  • A join or projection still pages. A custom select receives the window and renders its three clauses, so a hand-written query honors the same order and cursor:

    select: (partition, w) => sql<Post>(
      `select p.*, u.handle as author
       from posts p join users u on u.id = p.userId
       where ${w.keyset("p")}
       order by ${w.order("p")} ${w.page()}`,
    ),
    

    keyset is the cursor condition (true on the first page), order the ORDER BY list, page the LIMIT.

  • Resetting the observer. The e:if={posts.hasMore} on the sentinel unmounts it when the feed ends. IntersectionObserver instances tied to unmounted elements are eligible for GC; explicit observer.disconnect() isn't required for the recipe pattern.