Blog

Part 55 · Build Infinite Scroll in React

Build a list that loads more posts as you scroll, step by step. Cursors, a Load more button, IntersectionObserver, one request at a time, errors, focus, windowing and the feed role.

This is the last “build it yourself” part. A machine coding interview gives you a small app to build while someone watches. A common task is infinite scroll. It’s a list that loads more items as you scroll down, with no page numbers to click.

It uses many earlier parts. Fetching from Part 13, and Effects that clean up from Part 12. Refs from Part 14, and the useOnScreen hook from Part 17. Windowing from Part 35. ARIA and the keyboard from Part 46 and Part 47.

We build it in eight steps, each an app you can run. Then we look at what an interviewer checks.

Try this first

Before any scrolling, a question: how should the app ask the server for “more”? Here are two ways, side by side. Read the code, but don’t press Run yet.

import { useState } from 'react'

type Post = { id: number; text: string }

// This pretends to be the server's list of posts. The newest post is first.
let allPosts: Post[] = []
for (let id = 12; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}
const PAGE_SIZE = 4

// Way 1: ask for page 1, page 2, page 3, and so on.
function getPage(page: number) {
  const start = (page - 1) * PAGE_SIZE
  return allPosts.slice(start, start + PAGE_SIZE)
}

// Way 2: ask for the posts that are older than the last one you have.
function getOlderThan(lastId: number | null) {
  return allPosts.filter(p => lastId === null || p.id < lastId).slice(0, PAGE_SIZE)
}

function List({ title, posts }: { title: string; posts: Post[] }) {
  return (
    <div>
      <h3>{title}</h3>
      <ul>
        {posts.map(p => (
          <li key={p.id}>{p.text}</li>
        ))}
      </ul>
    </div>
  )
}

export default function App() {
  const [page, setPage] = useState(1)
  const [byPage, setByPage] = useState(() => getPage(1))
  const [byCursor, setByCursor] = useState(() => getOlderThan(null))

  function addPost() {
    const id = allPosts[0].id + 1
    allPosts = [{ id, text: `Post ${id}` }, ...allPosts]
  }

  function loadMore() {
    setByPage([...byPage, ...getPage(page + 1)])
    setPage(page + 1)
    const last = byCursor[byCursor.length - 1]
    setByCursor([...byCursor, ...getOlderThan(last.id)])
  }

  return (
    <div>
      <button onClick={addPost}>A new post arrives</button>{' '}
      <button onClick={loadMore}>Load more</button>
      <div style={{ display: 'flex', gap: 32 }}>
        <List title="By page number" posts={byPage} />
        <List title="By cursor" posts={byCursor} />
      </div>
    </div>
  )
}

Both lists start with the newest 4 posts, 12 to 9. Now make a guess. You press “A new post arrives”, then “Load more”. What will each list show?

Press Run and try it.

The left list shows Post 9 twice. The right list goes on with Post 8, as it should. The Console shows a warning from React:

Encountered two children with the same key, `9`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior is unsupported and could change in a future version.

The new post, 13, is in neither list. That’s fine: it belongs at the top, not at the bottom. Step 1 explains what went wrong on the left.

The task, as an interviewer gives it

An interviewer often reads out something like this:

  1. Show a list of posts from a server, 20 at a time.
  2. When the user gets near the end, load the next 20. Don’t load anything twice.
  3. Show when it is loading, when the server fails, and when there is nothing more.
  4. Keyboard and screen reader users must be able to use it.
  5. It must stay fast with thousands of posts.

Ask questions first. Each answer changes the code:

  • Can new posts arrive while the user reads? Can posts be deleted?
  • Does the server give page numbers, or something else?
  • Should it load by itself, or only when the user asks?
  • Is there anything under the list, like a footer with links?
  • How long can the list get?

Here are our answers. Yes, posts come and go. The server can give a cursor. Load by itself, but keep a button too. There is a footer. The list can get to thousands.

Step 1: a pretend server, and why cursors

There is no real server in this page. Our server is a small function that pretends.

The left list asks by page number: “give me page 2”. With 4 posts per page, the server skips the first 4. But a new post arrived at the top, and everything moved down by one. Post 9 is now 5th, the first post of page 2. We got it twice.

The right list asks with a cursor. A cursor is a marker that says where you stopped. Here, it’s the id of the last post we have: “give me the posts older than Post 9”. New posts at the top don’t change which posts are older than 9. So nothing repeats.

Deleting causes the opposite problem. We added a button that deletes Post 10 after the first page is shown. Then “Load more” by page number skipped Post 8. It moved back onto page 1, which we had already loaded. The cursor list went on with Post 8, 7, 6 and 5.

Page numbers are fine for a list that never changes. We checked: with no new post, both lists were the same after two more loads. For a feed that changes while people read, use a cursor.

An everyday example

You are reading a long book, and someone puts new pages in at the front. If you remember “I was on page 40”, you come back to the wrong place. A bookmark is a piece of paper that marks your page. If you left one there, you come back to the right place. A page number counts from the start. A cursor is the bookmark.

The exact version

A cursor must be a value that keeps its order. Our posts are sorted by id, newest first, so “older than 9” means “id below 9”.

getOlderThan asks for ids below 9, so it works even if Post 9 itself was deleted. The cursor can’t be a position: “start after the 4th post” is just a page number.

From now on, the pretend server works with cursors. It takes after, the id of the last post we have, or null for the first page. It answers with 20 posts and next, the cursor for the request after that. When there is nothing more, next is null. It waits half a second, and logs every request, so we can count them.

Step 2: a Load more button

Start with something small that works: a button. It is easy to build, and easy for every user, as step 8 will show.

