Infinite Scroll Feed
elements man recipes/infinite-scroll-feed Read as markdownA 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
hasMoreis false. A backfilled post with an oldcreatedAtstays 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 explicitLiveTable<Post>annotation, since it refers to itself) andsession.isLoggedInOrThrow()at the top of the route handler. -
Display a timestamp? Pass one at insert. If the feed shows
{formatTime(p.createdAt)}, passcreatedAt: new Date()in the insert payload so the optimistic row has a value to render. The server'snow()reconciles it a moment later. -
A join or projection still pages. A custom
selectreceives 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()}`, ),keysetis the cursor condition (trueon the first page),orderthe ORDER BY list,pagethe LIMIT. -
Resetting the observer. The
e:if={posts.hasMore}on the sentinel unmounts it when the feed ends.IntersectionObserverinstances tied to unmounted elements are eligible for GC; explicitobserver.disconnect()isn't required for the recipe pattern.