Getting started
ScrollStack has no components and no styles. You give it three options, it gives you a snapshot, and you render that however you like.
1. Install
npm i @scrollstackjs/reactnpm i @scrollstackjs/vuenpm i @scrollstackjs/sveltenpm i @scrollstackjs/coreThe adapter pulls in @scrollstackjs/core for you — you never install it yourself.
2. Paste this
import { useInfiniteScroll } from '@scrollstackjs/react'
function Feed() {
const { pages, ref, isLoading, hasNextPage, isFetchingNextPage } = useInfiniteScroll({
initialPageParam: 0,
fetchPage: async ({ pageParam, signal }) =>
(await fetch(`/api/items?cursor=${pageParam}`, { signal })).json(),
getNextPageParam: (last) => last.nextCursor,
})
if (isLoading) return <p>Loading…</p>
return (
<ul>
{pages
.flatMap((page) => page.items)
.map((item) => (
<li key={item.id}>{item.name}</li>
))}
{hasNextPage && <li ref={ref}>{isFetchingNextPage ? 'Loading more…' : ''}</li>}
</ul>
)
}<script setup lang="ts">
import { useInfiniteScroll } from '@scrollstackjs/vue'
import { computed } from 'vue'
const { state, target } = useInfiniteScroll({
initialPageParam: 0,
fetchPage: async ({ pageParam, signal }) =>
(await fetch(`/api/items?cursor=${pageParam}`, { signal })).json(),
getNextPageParam: (last) => last.nextCursor,
})
const items = computed(() => state.value.pages.flatMap((page) => page.items))
</script>
<template>
<p v-if="state.isLoading">Loading…</p>
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
<li v-if="state.hasNextPage" :ref="target">
{{ state.isFetchingNextPage ? 'Loading more…' : '' }}
</li>
</ul>
</template><script lang="ts">
import { onDestroy } from 'svelte'
import { createInfiniteScroll } from '@scrollstackjs/svelte'
const scroll = createInfiniteScroll({
initialPageParam: 0,
fetchPage: async ({ pageParam, signal }) =>
(await fetch(`/api/items?cursor=${pageParam}`, { signal })).json(),
getNextPageParam: (last) => last.nextCursor,
})
const { target } = scroll
onDestroy(scroll.destroy)
</script>
{#if $scroll.isLoading}
<p>Loading…</p>
{:else}
<ul>
{#each $scroll.pages.flatMap((page) => page.items) as item (item.id)}
<li>{item.name}</li>
{/each}
{#if $scroll.hasNextPage}
<li use:target>{$scroll.isFetchingNextPage ? 'Loading more…' : ''}</li>
{/if}
</ul>
{/if}import { createInfiniteScroll } from '@scrollstackjs/core'
const scroll = createInfiniteScroll({
initialPageParam: 0,
fetchPage: async ({ pageParam, signal }) =>
(await fetch(`/api/items?cursor=${pageParam}`, { signal })).json(),
getNextPageParam: (last) => last.nextCursor,
})
// Runs on every change. Read the current state and paint it.
scroll.subscribe(() => {
const { pages, hasNextPage } = scroll.getSnapshot()
render(pages.flatMap((page) => page.items))
sentinel.hidden = !hasNextPage
})
scroll.observeTarget(sentinel)Two framework notes:
- Vue —
stateis a ref. In the template it unwraps by itself; in<script setup>writestate.value.pages. - Svelte — the returned object is a store, so
$scrollis the state. This is the one adapter where you clean up by hand:onDestroy(scroll.destroy).
3. What you get back
The fields you will actually use, most of the time:
| Field | Meaning |
|---|---|
pages | Every page loaded so far, in order. Flatten it yourself. |
isLoading | First page is loading and there is nothing to show yet. |
isFetchingNextPage | A later page is loading. Your rows are still on screen. |
hasNextPage | There is more to load. |
isError | The first load failed, so there is nothing to show. |
error | The last error, or null. Set on any failure. |
retry() | Try again after a failure. |
reset() | Empty the list and start over. |
loadNextPage() | Load the next page yourself, instead of scrolling. |
There are a few more (status, fetchStatus, pageParams, failureCount, isIdle, isSuccess, isFetching) — see the API reference.
The sentinel — one rule
ref / :ref="target" / use:target all do the same thing: they hand an element to the engine. When that element scrolls into view, the next page loads.
It must have a size
A <div> with nothing in it is 0px tall and never scrolls into view — so nothing ever loads. Give the sentinel text, a spinner, or a height.
Also: the trigger fires when the element enters view. If a loaded page is too short to push the sentinel back out of view, nothing fires again. Load enough rows per page to overflow the screen, or call loadNextPage() yourself.
Rendering the sentinel only while hasNextPage is true — as every example above does — gives you an end-of-list state for free.
Next
- Examples — running demos with full code: retries, virtual lists, horizontal rails, devtools.
- Pagination — cursor, offset and page numbers.
- Errors & retry — why a failed load more keeps your list on screen.