import { useEffect, useState } from 'react'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPosts(after: number | null): Promise<Page> {
  console.log('Asking for posts after', after)
  return new Promise(resolve => {
    setTimeout(() => {
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [loading, setLoading] = useState(true)
  const [done, setDone] = useState(false)

  useEffect(() => {
    let ignore = false
    fetchPosts(null).then(page => {
      if (ignore) return
      setPosts(page.posts)
      setNext(page.next)
      setDone(page.next === null)
      setLoading(false)
    })
    return () => {
      ignore = true
    }
  }, [])

  async function loadMore() {
    setLoading(true)
    const page = await fetchPosts(next)
    setPosts(old => [...old, ...page.posts])
    setNext(page.next)
    setDone(page.next === null)
    setLoading(false)
  }

  return (
    <div>
      <ul>
        {posts.map(p => (
          <li key={p.id}>{p.text}</li>
        ))}
      </ul>
      {!done && <button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</button>}
    </div>
  )
}
Asking for posts after null
Asking for posts after null
Asking for posts after 31

Run it. At first the button says “Loading…”. After half a second, the first 20 posts appear, and the button says “Load more”. Press it. It says “Loading…” for half a second, then 20 more posts appear under the first ones. Press it once more. The last 10 posts appear. Now next is null, so done is true, and the button goes away.

The first page comes from an Effect, with the ignore flag from Part 13. Look at the Console: Asking for posts after null shows twice. That is Strict Mode, as Part 13 explained. React runs the Effect, cleans it up, and runs it again. The first answer is ignored. We counted: with Strict Mode on, 2 requests and 20 posts. With it off, 1 request and 20 posts.

The next pages come from the click, not from an Effect. loadMore asks for the posts after next. Then it adds them to the end with [...old, ...page.posts].

Step 3: load by itself, with IntersectionObserver

The old way was to listen for scroll events. On every event, your code measured where the end of the list was. MDN explains why that is slow. That code “runs on the main thread”, where the page does all its other work too.

The browser has a better tool: IntersectionObserver, from Part 17. You give it a tag to watch. It does the measuring itself, and calls your function only when the tag comes into view or leaves it.

So we put an empty <div>, 1 pixel tall, after the last post. It is called a sentinel: a guard who watches. When it comes near the bottom edge, we load more. New posts go above it.

import { useEffect, useEffectEvent, useRef, useState } from 'react'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPosts(after: number | null): Promise<Page> {
  console.log('Asking for posts after', after)
  return new Promise(resolve => {
    setTimeout(() => {
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [loading, setLoading] = useState(false)
  const [done, setDone] = useState(false)
  const boxRef = useRef<HTMLDivElement>(null)
  const sentinelRef = useRef<HTMLDivElement>(null)

  async function loadMore() {
    setLoading(true)
    const page = await fetchPosts(next)
    setPosts(old => [...old, ...page.posts])
    setNext(page.next)
    setDone(page.next === null)
    setLoading(false)
  }

  const onSentinelSeen = useEffectEvent(() => {
    if (!loading && !done) loadMore()
  })

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (sentinel === null) return
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) onSentinelSeen()
      },
      { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
    )
    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [posts.length])

  return (
    <div>
      <div ref={boxRef} style={{ height: 300, overflowY: 'auto', border: '1px solid gray' }}>
        <ul style={{ margin: 0 }}>
          {posts.map(p => (
            <li key={p.id} style={{ height: 40 }}>
              {p.text}
            </li>
          ))}
        </ul>
        {!done && <div ref={sentinelRef} style={{ height: 1 }} />}
      </div>
      <p>
        {posts.length} posts. {loading ? 'Loading…' : ''}
      </p>
      {!done && <button onClick={loadMore}>Load more</button>}
    </div>
  )
}

Run it, and scroll down inside the bordered box. Watch the Console. Each new “Asking for posts” line comes a little before you reach the end.

Here is what’s new.

  • The Effect makes an observer that watches the sentinel. It calls us back when the sentinel goes in or out of range. entries[0].isIntersecting is true when the sentinel is in range.
  • root: boxRef.current says “in range of this box”. Without a root, the observer uses the window of the top page. In the result box, that’s this lesson page’s window.
  • rootMargin: '0px 0px 200px 0px' makes the box count as 200 pixels taller at the bottom. The four numbers are top, right, bottom and left, like CSS margins. So the call comes 200 pixels before the sentinel can be seen.
  • The cleanup calls disconnect(), which stops the observer. Strict Mode makes, removes and makes the observer again on the first mount. We counted 1 request at the start, with Strict Mode on and with it off.
  • useEffectEvent, from Part 17, gives the observer a function that always sees the newest loading, done and next.
1. The first 20 posts are in the list. The sentinel sits under the last one. 2. You scroll down 300 px. The sentinel enters the 200 px margin. 3. The observer calls you back. One request goes out: posts after 31. 4. 20 more posts arrive and go under the old ones. The sentinel moves down. 5. At 1,100 px the sentinel enters the margin again: posts after 11. Posts 50 to 31 Posts 30 to 11 sentinel boxmargin the box's scrollTop 0 requests to the server 1. posts after null 2. posts after 31 20 posts arrive, 500 ms later 3. posts after 11 the box: 300 px you can see rootMargin: 200 px more the sentinel, 1 px tall

The sentinel enters the margin, a request goes out, and the sentinel moves down. Press play, or step through it.

The same steps, in words. We measured them in Chromium, Firefox and WebKit, in a frame like this page’s result box. All three gave the same numbers.

  1. The page opens. The observer sees the sentinel at once, in the empty list, and asks for the first page. 20 posts of 40 pixels make 800 pixels, and the sentinel sits under them.
  2. You scroll down. At scrollTop 300 the sentinel is 200 pixels below what you can see. It has entered the margin.
  3. The observer calls back, and one request goes out: posts after 31.
  4. Half a second later, 20 posts arrive. They go under the old ones, and the sentinel moves down by 800 pixels. scrollTop was still 300. Adding below changes nothing you can see.
  5. At scrollTop 1,100 the sentinel enters the margin again, and the last page is asked for. After it, done is true and the sentinel is gone. That was 3 requests in all, for 50 posts.

Why the Effect runs again for each page

The array is [posts.length], so React makes a new observer after each page. A new observer reports at once whether the sentinel is in range. That matters when a page is too short to push the sentinel out of range. We made the box 900 pixels tall. With [posts.length], two pages loaded at once, and the box was full. With [], the list stopped at 20 posts. The sentinel never left the range, so the old observer never had anything new to report.

In the result box, the root matters

The result box under each example is a small page inside this page. It is a sandboxed frame: the browser gives it fewer rights. Its origin, the site it belongs to, is null. Part 37 showed one thing that changes because of that. Here is another.

We tried the app with and without a root, and wrote down when the second request went out. “Gap” is how far the sentinel was below what you could see at that moment.

Setup Where it ran Gap when the request went out
root is the box, rootMargin 200 px result box 200 px
no root, list in the box result box 0 px
no root, list in the box normal page 0 px
no root, no box (the page scrolls) result box 0 px
no root, no box (the page scrolls) normal page, 500 px tall 200 px

Three things went wrong without a root.

First, the margin grows only the root, which here is the window. The W3C rule says so too. The box still hides what is below its own edge.

Second, in the result box, the margin was ignored. The rule says root margins “are only applied when handling same-origin-domain targets”. That means tags in a page with the same origin as the top page. The result box’s origin is null, so it never matches. In a normal page, the same code asked 200 pixels early.

Third, without a root, the result box’s app watches the lesson page’s window. We made that window 300 pixels tall, so the result box was partly below it. Then the app sent no second request at all. With the box as root, it asked 200 pixels early, as always.

So give the observer the box that scrolls as its root. Then the margin works everywhere.

Step 4: one request at a time

Go back to step 2’s app. Press “Load more”, then press it again while it says “Loading…”. We did it in our test, 100 ms apart. The server got “Asking for posts after 31” twice. The list had 60 posts, and posts 30 to 11 showed twice. React printed the same-key warning 20 times, once for each repeated post.

Both clicks used the same next, because the first answer hadn’t come back yet. A click while the first page was loading asked for the first page a third time. The list had 40 posts, with 20 repeats.

The fix: remember that a request is on its way, and say no to a second one.

import { useEffect, useRef, useState } from 'react'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPosts(after: number | null): Promise<Page> {
  console.log('Asking for posts after', after)
  return new Promise(resolve => {
    setTimeout(() => {
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [loading, setLoading] = useState(true)
  const [done, setDone] = useState(false)
  const busy = useRef(true)

  useEffect(() => {
    let ignore = false
    fetchPosts(null).then(page => {
      if (ignore) return
      setPosts(page.posts)
      setNext(page.next)
      setDone(page.next === null)
      setLoading(false)
      busy.current = false
    })
    return () => {
      ignore = true
    }
  }, [])

  async function loadMore() {
    if (busy.current) return
    busy.current = true
    setLoading(true)
    const page = await fetchPosts(next)
    setPosts(old => [...old, ...page.posts])
    setNext(page.next)
    setDone(page.next === null)
    setLoading(false)
    busy.current = false
  }

  return (
    <div>
      <ul>
        {posts.map(p => (
          <li key={p.id}>{p.text}</li>
        ))}
      </ul>
      {!done && <button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</button>}
    </div>
  )
}
Asking for posts after null
Asking for posts after null
Asking for posts after 31

Run it, and press the button as fast as you can. One request per page, and no repeats. We pressed it five times in a row: one request. The two lines for null are Strict Mode again, as in step 2.

busy starts as true, because the first page is already on its way. Only the answer that is used sets it back to false.

Why a ref, and not the loading state? A state change shows up only in the next render. React renders between two clicks, so a second click sees the new state. But two calls in the same task see the old state, like two observers that call back together. A ref changes at once. We saw this in the browser with two observers. More on that under Common mistakes.

This isn’t the ref trick that Part 14 warned about. That trick covered up Strict Mode’s second setup. Here, the first page still uses the ignore cleanup, and Strict Mode still asks twice. The ref only stops two loads at the same time.

Data libraries do the same. The TanStack Query guide says an infinite list can have only “a single ongoing fetch”. Its example loads the next page only when it is not already fetching.

Step 5: loading, errors and the end

A real server sometimes fails. Our pretend server can fail on purpose: press “Make the next request fail”, and the next request fails. This step adds the busy ref to step 3’s app, and one status in place of loading and done.

import { useEffect, useEffectEvent, useRef, useState } from 'react'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }
type Status = 'idle' | 'loading' | 'error' | 'done'

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second. It can fail once, on purpose.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}
let failNext = false

function fetchPosts(after: number | null): Promise<Page> {
  console.log('Asking for posts after', after)
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (failNext) {
        failNext = false
        reject(new Error('The server did not answer.'))
        return
      }
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [status, setStatus] = useState<Status>('idle')
  const busy = useRef(false)
  const boxRef = useRef<HTMLDivElement>(null)
  const sentinelRef = useRef<HTMLDivElement>(null)

  async function loadMore() {
    if (busy.current) return
    busy.current = true
    setStatus('loading')
    try {
      const page = await fetchPosts(next)
      setPosts(old => [...old, ...page.posts])
      setNext(page.next)
      setStatus(page.next === null ? 'done' : 'idle')
    } catch {
      setStatus('error')
    }
    busy.current = false
  }

  const onSentinelSeen = useEffectEvent(() => {
    if (status === 'idle') loadMore()
  })

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (sentinel === null) return
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) onSentinelSeen()
      },
      { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
    )
    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [posts.length])

  return (
    <div>
      <button onClick={() => (failNext = true)}>Make the next request fail</button>
      <div ref={boxRef} style={{ height: 300, overflowY: 'auto', border: '1px solid gray', marginTop: 8 }}>
        <ul style={{ margin: 0 }}>
          {posts.map(p => (
            <li key={p.id} style={{ height: 40 }}>
              {p.text}
            </li>
          ))}
        </ul>
        {status !== 'done' && <div ref={sentinelRef} style={{ height: 1 }} />}
      </div>
      {status === 'error' && <p>The posts did not load.</p>}
      {status === 'done' && <p>You have seen all {posts.length} posts.</p>}
      {status !== 'done' && (
        <button onClick={loadMore}>
          {status === 'loading' ? 'Loading…' : status === 'error' ? 'Try again' : 'Load more'}
        </button>
      )}
    </div>
  )
}

Run it. When the first page is there, press “Make the next request fail”. Then scroll down. This is what we saw in all three browsers:

  • The request went out, and failed. The page said “The posts did not load.”, and the button said “Try again”.
  • We scrolled up and down again. No new request went out. The observer loads only when the status is 'idle', so a broken server isn’t asked again and again.
  • We pressed “Try again”. The same page loaded, and the message went away.
  • At the end, the page said “You have seen all 50 posts.”, and the “Load more” button was gone. That was 4 requests: 3 pages and the one that failed.

try and catch turn a failed request into the 'error' status. Part 13 showed errors and a Retry button too.

The button doesn’t go away while it loads or fails. It only changes its words: “Load more”, “Loading…”, “Try again”. React keeps the same <button>. That matters for the next step.

Step 6: keep the reader’s place

New posts at the bottom move nothing

In step 3, scrollTop was 300 before the second page arrived, and 300 after it, in all three browsers.

New posts at the top

We added a button to step 3’s app that puts five posts at the top. With the box scrolled to 200, we pressed it. scrollTop jumped to 400 by itself, and Post 45 stayed at the top edge. That is scroll anchoring: the browser keeps what you see in place. MDN says it works in the newest version of every main browser since September 2026. Older browsers may jump. Many feeds show a “3 new posts” button instead.

The focus

In step 5’s app, press Tab until “Load more” has the focus, then press Enter. While it loads, the focus stays on the button, now saying “Loading…”. Press Enter again for the last page. The button is removed, and in all three browsers the focus went to the <body>. A screen reader user can’t tell where they are.

What about the disabled attribute while loading? We tried disabled={status === 'loading'}. A button that is disabled can’t keep the focus. In all three browsers, the focus fell to the <body> as soon as the first load began. The next Enter presses did nothing, so the list stayed at 40 posts. In Chromium and WebKit, the next Tab left the result box completely.

Part 48 found the same with a Send button, and used aria-disabled instead. It tells a screen reader the button can’t be used now, and the focus stays. So step 8 uses aria-disabled={status === 'loading'}, and the busy ref stops the click. TanStack’s own example uses disabled, so change that when you copy it. When the button has to go, move the focus first, as Part 47 said.

Coming back from a post

The reader opens a post and comes back. If the list kept the posts in its own state, they are gone. In this app, a post opens in place of the list, like a new page. The posts and the scroll position are kept in App, above the list. Part 9 called this lifting state up.

import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'

type Post = { id: number; text: string }
type Feed = { posts: Post[]; next: number | null }

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPosts(after: number | null): Promise<Feed> {
  console.log('Asking for posts after', after)
  return new Promise(resolve => {
    setTimeout(() => {
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

type ListProps = {
  feed: Feed
  setFeed: (feed: Feed) => void
  scrollRef: RefObject<number>
  openedId: number | null
  onOpen: (post: Post) => void
}

function PostList({ feed, setFeed, scrollRef, openedId, onOpen }: ListProps) {
  const [loading, setLoading] = useState(false)
  const boxRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (feed.posts.length > 0) return
    let ignore = false
    fetchPosts(null).then(page => {
      if (!ignore) setFeed(page)
    })
    return () => {
      ignore = true
    }
  }, [feed.posts.length, setFeed])

  useLayoutEffect(() => {
    if (boxRef.current === null) return
    boxRef.current.scrollTop = scrollRef.current
    if (openedId !== null) {
      document.getElementById(`post-${openedId}`)?.focus({ preventScroll: true })
    }
  }, [scrollRef, openedId])

  async function loadMore() {
    setLoading(true)
    const page = await fetchPosts(feed.next)
    setFeed({ posts: [...feed.posts, ...page.posts], next: page.next })
    setLoading(false)
  }

  return (
    <div
      ref={boxRef}
      onScroll={e => (scrollRef.current = e.currentTarget.scrollTop)}
      style={{ height: 300, overflowY: 'auto', border: '1px solid gray' }}
    >
      <ul style={{ margin: 0 }}>
        {feed.posts.map(p => (
          <li key={p.id} style={{ height: 40 }}>
            <button id={`post-${p.id}`} onClick={() => onOpen(p)}>
              {p.text}
            </button>
          </li>
        ))}
      </ul>
      {feed.next !== null && <button onClick={loadMore}>{loading ? 'Loading…' : 'Load more'}</button>}
    </div>
  )
}

export default function App() {
  const [feed, setFeed] = useState<Feed>({ posts: [], next: null })
  const [open, setOpen] = useState<Post | null>(null)
  const [openedId, setOpenedId] = useState<number | null>(null)
  const scrollRef = useRef(0)

  function openPost(post: Post) {
    setOpen(post)
    setOpenedId(post.id)
  }

  if (open !== null) {
    return (
      <div>
        <button onClick={() => setOpen(null)}>Back to the list</button>
        <h2>{open.text}</h2>
        <p>This is the whole post.</p>
      </div>
    )
  }
  return <PostList feed={feed} setFeed={setFeed} scrollRef={scrollRef} openedId={openedId} onOpen={openPost} />
}

Run it. Scroll to the end of the box and press “Load more”. Then scroll a little, and open Post 35. Now press “Back to the list”.

We tried this in all three browsers, with the box scrolled to 450. After “Back to the list”, the list had 40 posts again, scrollTop was 450, and the focus was on the “Post 35” button. No new request went out.

As a test, we moved the posts into the list’s own state. After “Back to the list”, it asked for the first page again and showed 20 posts at the top. The focus was on the <body>.

How it works:

  • onScroll saves scrollTop in a ref that App owns. Nothing on the screen needs to change when it changes.
  • When the list comes back, useLayoutEffect puts scrollTop back, before the browser paints (Part 11). It also gives the focus back to the post you opened. preventScroll: true stops that from moving the box.
  • The first-page Effect asks the server only when there are no posts yet.

With a router, like Part 37‘s, keep the pages above the routes, or in a data library’s cache.

The browser’s own Back button, after a real page load, is different. Browsers have a back/forward cache. web.dev calls it “a snapshot of the entire page in memory”. A page that comes back from it has its posts and its scroll. But a reload starts from nothing, and the browser doesn’t keep every page. Our test tool turns this cache off, so we saw only that case. We ran step 2’s app as a whole page, loaded all 50 posts and scrolled down. After another page and Back, all three browsers showed 20 posts, at the top. For that case, save how far the reader got, for example in the address.

Step 7: when the list gets very long

Every page adds tags to the page. After 2,000 posts, that’s 2,000 <li> tags. Part 35 showed why that gets slow, and its fix: windowing. Put on the page only the rows that can be seen, plus a few extra.

The two ideas fit together. Windowing decides which rows get tags. The sentinel decides when to load. Here the sentinel sits after the tall <ul>, so it is still at the very end.

import { useEffect, useEffectEvent, useRef, useState } from 'react'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }

// This pretends to be a server with 2,000 posts, newest first.
// It sends 100 at a time, after 0.3 seconds.
const allPosts: Post[] = []
for (let id = 2000; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPosts(after: number | null): Promise<Page> {
  return new Promise(resolve => {
    setTimeout(() => {
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 100)
      const next = older.length > 100 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 300)
  })
}

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const OVERSCAN = 3

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [done, setDone] = useState(false)
  const [scrollTop, setScrollTop] = useState(0)
  const [count, setCount] = useState('not counted yet')
  const busy = useRef(false)
  const boxRef = useRef<HTMLDivElement>(null)
  const listRef = useRef<HTMLUListElement>(null)
  const sentinelRef = useRef<HTMLDivElement>(null)

  async function loadMore() {
    if (busy.current) return
    busy.current = true
    const page = await fetchPosts(next)
    setPosts(old => [...old, ...page.posts])
    setNext(page.next)
    setDone(page.next === null)
    busy.current = false
  }

  const onSentinelSeen = useEffectEvent(() => {
    if (!done) loadMore()
  })

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (sentinel === null) return
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) onSentinelSeen()
      },
      { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
    )
    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [posts.length])

  const first = Math.floor(scrollTop / ROW_HEIGHT)
  const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
  const start = Math.max(0, first - OVERSCAN)
  const end = Math.min(posts.length, last + OVERSCAN)
  const visible = posts.slice(start, end)

  function handleCount() {
    if (listRef.current === null) return
    setCount(String(listRef.current.children.length))
  }

  return (
    <div>
      <button onClick={handleCount}>Count the rows on the page</button>
      <p>
        {posts.length} posts loaded. Rows on the page: {count}
      </p>
      <div
        ref={boxRef}
        onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
        style={{ height: BOX_HEIGHT, overflowY: 'auto', border: '1px solid gray' }}
      >
        <ul
          ref={listRef}
          style={{ position: 'relative', height: posts.length * ROW_HEIGHT, margin: 0, padding: 0 }}
        >
          {visible.map((p, i) => (
            <li
              key={p.id}
              style={{ position: 'absolute', top: (start + i) * ROW_HEIGHT, height: ROW_HEIGHT, left: 24, right: 0 }}
            >
              {p.text}
            </li>
          ))}
        </ul>
        {!done && <div ref={sentinelRef} style={{ height: 1 }} />}
      </div>
    </div>
  )
}

Run it and scroll down for a while. Press the count button now and then.

We scrolled each version to the end in all three browsers: this one, and a copy that draws every row. All 2,000 posts were loaded in both.

<li> tags at the start <li> tags at the end of 2,000 posts All tags in the result box, at the end
Windowed (this app) 13 13 29
Every row drawn 100 2,000 2,016

In the middle of a list there are 16 or 17, as Part 35 measured.

The windowed list stays small, no matter how many posts it holds. Only the array of posts grows.

Windowing takes things away, as Part 35 said. Rows that leave the window lose their tags. The browser’s find (Ctrl+F), screen readers and the Tab key can’t reach them. A focused post that leaves the window loses the focus too. With the feed role, aria-posinset must count in the whole list: start + i + 1. Keep windowing for lists that really get long.

Step 8: make it work for everyone

Nielsen Norman Group, who study how people use websites, list known problems.

  • The footer can’t be reached. Their words: infinite scrolling without a Load More button “can make it impossible to access the footer of a website”. Each time you get near the bottom, more posts push the footer down.
  • Keyboard users “have to “tab” through content link by link”.
  • Screen reader users “will only see the first “chunk” of the list”, with no way to load more.

Their advice is a Load More button instead of loading by itself. It “enables users to access the footer section of a website”. Our app keeps both, because the posts scroll inside a 300-pixel box, and the footer is always under it. When the whole window scrolls, stop loading by itself after a few pages. Then the button takes over.

The W3C’s ARIA Authoring Practices Guide, the APG, has a pattern for this: the feed. It says: “A feed is a section of a page that automatically loads new sections of content as the user scrolls.”

Its rules, as we use them:

  • The list has role="feed" and a name. Ours is named by the “Posts” heading, with aria-labelledby.
  • Each post is an <article>. Each one gets a name from its heading, and aria-posinset, its place in the feed.
  • aria-setsize is the total, or the number loaded so far. The guide allows -1 when the total is not known. A cursor feed doesn’t know its total, so we use -1.
  • The guide strongly recommends aria-describedby on each article, pointing at its main text. Our posts have only a title, so we leave it out.
  • The page loads new articles based on which one has the focus. Ours does this with no extra code, because focus scrolls the box. We pressed Page Down from the first post. Around Post 37 to 39, the box had moved far enough, and the next page was asked for.
  • While posts are being added, the feed has aria-busy="true". The guide says it is “extremely important” to set it back to false.
  • Keys, when the focus is inside the feed: “Page Down: Move focus to next article.” “Page Up: Move focus to previous article.” Control + End goes to “the first focusable element after the feed”. Control + Home goes to the one before it.

MDN’s page on the role adds that each article “should be focusable, with tabindex of 0 or -1”. We use -1, so Tab doesn’t stop on every post.

Here is the whole app. It has everything from steps 3 to 5, plus the feed, a live region and the focus fix that step 6 asked for.

import { useEffect, useEffectEvent, useRef, useState, type KeyboardEvent } from 'react'
import { flushSync } from 'react-dom'

type Post = { id: number; text: string }
type Page = { posts: Post[]; next: number | null }
type Status = 'idle' | 'loading' | 'error' | 'done'

// This pretends to be a server with 50 posts, newest first.
// It answers after half a second. It can fail once, on purpose.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}
let failNext = false

function fetchPosts(after: number | null): Promise<Page> {
  console.log('Asking for posts after', after)
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (failNext) {
        failNext = false
        reject(new Error('The server did not answer.'))
        return
      }
      const older = allPosts.filter(p => after === null || p.id < after)
      const posts = older.slice(0, 20)
      const next = older.length > 20 ? posts[posts.length - 1].id : null
      resolve({ posts, next })
    }, 500)
  })
}

