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
:rootfont-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><Listposts={$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">mainmargin 0 autopadding 2remmin-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 = falselet noMoreData = falselet page = 1let data: PostsOrPages = [] as PostsOrPagesconst 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) returnloading = trueconst response = await api.posts.browse({limit: 15,page: page++,})loading = falsenoMoreData = response.length === 0data.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 = trueconst response = await api.posts.browse({limit: 3,page: page++,})loading = falsenoMoreData = response.length === 0data.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: booleanexport let loading = falseexport let key: stringconst 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">uldisplay flexjustify-content centerflex-flow wrapmargin 0 autopadding 1rem 0gap 2remmax-width 4 * 30rem + 3 * 2remmax-height calc(100vh - var(--header-height) - var(--footer-height))overflow-y auto-ms-overflow-style none /* IE and Edge */scrollbar-width none /* Firefox */&::-webkit-scrollbardisplay none /* Chrome, Safari and Opera */lilist-style noneposition relativemax-width 30remmin-width 100%min-height 30remmax-height 34rem@media (min-width: 342px)min-width 28rem&.loadermax-height 5remmin-height 5remdisplay flexjustify-content centeralign-items centermin-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 excerptexport let slugexport 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">articlemax-height 100%height 100%display blockbackground-color #333border-radius 1remadisplay blocktext-decoration noneimgwidth 100%height 18.225remobject-fit coverpadding 1remborder-radius 2remdivpadding 0.5remh2,ptext-overflow ellipsiswhite-space nowrapoverflow hiddencolor #fff@supports(display: -webkit-box)white-space inheritdisplay -webkit-box-webkit-line-clamp 3-webkit-box-orient verticalh2margin-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.