# Infinite Scroll Feed 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 ```bash elements create migration 'add posts' -tables=posts ``` `app/migrations/-add-posts.migration.sql`: ```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 ```bash 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 `template.ehtml` and the route imports them from there. The route opens the window; the template renders it as one list with a sentinel at the bottom that loads the next page. 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`: ```ts import { Request, Response } from "@elements/app"; import feed, { posts, PAGE_SIZE } from "./template"; export default function route(req: Request, res: Response) { 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.ehtml`: ```ehtml import "./style.css"; import { LiveTable, LiveView } from "@elements/app"; export interface Post { id: string; createdAt: Date; userName: string; body: string; } export let posts = new LiveTable(); export const PAGE_SIZE = 20; function onSentinelInsert(el: HTMLElement, posts: LiveView) { 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, 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 = "", ); } , private userName: string = "", private body: { value: string } = { value: "" }, )>

feed

onCompose(posts, userName, body)}>
  • {p.userName} {p.body}
onSentinelInsert(e.target as HTMLElement, posts)}> loading more…

you've reached the bottom.

``` 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 `
`'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`: ```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` 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: ```ts select: (partition, w) => sql( `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.