export default function App() {
  const [posts, setPosts] = useState<Post[]>([])
  const [next, setNext] = useState<number | null>(null)
  const [status, setStatus] = useState<Status>('idle')
  const busy = useRef(false)
  const boxRef = useRef<HTMLDivElement>(null)
  const feedRef = useRef<HTMLDivElement>(null)
  const sentinelRef = useRef<HTMLDivElement>(null)
  const beforeRef = useRef<HTMLButtonElement>(null)
  const buttonRef = useRef<HTMLButtonElement>(null)
  const footerRef = useRef<HTMLAnchorElement>(null)

  async function loadMore(pressed: boolean) {
    if (busy.current) return
    busy.current = true
    setStatus('loading')
    try {
      const page = await fetchPosts(next)
      const firstNew = posts.length
      const buttonHadFocus = document.activeElement === buttonRef.current
      flushSync(() => {
        setPosts(old => [...old, ...page.posts])
        setNext(page.next)
        setStatus(page.next === null ? 'done' : 'idle')
      })
      if (buttonHadFocus && (pressed || page.next === null)) {
        feedRef.current?.querySelectorAll('article')[firstNew]?.focus()
      }
    } catch {
      setStatus('error')
    }
    busy.current = false
  }

  const onSentinelSeen = useEffectEvent(() => {
    if (status === 'idle') loadMore(false)
  })

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (sentinel === null) return
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) onSentinelSeen()
      },
      { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
    )
    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [posts.length])

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const articles = Array.from(e.currentTarget.querySelectorAll('article'))
    const i = articles.findIndex(a => a.contains(document.activeElement))
    if (e.key === 'PageDown') {
      e.preventDefault()
      articles[i + 1]?.focus()
    } else if (e.key === 'PageUp') {
      e.preventDefault()
      articles[i - 1]?.focus()
    } else if (e.key === 'End' && e.ctrlKey) {
      e.preventDefault()
      ;(buttonRef.current ?? footerRef.current)?.focus()
    } else if (e.key === 'Home' && e.ctrlKey) {
      e.preventDefault()
      beforeRef.current?.focus()
    }
  }

  let message = ''
  if (status === 'loading') message = 'Loading more posts…'
  else if (status === 'error') message = 'The posts did not load.'
  else if (status === 'done') message = `All ${posts.length} posts are loaded.`
  else if (posts.length > 0) message = `${posts.length} posts loaded.`

  return (
    <div>
      <button ref={beforeRef} onClick={() => (failNext = true)}>
        Make the next request fail
      </button>
      <h2 id="feed-title">Posts</h2>
      <div ref={boxRef} style={{ height: 300, overflowY: 'auto', border: '1px solid gray' }}>
        <div
          ref={feedRef}
          role="feed"
          aria-labelledby="feed-title"
          aria-busy={status === 'loading'}
          onKeyDown={handleKeyDown}
        >
          {posts.map((p, i) => (
            <article
              key={p.id}
              tabIndex={-1}
              aria-labelledby={`post-${p.id}`}
              aria-posinset={i + 1}
              aria-setsize={-1}
              style={{ height: 40 }}
            >
              <h3 id={`post-${p.id}`} style={{ margin: 0, fontSize: 16 }}>
                {p.text}
              </h3>
            </article>
          ))}
        </div>
        {status !== 'done' && <div ref={sentinelRef} style={{ height: 1 }} />}
      </div>
      <p role="status">{message}</p>
      {status !== 'done' && (
        <button ref={buttonRef} onClick={() => loadMore(true)} aria-disabled={status === 'loading'}>
          {status === 'loading' ? 'Loading…' : status === 'error' ? 'Try again' : 'Load more'}
        </button>
      )}
      <footer>
        <a ref={footerRef} href="#help">
          Help
        </a>
      </footer>
    </div>
  )
}

