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. 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 < cursorrather thanoffset Nmeans 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
olderarray holds the static pages. Concatenating them in the template gives one continuous scroll. New inserts fromposts.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 andsession.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.IntersectionObserverinstances tied to unmounted elements are eligible for GC; explicitobserver.disconnect()isn't required for the recipe pattern.