DEV Community

loading...
Cover image for Infinite Scrolling with Svelte 3, XState and IntersectionObserver

Infinite Scrolling with Svelte 3, XState and IntersectionObserver

Gustavo Castillo
I'm a web developer I won't fix your computer >:(
・3 min read

Introduction

A year ago I created a post related to infinite scrolling with Svelte and IntersectionObserver, this time I'm going to show you a similar project (we could say it's the second version) using XState.

If you want to try it out go to this Live Demo

Create the project folder

Execute these commands in your terminal one by one in order to use the Svelte template.

# Install a Svelte project using sveltejs/template
$ npx degit sveltejs/template infinite-scroll

# Change working directory
$ cd infinite-scroll

# Install npm dependencies
$ npm install

# Install XState
$ npm install xstate

# Run the dev server
$ npm run dev
Enter fullscreen mode Exit fullscreen mode

Create components

Create a folder called components inside src one, then create four components: Character.svelte, Footer.svelte, Header.svelte, and Loader.svelte, make sure you use Svelte's file extension, which is .svelte.

The components are basically the same as the previous post you can find the code here.

Create utility functions

Create a folter called lib inside src one, then create an index.js file, so let's implement some util functions to make our life easier:

export const transformCharacter = character => {
  const {
    id,
    name,
    status,
    species,
    created,
    image,
    gender,
    origin,
    location,
  } = character
  return {
    id,
    name,
    image,
    status,
    species,
    gender,
    created,
    origin: origin.name,
    location: location.name,
  }
}

// Format created date.
export const relativeTime = created => {
  const createdYear = new Date(created).getFullYear()
  const currentYear = new Date().getFullYear()
  const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
  return rtf.format(createdYear - currentYear, 'year')
}

// Util function to fetch the list of Characters.
export const fetchCharacters = async (context) =>  {
try {
    const endpoint = context.characters.length > 0 ? context.pageInfo.next : 'https://rickandmortyapi.com/api/character'
    const blob = await fetch(endpoint)
    const { results, info, error } = await blob.json()
    if (!blob.ok) {
      return Promise.reject(error)
    }
    const characters = results.map(result => transformCharacter(result))
    return Promise.resolve({ characters, info })
  } catch (error) {
    return Promise.reject(error.message)
  }
}
Enter fullscreen mode Exit fullscreen mode

Create the State Machine

In lib folder create a file called scrollMachine.js, and let's implement our state machine using XState.

import { createMachine, assign } from 'xstate'

const scrollMachine = createMachine({
  id: 'scrollMachine',
  context: {
    characters: [],
    pageInfo: {},
    error: '',
  },
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCH: {
          target: 'loading',
        },
      },
    },
    loading: {
      invoke: {
        id: 'characters',
        src: 'fetchCharacters',
        onDone: {
          target: 'success',
          actions: 'setCharacters',
        },
        onError: {
          target: 'failure',
          actions: 'setError',
        },
      },
    },
    loadMore: {
      invoke: {
        src: 'fetchCharacters',
        onDone: {
          target: 'success',
          actions: 'setMoreCharacters',
        },
        onError: {
          target: 'failure',
          actions: 'setError',
        },
      },
    },
    success: {
      on: {
        FETCH_MORE: {
          target: 'loadMore',
          cond: 'hasMoreCharacters',
        },
      },
    },
    failure: {
      type: 'final',
    },
  },
}, {
  guards: {
    hasMoreCharacters: ({ pageInfo }) => pageInfo.next,
  },
  actions: {
    setCharacters: assign({
      characters: (_, event) => event.data.characters,
      pageInfo: (_, event) => event.data.info,
    }),
    setMoreCharacters: assign({
      characters: ({ characters }, { data }) => [...characters, ...data.characters],
      pageInfo: (_, { data }) => data.info,
    }),
    setError: assign({
      error: (_, event) => event.data,
    }),
  },
})

export default scrollMachine
Enter fullscreen mode Exit fullscreen mode

Update App component

<script>
  import { onMount, onDestroy } from 'svelte'
  import { interpret } from 'xstate'
  import { fetchCharacters } from './lib'
  import scrollMachine from './lib/scrollMachine'
  import Header from './components/Header.svelte'
  import Loader from './components/Loader.svelte'
  import Character from './components/Character.svelte'
  import Footer from './components/Footer.svelte'

  // Let's pass the fetchCharacters function
  // as a service to our state machine.
  const machine = scrollMachine.withConfig({
    services: { fetchCharacters },
  })

  // Interpret our machine and start it ("create an instance").
  const service = interpret(machine).start()

  // Create options to our IntersectionObserver instance.
  let options = {
    root: document.getElementById('scrollArea'),
    rootMargin: '0px',
    threshold: 0.5,
  }

  // Handle intersection and send `FETCH_MORE` event to
  // our state machine if there are more characters to load.
  let observer = new IntersectionObserver(event => {
    const [entries] = event
    if (!entries.isIntersecting || !$service.context.pageInfo.next) {
      return
    }
    service.send({ type: 'FETCH_MORE' })
  }, options)

  // Fetch characters on component mounts.
  onMount(() => {
    service.send({ type: 'FETCH' })
    observer.observe(document.querySelector('footer'))
  })

  // Remove observer from our target to avoid potential memory leaks.
  onDestroy(() => {
    observer.unobserve(document.querySelector('footer'))
  })
</script>

<style>
  .container {
    min-height: 50vh;
    background-color: var(--text-color);
  }
  .inner {
    max-width: 80em;
    margin: 0 auto;
    padding: 3rem 0;
    display: grid;
    grid-gap: 20px;
    justify-items: center;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  }
  .loader,
  .error {
    padding-top: var(--padding-lg);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .error {
    color: var(--orange-color);
    font-size: 18px;
  }
</style>

<Header />
<section class="container" id="scrollArea">
  {#if $service.matches('success') || $service.matches('loadMore')}
    <div class="inner">
      {#each $service.context.characters as character (character.id)}
        <Character {character} />
      {/each}
    </div>
  {/if}
  {#if $service.matches('failure')}
    <div class="error"><span>{$service.context.error}</span></div>
  {/if}
  {#if $service.matches('loading') || $service.matches('loadMore')}
    <div class="loader">
      <Loader />
    </div>
  {/if}
  <Footer />
</section>
Enter fullscreen mode Exit fullscreen mode

Notes:
Rick and Marty API docs can be found: here
GitHub Repository: here
Youtube video (Spanish): here

Happy coding 👋🏽

Discussion (0)