Svelte Infinite Loader designed and rebuilt specifically for use with Svelte 5
β¨ Flexible
β° Infinite Loop Detection
π£ Control Loader State
π IntersectionObserver
based
π₯ Using Runes and Snippets
π§βπ§ Demo: svelte-5-infinite.vercel.app
Svelte 5 is still early days, but I couldn't find an infinite loader-type component that was maintained for the last few years of Svelte 4 even. So I had recently built this for a Svelte 5-based application I'm working on and was pretty happy with it, so I decided to share it with the world!
As Svelte 5 inevitably changes over the next weeks and months, I plan to keep this package updated and working with the latest available version of Svelte 5. Don't hesitate to open an issue if something has changed in the latest Svelte releases or you come across a bug!
svelte-infinite
npm install svelte-infinite
pnpm install svelte-infinite
yarn add svelte-infinite
InfiniteLoader
and loaderState
from svelte-infinite
<script lang="ts">
import { InfiniteLoader, loaderState } from "svelte-infinite"
const allItems = $state([])
const loadMore = async () => {
const res = fetch("...")
const data = await jes.json()
allItems.push(...data)
loaderState.loaded()
}
</script>
<InfiniteLoader triggerLoad={loadMore}>
{#each allItems as user (user.id)}
<div>{user.name}</div>
{/each}
</InfiniteLoader>
This is a more realistic example use-case which includes a paginated data endpoint that your triggerLoad
function should hit every time it's called to load more data. It also includes the use of some of the optional snippets to render custom markup inside the loader component.
<script lang="ts">
// +page.svelte
import { InfiniteLoader, loaderState } from "svelte-infinite"
import UserCard from "$components/UserCard.svelte"
const LOAD_LIMIT = 20
// Assume `$page.data.items` is the `+page.server.ts` server-side loaded
// and rendered initial 20 items of the list
const allItems = $state<{ id: number, body: string }[]>($page.data.items)
let pageNumber = $state(1)
// 1. This `loadMore` function is what we'll pass the InfiniteLoader component
// to its `triggerLoad` prop.
const loadMore = async () => {
try {
pageNumber += 1
const limit = String(LOAD_LIMIT)
const skip = String(LOAD_LIMIT * (pageNumber - 1))
// If there are less results on the first page (page.server loaded data)
// than the limit, don't keep trying to fetch more. We're done.
if (allItems.length < LOAD_LIMIT) {
loaderState.complete() // <--- using loaderState
return
}
const searchParams = new URLSearchParams({ limit, skip })
// Fetch an endpoint that supports server-side pagination
const dataResponse = await fetch(`/api/data?${searchParams}`)
// Ideally, like most paginated endpoints, this should return the data
// you've requested for your page, as well as the total amount of data
// available to page through
if (!dataResponse.ok) {
loaderState.error() // <--- using loaderState
// On errors, set the pageNumber back so we can retry
// that page's data on the next 'loadMore' attempt
pageNumber -= 1
return
}
const data = await dataResponse.json()
// If we've successfully received data, push it to the reactive state variable
if (data.items.length) {
allItems.push(...data.items)
}
// If there are more (or equal) number of items loaded as are totally available
// from the API, don't keep trying to fetch more. We're done.
if (allItems.length >= data.totalCount) {
loaderState.complete() // <--- using loaderState
} else {
loaderState.loaded() // <--- using loaderState
}
} catch (error) {
console.error(error)
loaderState.error() // <--- using loaderState
pageNumber -= 1
}
}
</script>
<main class="container">
<!-- 2. Here you wrap your items with the InfiniteLoader component -->
<InfiniteLoader triggerLoad={loadMore}>
{#each allItems as user (user.id)}
<UserCard {user} />
{/each}
<!-- 3. There are a few optional snippets for customizing what is shown at the bottom
of the scroller in various states, see the 'Snippets' section for more details -->
{#snippet loading()}
Loading...
{/snippet}
{#snippet error(load)}
<div>Error fetching data</div>
<button onclick={load}>Retry</button>
{/snippet}
</InfiniteLoader>
</main>
</script>
This package consists of two parts, first the InfiniteLoader
component which is a wrapper around your items. It will trigger whichever async function you've passed to the triggerLoad
prop when the user scrolls to the bottom of the list.
Second, there is also a loaderState
import which you should use to interact with the internal state of the loader. For example, if your fetch
call errored, or you've reached the maximum number of items, etc. you can communicate that to the loader. The most basic usage example can be seen in the 'Getting Started' section above. A more complex example can be seen in the 'Example' section, and of course the application in /src/routes/+page.svelte
in this repository also has a "real-world" usage example.
loaderState
ControllerThe loaderState
controller has 4 methods on it. You should call these at the appropriate times to control the internal state of the InfiniteLoader
.
loaderState.loaded()
READY
so another fetch can be attempted.loaderState.error()
InfiniteLoader
to render a "Retry" button by default, or the error
snippet.loaderState.complete()
noData
snippet.loaderState.reset()
InfiniteLoader
to its initial state, for example if there is a search input tied to your data and the user enters a new query.InfiniteLoader
PropstriggerLoad: () => Promise<void>
- requiredintersectionOptions:
IntersectionObserverInit
= { rootMargin: "0px 0px 200px 0px" }
- optionalIntersectionObserver
instance. See MDN for more details. The default rootMargin
value will cause the target to intersect 200px earlier and trigger the loadMore
function before it actually intersects with the root element (window by default). This has the effect of beginning to load the next page of data before the user has actually reached the current bottom of the list, making the experience feel more smooth.overflow-y: scroll
) other than the window / viewport, then it might be necessary for you to also pass a custom root
element here.loopTimeout: number = 3000
- optionalloopDetectionTimeout: number = 2000
- optionalloopMaxCalls
count must be hit in order to trigger a cool down period.loopMaxCalls: number = 5
- optionaltriggerLoad
executions which will trigger a cool down period, if reached within the loopDetectionTimeout
.InfiniteLoader
SnippetsSnippets replace slots in Svelte 5, and as such are used here to customize the content shown at the bottom of the scroller in various states. The InfiniteLoader
component has 5 snippet "slots" available.
loading
triggerLoad
and waiting on a response.noResults
noData
loaderState.complete()
is called, indicating we've fetched and displayed all available data.coolingOff
loaderState !== "COMPLETE"
and a loop has been detected. Will disappear and loopTimeout
when the cooling off period expires.error
loaderState.error()
has been called. The snippet has an attemptLoad
parameter passed to it which is just the internal triggerLoad
function, designed for a "Retry" button or similar.MIT