/assets/svelte.jpg

How to create a lazy loading list in SvelteKit

Motivation

Imagine you’re having a blog with a long list of posts you want to be able to display to your audience on a single page. You would have to call all of the data at once and display all of it immediately.

This practice works for a small amount of posts, but not for a lot. The load times would increase per post and you might never be able to reach your websites footer, without first scrolling for half an hour.

The arguably best way to handle this is by creating a list of elements, that requests more data, if certain criteria are reached. For example if you scroll at the end of the list, or if a specific item is visible. This will reduce load times dramatically and you don’t have to scroll like a maniac, if you keep this list in a container of its own.

Implementation

To implement this we need to setup our project first. For this we can simple run

bash
npm init svelte@next my-app

We go for TypeScript but you’re free to choose whatever settings you fancy. Other than that we like Stylus so we add this as well

While using Stylus we always add

bash
npm i -D stylus

To our styles. This changes the usual default font size of 16px to 10px. REM values will be using this as reference, so a value of 1.6rem would be exactly 16px. This is to work more easily on screens with high pixel density.

css
:root
font-size 62.5%

We need a place to fetch our posts from. In this tutorial we’ll be using Ghost CMS to do so. To make our lives with this a little easier we can use a package

bash
npm i @tryghost/content-api

To make this work we essentially need 4 files

  • a page under routes where we want to use our List
  • a List component
  • the lists items
  • a writable svelte store to handle our data

The page with our List component

A great way to think about how to implement something is by thinking about how you want to use it. That’s why we start with the usage of our list, our page.

html
<script lang="ts">
import postsStore from '$lib/stores/ghostcms/postsStore'
import List from '$lib/components/List/index.svelte'
import PostListItem from '$lib/components/List/PostListItem/index.svelte'
</script>
<main>
<List
posts={$postsStore.data}
noMorePosts={$postsStore.noMoreData}
loading={$postsStore.loading}
key="id"
let:item={{
title,
excerpt,
slug,
feature_image,
}}
on:loadMore={postsStore.fetchMore}
>
<PostListItem slot="item" image={feature_image} {title} {excerpt} {slug} />
</List>
</main>
<style lang="stylus">
main
margin 0 auto
padding 2rem
min-height calc(100vh)
max-width unquote("min(100%, 80rem)")
</style>

We need a Svelte store to handle our data for us, so we import it. We want our List component and things to show inside, a PostListItem. So we import both of them as well. Because we want our List to be as flexible as possible, the component is responsible to distribute the stores data to its items. So we need to give our List component a posts property which is subscribed to our store. To handle loading and the possibility of having no more posts to load, we need to add two more properties to our List component, noMorePosts and loading.

With our List component we directly pass data to whatever is defined in its slot. Here it is our PostListItem. To make this behavior possible we need to define a property variable

js
let:item={{
title,
excerpt,
slug,
feature_image,
}}

Whatever props our posts items have we can pass in here and we add these props to our PostListItem. The PostListItem will be placed in a named slot, called “item”. To know when to reload more post we need a way to know when to do so. We use an action, on:loadMore, for this, which calls our store to update itself.

The dynamically fetching Svelte store

You API can be any API, that returns some data, where you can decide how much data you want to retrieve. We’ll go for Ghost with this tutorial.

js
import { writable } from 'svelte/store'
import GhostContentAPI, { PostsOrPages } from '@tryghost/content-api'
let loading = false
let noMoreData = false
let page = 1
let data: PostsOrPages = [] as PostsOrPages
const list = writable({
loading,
data,
noMoreData,
})
const api = new GhostContentAPI({
url: `${import.meta.env.VITE_API_URL}`,
key: `${import.meta.env.VITE_CONTENT_API_KEY}`,
version: 'v3',
})
export default {
subscribe: list.subscribe,
async fetchMore() {
if (loading || noMoreData) return
loading = true
const response = await api.posts.browse({
limit: 15,
page: page++,
})
loading = false
noMoreData = response.length === 0
data.push(...response)
list.set({ loading, data, noMoreData })
},
}

So at first we set some dynamic variables we decided to use within our List component.

  • loading (indicates if we’re currently fetching data)
  • noMoreData (tells us, that there’s no more data to load)
  • data (these are the posts we will be fetching) These will be directly passed as props to our writable store
js
const list = writable({
loading,
data,
noMoreData,
})

The page variable is used to tell our API what “set” of posts we want. For example with a set size of 15 we would have post 1 to 15 for page 1 and 16 to 30 for page 2. After we’ve initialized our GhostContentAPI

js
const api = new GhostContentAPI({
url: `${import.meta.env.VITE_API_URL}`,
key: `${import.meta.env.VITE_CONTENT_API_KEY}`,
version: 'v3',
})

we can start by creating our store containing object, which we will be importing.

