Domphy

Infinite Scroll

Combine @domphy/virtual (virtualizer) with @domphy/query (infinite query) to render millions of items without DOM bloat.

Basic setup

import { QueryClient } from "@domphy/query"
import { createVirtualizer } from "@domphy/virtual/domphy"
import { createInfiniteQuery } from "@domphy/query/domphy"
import { toState, computed } from "@domphy/core"

interface Post { id: string; title: string }
interface Page { posts: Post[]; nextCursor: string | null }

const queryClient = new QueryClient()

// Infinite query: loads pages of data
const feed = createInfiniteQuery<Page>(queryClient, {
  queryKey: () => ["feed"],
  queryFn: ({ pageParam }) => fetchPosts(pageParam as string),
  initialPageParam: "",
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})

// Flatten pages into a single list
const allPosts = computed((l): Post[] =>
  feed.data(l)?.pages.flatMap(p => p.posts) ?? []
)

const isFetchingMore = computed((l) => feed.isFetchingNextPage(l))
const hasMore = computed((l) => feed.hasNextPage(l))

// Virtualizer
const container = toState<HTMLElement | null>(null)
const virtualizer = createVirtualizer({
  count: (l) => allPosts.get(l).length + (hasMore.get(l) ? 1 : 0),   // +1 for loader row
  estimateSize: () => 72,
  getScrollElement: () => container.get(),
})

Detecting scroll-to-bottom

Trigger fetchNextPage() when the last item is visible:

import { effect } from "@domphy/core"

effect(() => {
  const items = virtualizer.getVirtualItems()
  if (!items.length) return

  const lastItem = items[items.length - 1]
  const total = allPosts.get().length
  const isLast = lastItem.index >= total - 1

  if (isLast && hasMore.get() && !feed.isFetchingNextPage()) {
    feed.fetchNextPage()
  }
})

Rendering the virtual list

const FeedList = {
  div: [
    {
      div: (l) => {
        const items = virtualizer.getVirtualItems(l)
        const total = virtualizer.getTotalSize(l)

        return {
          div: items.map(virtualItem => {
            const post = allPosts.get()[virtualItem.index]
            const isLoader = !post

            return {
              _key: virtualItem.key,
              div: isLoader
                ? { div: "Loading more…", style: { padding: "1rem", textAlign: "center" } }
                : PostCard(post),
              style: {
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
                height: `${virtualItem.size}px`,
              },
            }
          }),
          style: { position: "relative", height: `${total}px` },
        }
      },
    },
  ],
  style: {
    height: "600px",
    overflowY: "auto",
    position: "relative",
  },
  _onMount: (el) => container.set(el),
}

Dynamic heights

If post heights vary (different content lengths), use measureElement:

const virtualizer = createVirtualizer({
  count: () => allPosts.get().length,
  estimateSize: () => 80,   // initial estimate
  measureElement: (el) => el.getBoundingClientRect().height,
  getScrollElement: () => container.get(),
})

// In the item render, attach the measurement ref:
const PostItem = (post: Post, virtualItem: VirtualItem) => ({
  div: [
    { h3: post.title },
    { p: post.excerpt },
  ],
  _onMount: (el) => virtualizer.measureElement(el),   // actual height replaces estimate
  _key: virtualItem.key,
  style: {
    position: "absolute",
    top: 0,
    transform: `translateY(${virtualItem.start}px)`,
    width: "100%",
  },
})

Scroll restoration

Restore scroll position when navigating back:

import { createRoute } from "@domphy/router"

const feedRoute = createRoute({
  path: "/feed",
  component: () => FeedList,
  // Save scroll position before leaving
  onLeave: () => {
    sessionStorage.setItem("feed-scroll", String(container.get()?.scrollTop ?? 0))
  },
  // Restore on return
  onEnter: () => {
    const saved = sessionStorage.getItem("feed-scroll")
    if (saved) {
      requestAnimationFrame(() => {
        container.get()?.scrollTo({ top: Number(saved) })
      })
    }
  },
})

Initial data

Pre-populate the first page to avoid a loading flash:

const queryClient = new QueryClient()

const feed = createInfiniteQuery<Page>(queryClient, {
  queryKey: () => ["feed"],
  queryFn: ({ pageParam }) => fetchPosts(pageParam as string),
  initialPageParam: "",
  getNextPageParam: (page) => page.nextCursor ?? undefined,
  initialData: {
    pages: [firstPageFromSSR],
    pageParams: [""],
  },
})

Scroll to top

Provide a "Back to top" button:

const BackToTop = {
  button: "↑ Back to top",
  onClick: () => {
    container.get()?.scrollTo({ top: 0, behavior: "smooth" })
    virtualizer.scrollToIndex(0, { behavior: "smooth" })
  },
  hidden: (l) => (container.get()?.scrollTop ?? 0) < 300,
}