Run it. Press Tab until “Load more” has the focus, and press Enter.

This is what we saw in all three browsers:

  • While loading, the line under the box said “Loading more posts…”, and the feed had aria-busy="true". The button had aria-disabled="true", and it kept the focus.
  • When the page came, the focus moved to the first new post, “Post 30”. The box scrolled to show it. The line said “40 posts loaded.”
  • Page Down moved the focus to “Post 29”. Page Up, twice, to “Post 31”.
  • Control + End moved it to “Load more”. Control + Home, from the first post, to “Make the next request fail”.
  • After the last page, the focus was on “Post 10”, the first new post. The line said “All 50 posts are loaded.” Control + End moved to the “Help” link in the footer.

The line under the box is a live region, <p role="status">, always on the page, as Part 46 said. Chromium’s accessibility tree had it as a status with live=polite, and a feed named “Posts” with 50 article nodes.

The focus fix is in loadMore. It acts only when the button had the focus. Then, if the user pressed the button or the last page came, it focuses the first new post. Two things make that possible:

  • flushSync from react-dom makes React put the new posts on the page at once, inside loadMore. Part 47 mentioned it for a case like this one. React’s docs warn that it “can significantly hurt performance”, so we use it only here.
  • tabIndex={-1} on each <article> lets code focus it.