js
export default {
subscribe: list.subscribe,
async fetchMore() {
// TODO: add the fetching magic
},
}

Because we have store and we want to be able to listen to upgrades we need to add the subscribe method of our list store, other than that we need an async fetchMore method, we decided to use in the on:loadMore action of our List component.

While this fetchMore method is supposed to be loading new posts into our writable store, we don’t want it to keep on requesting. So we catch return if we’re either loading or there is no more data to be fetched.

js
async fetchMore() {
if (loading || noMoreData) return
// TODO: the actual fetching
},

We can easily fetch pages by using the GhostContentAPI. Before and after the actual fetching we have to set the loading variable we defined earlier. If our responses’ length is 0, we don’t have more posts to fetch. We add our newly fetched data to our array and afterwards we set our stores content to its new values.

js
loading = true
const response = await api.posts.browse({
limit: 3,
page: page++,
})
loading = false
noMoreData = response.length === 0
data.push(...response)
list.set({ loading, data, noMoreData })

With “limit” we’re setting our set size to 3, with using “page++” as value for our “page” we make sure, that we don’t fetch the same set multiple times.

The dynamic List component

At first we declare all properties we passed to our List component as variables.

To trigger the on:loadMore action we will be using createEventDispatcher. So we’ll be creating one, that will be called directly in an onMount method.

To trigger this method on demand we need to create a way for demand to be manifested. In our handleListScroll function we will be doing exactly this.

While scrolling our List component, we simply check if a specific position above the end has been reached. This will reduce the felt loading times of additional list items. For the template we call our previously created handleListScroll function on:scroll of our scrolling container.

Then, for each post we received in our posts props the item in the slot named item, which is defined by our key, will be receiving the current iterations properties.

html
<script lang="ts">
import type { PostOrPage } from '@tryghost/content-api'
import { createEventDispatcher, onMount } from 'svelte'
export let posts: Array<PostOrPage>
export let noMorePosts: boolean
export let loading = false
export let key: string
const dispatch = createEventDispatcher()
onMount(() => {
dispatch('loadMore')
})
function handleListScroll(
e: UIEvent & {
currentTarget: EventTarget & HTMLUListElement
},
) {
if (
e.currentTarget.clientHeight + e.currentTarget.scrollTop >=
e.currentTarget.scrollHeight - 300
) {
dispatch('loadMore')
}
}
</script>
<ul on:scroll={handleListScroll}>
{#if posts?.length}
{#each posts as item (item[key])}
<li><slot name="item" {item} /></li>
{/each}
{/if}
{#if !noMorePosts && loading}
<li class="loader">Loading...</li>
{/if}
</ul>
<style lang="stylus">
ul
display flex
justify-content center
flex-flow wrap
margin 0 auto
padding 1rem 0
gap 2rem
max-width 4 * 30rem + 3 * 2rem
max-height calc(100vh - var(--header-height) - var(--footer-height))
overflow-y auto
-ms-overflow-style none /* IE and Edge */
scrollbar-width none /* Firefox */
&::-webkit-scrollbar
display none /* Chrome, Safari and Opera */
li
list-style none
position relative
max-width 30rem
min-width 100%
min-height 30rem
max-height 34rem
@media (min-width: 342px)
min-width 28rem
&.loader
max-height 5rem
min-height 5rem
display flex
justify-content center
align-items center
min-width 100%
</style>

The List Item

The PostListItem is only for displaying our posts. There is nothing special about it. The props are what we passed in the page we added our List component to. Because we’re showing blog posts we have an anchor tag, which redictects there. Prefetching is absolutely recommended. It’s awesome.

html
<script>
export let title = ''
export let excerpt
export let slug
export let image
</script>
<article>
<a href={`/posts/${slug}`} sveltekit:prefetch>
<img src={image} alt={title} />
<div>
<h2>{title}</h2>
<p>{excerpt}</p>
</div>
</a>
</article>
<style lang="stylus">
article
max-height 100%
height 100%
display block
background-color #333
border-radius 1rem
a
display block
text-decoration none
img
width 100%
height 18.225rem
object-fit cover
padding 1rem
border-radius 2rem
div
padding 0.5rem
h2,
p
text-overflow ellipsis
white-space nowrap
overflow hidden
color #fff
@supports(display: -webkit-box)
white-space inherit
display -webkit-box
-webkit-line-clamp 3
-webkit-box-orient vertical
h2
margin-top 1rem
@supports(display: -webkit-box)
-webkit-line-clamp 2
</style>

Wrapping up

You have seen how you can create a dynamic list component, which receives its data from a dynamically fetching Svelte store. This is a great thing if you have a lot of data to show, but you don’t want to render all of it at once. One downside to this might be, that you don’t expose all of your links this way to search engines. There is a great workaround for this called sitemap. You can learn about how to implement one here.