Skip to content

Cancellation

The classic infinite-scroll bug is a response that lands after you cleared the list, quietly resurrecting rows the user already dismissed. ScrollStack closes that door twice: an AbortSignal cancels the real network work, and a generation counter makes any late result inert even when the fetcher ignores the signal.

Start a load in the demo and hit Reset before it lands. Watch what does not happen: no page is appended, failureCount stays 0, and no error is surfaced.

Cancellation & stale results
0fetches started
0signals aborted
0responses kept
0pages in state

Forwarding the signal

The engine hands signal to every fetchPage call. Passing it on is the whole job:

tsx
import { useInfiniteScroll } from '@scrollstackjs/react'
import { useEffect } from 'react'

export function Feed({ query }: { query: string }) {
  const { pages, ref, reset, engine } = useInfiniteScroll({
    initialPageParam: 0,
    // `signal` aborts on reset(), destroy(), and unmount.
    fetchPage: async ({ pageParam, signal }) => {
      const response = await fetch(`/api/search?q=${query}&page=${pageParam}`, { signal })
      if (!response.ok) throw new Error(`${response.status}`)
      return response.json()
    },
    getNextPageParam: (last) => last.nextCursor,
  })

  // Options are read once, at mount — so a changed query needs an explicit reset.
  useEffect(() => {
    reset()
  }, [query, reset])

  return <List pages={pages} sentinelRef={ref} />
}
vue
<script setup lang="ts">
import { useInfiniteScroll } from '@scrollstackjs/vue'
import { watch } from 'vue'

const props = defineProps<{ query: string }>()

const { state, target, reset } = useInfiniteScroll({
  initialPageParam: 0,
  fetchPage: async ({ pageParam, signal }) => {
    const response = await fetch(`/api/search?q=${props.query}&page=${pageParam}`, { signal })
    if (!response.ok) throw new Error(`${response.status}`)
    return response.json()
  },
  getNextPageParam: (last) => last.nextCursor,
})

// Aborts whatever is in flight and starts the new query from page one.
watch(
  () => props.query,
  () => reset(),
)
</script>
svelte
<script lang="ts">
  import { onDestroy } from 'svelte'
  import { createInfiniteScroll } from '@scrollstackjs/svelte'

  export let query: string

  const scroll = createInfiniteScroll({
    initialPageParam: 0,
    fetchPage: async ({ pageParam, signal }) => {
      const response = await fetch(`/api/search?q=${query}&page=${pageParam}`, { signal })
      if (!response.ok) throw new Error(`${response.status}`)
      return response.json()
    },
    getNextPageParam: (last) => last.nextCursor,
  })

  // Svelte re-runs this whenever `query` changes.
  $: query, scroll.reset()

  onDestroy(scroll.destroy)
</script>
ts
import { createInfiniteScroll } from '@scrollstackjs/core'

const scroll = createInfiniteScroll({
  initialPageParam: 0,
  fetchPage: async ({ pageParam, signal }) => {
    // Anything that takes an AbortSignal can be cancelled — not just fetch.
    const response = await fetch(`/api/items?page=${pageParam}`, { signal })
    if (!response.ok) throw new Error(`${response.status}`)
    return response.json()
  },
  getNextPageParam: (last) => last.nextCursor,
})

// Teardown: aborts in-flight work, disconnects observers, runs plugin cleanups.
window.addEventListener('pagehide', () => scroll.destroy())

What counts as a cancellation

ActionIn-flight requestfailureCounterror
reset()abortedreset to 0cleared
destroy()aborted
A genuine network failure+1set after retries

An abort is not a failure: it doesn't count toward retry, doesn't emit the error event, and doesn't call onError. If it did, switching a filter twice in a second would exhaust the retry budget of a list that never actually failed.

Custom fetchers

If you use something other than fetch, wire the signal yourself — most clients accept one (axios's signal, ky, undici). If yours cannot, the generation guard still protects state; you just don't get the network cancelled.

Next → Manual controls for reset() in context.