When the observer loads a page, the focus stays put. With the focus on “Load more”, we scrolled the box to 400. The next page loaded, and the focus and the box stayed. Then the last page loaded by itself, the button went away, and the focus moved to “Post 10”.

Here, Tab skips the posts. From “Make the next request fail”, Chromium and Firefox stopped once on the scrolling box itself. Then all three went to “Load more” and the “Help” link. But real posts have links and buttons, and Tab walks through every one. Then Control + End is the way out. The APG says that these keys are not common, so telling users about them is very important. Put a short note near the feed.

What an interviewer looks for

An interviewer watches how you work, not only the result. They look for:

  • Questions first. Do posts change while people read? Is there a footer?
  • A working base early. A Load more button that works, then automatic loading.
  • Cursors. Can you say why page numbers repeat or skip posts?
  • The observer. A sentinel, the scrolling box as root, a margin, and a cleanup that disconnects.
  • One request at a time. A ref, and why state is not enough.
  • Strict Mode. Do you know why the first page is asked for twice, and why that’s fine?
  • All the states. Loading, error with retry, and the end.
  • Everyone can use it. A button that stays, focus that isn’t lost, the feed role.
  • Size. When would you add windowing?

Common follow-ups:

  • “Load older posts at the top too.” Add a second sentinel at the top, and a second cursor. Scroll anchoring keeps the view still in new browsers. TanStack Query has fetchPreviousPage for this.
  • “Show new posts as they arrive.” Don’t push them in. Show a button like “3 new posts”, and add them on a click.
  • “Use a library.” useInfiniteQuery in TanStack Query gives you fetchNextPage, hasNextPage and isFetchingNextPage. Name it, then build the small version.

