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. Three primitives compose: a LiveTable keeps the first page live (broadcasts new inserts), an @rpc returns older pages one at a time, and an IntersectionObserver on a sentinel element at the bottom of the feed fires the next-page rpc when it scrolls into view.

The recipe uses a single posts table. Newest posts go in via LiveTable.insert; older posts come back via SQL with where createdAt < <cursor>. The cursor is the createdAt of the oldest post the browser currently has, so paging never duplicates or skips rows even if new posts arrive during a long scroll.

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);

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

postsCreatedAtIdx is the index the feed query reads off. Without it, every "load more" scan would walk the table.

Page setup

elements create page feed

The Post interface, the live first page, and the loadOlder rpc are used only by the /feed page, so they live in the page's own services.ts. The route passes the LiveTable; the template renders the live and older slices in one continuous scroll with an intersection-observer sentinel that fires loadOlder as the user scrolls.

app/pages/feed/services.ts:

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

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

const PAGE_SIZE = 20;

export let posts = new LiveTable<Post>({
  sort: "createdAt desc",
  select: () => sql<Post>(
    `select id, createdAt, userName, body from posts
     order by createdAt desc
     limit ${PAGE_SIZE}`,
  ),
});

/** @rpc */
export function loadOlder(before: Date): Post[] {
  return sql<Post>(
    `select id, createdAt, userName, body from posts
     where createdAt < ${before}
     order by createdAt desc
     limit ${PAGE_SIZE}`,
  ).all();
}

The custom select on the LiveTable returns the most recent PAGE_SIZE posts on first render. After that, LiveTable.insert broadcasts new rows to every browser watching, and each browser prepends them to its in-memory view; the table grows past the initial limit as new posts arrive.

loadOlder(before) takes the cursor (the oldest post the browser currently shows) and returns the next batch of older posts. Cursor-based paging is reliable in a real-time feed: inserts at the top don't shift older offsets, so a user scrolling down never sees a duplicate or a gap from new posts arriving while they read.

app/pages/feed/index.ts:

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

export default function route(req, res) {
  return new feed({ posts });
}

The route passes the LiveTable. The first page is server-rendered into the HTML, so the first paint shows posts. The browser receives a live, subscribed handle to the same table.

app/pages/feed/template.html:

import "./style.css";
import { LiveTable } from "@elements/app";
import { Post, loadOlder } from "./services";

function onSentinelInsert(el: HTMLElement, posts: LiveTable<Post>, older: { value: Post[] }, done: { value: boolean }) {
  let observer = new IntersectionObserver((entries) => {
    let entry = entries[0];

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

    onLoadMore(posts, older, done);
  });

  observer.observe(el);
}

function onLoadMore(posts: LiveTable<Post>, older: { value: Post[] }, done: { value: boolean }) {
  if (done.value) {
    return;
  }

  let cursor = older.value.length > 0
    ? older.value[older.value.length - 1].createdAt
    : posts.length > 0
      ? posts.at(posts.length - 1)!.createdAt
      : new Date();

  let batch = loadOlder(cursor);

  if (batch.length === 0) {
    done.value = true;
    return;
  }

  older.value = [...older.value, ...batch];
}

function onCompose(posts: LiveTable<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: LiveTable<Post>,
       private userName: string = "",
       private body: { value: string } = { value: "" },
       private older: { value: Post[] } = { value: [] },
       private done: { value: boolean } = { value: false })>
  <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>
    <li e:for={p of older.value}>
      <strong>{p.userName}</strong>
      <span class="body">{p.body}</span>
    </li>
  </ul>

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

  <p e:if={done.value} class="end">you've reached the bottom.</p>
</html>

Two iterations in the feed list: the LiveTable holds the most-recent slice (live, auto-prepended), and older holds the older pages loaded on demand. Concatenating them in render order keeps the visual order continuous.

onSentinelInsert runs on the sentinel <div>'s oninsert and attaches an IntersectionObserver. When the sentinel scrolls into view, the observer fires onLoadMore. The sentinel disappears once done flips, which also stops the observer naturally because the element is removed from the DOM.

onLoadMore picks the cursor from the oldest row currently on the page: the last entry in older if any have loaded, otherwise the last entry in posts. An empty result means the feed is exhausted and the sentinel hides.

The composer at the top uses posts.insert(...). The optimistic local insert shows the new post immediately; the broadcast then reaches every other browser's LiveTable and prepends there too.

Routes

Register the page in index.ts:

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

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

Notes

  • Cursor over offset. Using where createdAt < cursor rather than offset N means a flood of new inserts at the top during a long scroll doesn't shift the older boundary. The user keeps reading the same older posts they expected.
  • Live + paged in one feed. The LiveTable always holds the live, recent slice. The older array holds the static pages. Concatenating them in the template gives one continuous scroll. New inserts from posts.insert(...) only affect the live half.
  • Auth. This recipe is open-access for clarity. To require sign-in, add insert: { auth: (item, s) => s.isLoggedIn() } to the LiveTable config and session.isLoggedInOrThrow() at the top of the route handler.
  • Server-side rendering of the first page. The route serializes the LiveTable's initial result into the HTML response, so the first paint shows posts. No empty state, no fetching spinner on mount, no layout shift.
  • Resetting the observer. The e:if={!done.value} 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.