Common mistakes

Loading the same page twice

Here, pages are asked for in an Effect with no cleanup.

import { useEffect, useState } from 'react'

type Post = { id: number; text: string }

// This pretends to be a server with 50 posts, newest first.
const allPosts: Post[] = []
for (let id = 50; id >= 1; id--) {
  allPosts.push({ id, text: `Post ${id}` })
}

function fetchPage(page: number): Promise<Post[]> {
  console.log('Asking for page', page)
  return new Promise(resolve => {
    setTimeout(() => resolve(allPosts.slice((page - 1) * 20, page * 20)), 500)
  })
}

export default function App() {
  const [page, setPage] = useState(1)
  const [posts, setPosts] = useState<Post[]>([])

  useEffect(() => {
    fetchPage(page).then(newPosts => {
      setPosts(old => [...old, ...newPosts])
    })
  }, [page])

  return (
    <div>
      <p>{posts.length} posts</p>
      <ul>
        {posts.map(p => (
          <li key={p.id}>{p.text}</li>
        ))}
      </ul>
      <button onClick={() => setPage(page + 1)}>Load more</button>
    </div>
  )
}
Asking for page 1
Asking for page 1

Run it. It says “40 posts”, and posts 50 to 31 show twice. Strict Mode ran the Effect twice, and both answers were added. With Strict Mode off, we got 20 posts. But any second run of the Effect adds the page again. The kata loads its pages in this shape.

An ignore flag fixes the first page, but not everything. We added it and pressed “Load more” twice, 100 ms apart. Page 2’s Effect was cleaned up when page 3 was asked for, so page 2’s answer was ignored. The list said “30 posts”: page 1 and page 3, with page 2 missing. Ask for pages in the event, as in step 2, with a cursor.

An observer that is never stopped

useEffect(() => {
  const sentinel = sentinelRef.current
  if (sentinel === null) return
  const observer = new IntersectionObserver(
    entries => {
      if (entries[0].isIntersecting) onSentinelSeen()
    },
    { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
  )
  observer.observe(sentinel)
}, [posts.length])

This is step 3’s Effect with no cleanup. Strict Mode makes two observers at the start, and both keep watching. Both called back, and both saw loading as false. So the first page was asked for twice, and 20 posts showed twice.

It gets worse with every page. Each page makes one more observer, and none is ever stopped. We scrolled to the end. In our last run, Chromium and Firefox made 12 requests, and WebKit 9. The list ended with 180 and 140 posts, from a server that has 50. The counts depend on timing, so yours may differ.

With step 5’s busy ref, the same mistake gave 3 requests and 50 posts. The ref covered it up. The observers still piled up. The fix is the cleanup: return () => observer.disconnect().

An observer made once, with old values

useEffect(() => {
  const sentinel = sentinelRef.current
  if (sentinel === null) return
  const observer = new IntersectionObserver(
    entries => {
      if (entries[0].isIntersecting && !loading && !done) loadMore()
    },
    { root: boxRef.current, rootMargin: '0px 0px 200px 0px' },
  )
  observer.observe(sentinel)
  return () => observer.disconnect()
}, [])

This observer is made once, in the first render, and keeps calling that render’s loadMore. In it, next is still null.

So each time the sentinel came into range, it asked for the first page again. After the first scroll, the list had 40 posts: 50 to 31, then 50 to 31 again. After four more scrolls it had 120 posts, from 6 requests, all after null. Part 17 called this a stale handler. The fix is step 3’s: useEffectEvent, and [posts.length].

The index as the key

The kata uses key={index}. When posts are added at the top, every index changes, and Part 8 showed what goes wrong. Use the post’s id. Then React also warns you when a post shows twice. That is how we found most bugs in this part.

Practice

  1. In “Try this first”, press “A new post arrives” twice, then “Load more”. Which posts repeat on the left?
  2. In step 2’s app, wait for the first page, then press “Load more” twice, waiting each time. How many “Asking for posts” lines are in the Console?
  3. In step 3’s app, change the rootMargin to '0px'. Scroll down slowly. At what scrollTop does the second request go out? You can log boxRef.current?.scrollTop inside loadMore.
  4. In step 4’s app, why does busy start as true? What happens if you start it as false, and press “Load more” while it says “Loading…” at the start?
Answers
  1. Posts 10 and 9 repeat. The left list is 12, 11, 10, 9, 10, 9, 8, 7. Two new posts moved everything down by two. React warns about both keys, 10 and 9.
  2. Four lines: after null twice, then after 31, then after 11. The first two are Strict Mode, which runs the first-page Effect twice. The clicks are events, not Effects, so Strict Mode doesn’t double them.
  3. At 500. With no margin, the request goes out only when the sentinel reaches the bottom edge: 800 pixels of posts, minus the 300 that the box shows.
  4. The first page is on its way when the app starts. With busy as false, a click then sends a third request for the first page. The list gets 40 posts, with 20 shown twice, as in step 2.

Interview questions

Try to answer each one out loud before you open the answer.

Why use a cursor instead of a page number for a feed?

A page number counts from the top of the list. If posts are added at the top, the next page starts earlier, and you get repeats. If posts are deleted, it starts later, and you skip some. A cursor says “the posts after this one”, so it stays right when the list changes.

A strong answer says a good cursor keeps its order, like an id, and is never a position. Page numbers are fine when the list never changes, or the user must jump to page 7.

How do you know when to load the next page?

Put a small sentinel after the last item, and watch it with an IntersectionObserver. Use the box that scrolls as the root. Add a rootMargin at the bottom, so the call comes before the user reaches the end. Make the observer in an Effect, and disconnect() it in the cleanup.

A strong answer explains why not a scroll listener: the browser does the measuring. It also covers a page too short to push the sentinel out of range. A new observer after each page checks again at once.

How do you stop the same page from loading twice?

Keep a “busy” flag in a ref. Check it at the top of loadMore, set it before the request, and clear it after. Disconnect the observer in the cleanup, so old observers don’t call back. Ask with a cursor too. It doesn’t stop two requests at once, but it stops repeats when posts are added or deleted.

A strong answer says why a ref and not state: two calls in one task see the old state. It also uses the id as the key, so React warns about repeats.

What does Strict Mode do to an infinite list?

In development, it runs each Effect, then its cleanup, then the Effect again, when a component first appears. A first page loaded in an Effect is asked for twice. With an ignore flag in the cleanup, the first answer is dropped, and the page shows once. Without it, the page is added twice. An observer made in an Effect is made twice too. Without disconnect(), both keep calling back.

A strong answer adds that clicks are not doubled, and that none of this happens in production.

How would you make infinite scroll accessible?

Keep a Load more button, so keyboard users can reach the footer. When the window scrolls, stop loading by itself after a few pages. Use aria-disabled while loading, not disabled, or the focus is lost. When the button disappears at the end, move the focus first. Say what changed in a live region that is always on the page, like “40 posts loaded.”

A strong answer names the APG feed pattern: role="feed", articles with aria-posinset and aria-setsize, aria-busy while adding, and Page Down and Page Up.

The list has 10,000 items and scrolling is slow. What do you do?

Measure first, then add windowing. Put only the rows near the box on the page, inside a tall list with the full height. Keep the sentinel after it. In our test with 2,000 posts, a windowed list had 13 <li> tags at the end, and a normal one had 2,000.

A strong answer names what windowing takes away: the browser’s find can’t see rows that have no tags. It also names libraries like TanStack Virtual.

A user opens a post and presses Back. How do you keep their place?

Keep the loaded pages above the list, in a parent, a store or a data library’s cache. Save scrollTop in a ref when the user scrolls, and put it back in useLayoutEffect when the list comes back. Give the focus back to the post they opened.

A strong answer covers the browser’s Back button too. Browsers can bring the whole page back from their back/forward cache, posts and scroll included. After a reload, or when the cache didn’t keep the page, the memory is gone. Saving the cursors in the address lets the app load them again.

Sources

  • W3C ARIA Authoring Practices Guide, Feed Pattern: what a feed is, the roles and attributes, aria-setsize of -1, aria-busy, and the keys, quoted above.
  • W3C APG, Infinite Scrolling Feed Example: aria-busy while articles load.
  • MDN, ARIA: feed role: articles “should be focusable, with tabindex of 0 or -1”.
  • Nielsen Norman Group, Infinite Scrolling: When to Use It, When to Avoid It: the footer, keyboard and screen reader problems, and the Load More button.
  • MDN, Intersection Observer API: why scroll checks on the main thread are slow, root and rootMargin, and the first call when watching starts.
  • W3C, Intersection Observer: the implicit root is the top-level page, root margins apply only to same-origin-domain targets, and “rootMargin only applies to the intersection root itself”.
  • web.dev, Back/forward cache: “a snapshot of the entire page in memory”. Playwright, our test tool, turns it off: Chromium starts with --disable-back-forward-cache, and its Firefox sets fission.bfcacheInParent to false.
  • MDN, overflow-anchor: scroll anchoring, and Baseline since September 2026.
  • react.dev, Synchronizing with Effects: fetching in an Effect with an ignore flag, and why development makes two requests.
  • react.dev, useEffectEvent and flushSync: reading the newest values from an Effect, and “can significantly hurt performance”.
  • TanStack Query, Infinite Queries: cursors with getNextPageParam, “a single ongoing fetch”, and fetchPreviousPage.
  • The request counts, page text, warnings and lists above come from running React 19.3.0 for this post. The observer, scroll, focus, key and tag-count results come from headless Chromium 151, Firefox 153 and WebKit 26.5, and the accessibility tree from Chromium.
  • This part follows the Infinite Scroll kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.