Next.js Discord

Discord Forum

IntersectionObserver for infinite scroll exhibiting strange behaviour

Unanswered
Tonkinese posted this in #help-forum
Open in Discord
TonkineseOP
Hey guys, when I first load the page, and scroll to the bottom, 'RUN' is printed to console constantly (and I guess fetchMore as well, but no data is fetched because the cursor has not been updated).
However, if I then navigate to a different page, and then navigate back, the IntersectionObserver starts working properly (where new data will be fetched once the user has scrolled to the bottom of the page).

does anything about the following code jump out as the potential cause of this odd behavior?

const ItemList = ({ result }) => {
  console.log('RUN')

  const { data, loading, error, fetchMore } = result;

  const targetRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          if (fetchInfo) {
            fetchMore(...);
          }
        }
      },
      {
        threshold: 1,
        rootMargin: "0px",
      },
    );

    if (targetRef.current) {
      observer.observe(targetRef.current);
    }

    return () => {
      if (targetRef.current) {
        observer.disconnect();
      }
    };
  }, [targetRef, items]);

  return (
    <div>
      <Suspense fallback={<Loading />}>
        <Items items={items} />
      </Suspense>
      <div>{hasMoreItems && <Loading />}</div>
      <div>{loading && <Loading />}</div>
      <div ref={targetRef} />
    </div>
  )
}

37 Replies

TonkineseOP
browser console shows [Fast Refresh] rebuilding printing constantly too
TonkineseOP
any ideas? run out of ideas of what could be causing this tbh
TonkineseOP
also getting some weird behaviour when it does "work", where items on the page switch back and forth several times for staying on the new items
TonkineseOP
bump
Tenterfield Terrier
You said that it was printing RUN constantly, this behaviour could be a result of marking your component Async. React components cannot be asynchronous functions because they need to return JSX synchronously.
TonkineseOP
yeah so to clarify on mount RUN prints twice and the first N items of data are loaded, but when I scroll to the bottom of the component, it's like the component just reloads continuously, where RUN is continously printed to console and my api query is continuously fetched. This only stops if I click one of the items (via Link) and then navigate back to the list, after which the data loads more normally (but still has that issue where existing items are re-rendered several times)
no marking of any components as async btw (at least not explicitly)
Tenterfield Terrier
ok the dual print is because in dev, it mounts twice or something i don't remember the actual reason but you can disable it
the reason it is constantly fetching could be because you are replacing the array (in which you are storing the data from the fetch) rather than appending to it
just a speculation, if the code is not private, can you share the github link, id like to look at it
@Tenterfield Terrier the reason it is constantly fetching could be because you are replacing the array (in which you are storing the data from the fetch) rather than appending to it
TonkineseOP
I don't believe this is correct, the data should be cached by apollo, I think something else is causing the infinite fetching
console is also showing this warning: Warning: Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release. not sure if that's the cause though
@Tenterfield Terrier just a speculation, if the code is not private, can you share the github link, id like to look at it
TonkineseOP
here's the full component:
'use client'

import { Suspense, useState, useEffect, useRef } from "react";

export const List = ({ result, items, pageInfo }: any) => {
  const { data, loading, error, fetchMore } = result;

  console.log('RUN')

  const targetRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          if (pageInfo) {
            fetchMore({
              variables: {
                cursor: pageInfo.endCursor,
              },
            });
          }
        }
      },
      {
        // TODO: Make sure this works at all window sizes
        threshold: 1,
        //root: null,
        rootMargin: "300px",
      },
    );

    if (targetRef.current) {
      observer.observe(targetRef.current);
    }

    return () => {
      if (targetRef.current) {
        observer.disconnect();
      }
    };
  }, [targetRef, items]);

  if (error) {
    return (
      <div>
        Error
      </div>
    );
  }

  if (items?.length === 0) {
    return (
      <p>No items</p>
    );
  }

  return (
    <div>
      <Suspense fallback={<ListSkeleton num={10} />}>
        <div>
          {(!items || items?.length < 1) && (
            <div>
              <p>No items</p>
            </div>
          )}
          {items?.map((item: any, num: any) => (
            <Item key={item.id} item={item} />
          ))}
        </div>
      </Suspense>
      <div>{pageInfo?.hasNextPage && <ListSkeleton num={5} />}</div>
      <div>{loading && <ListSkeleton num={5} />}</div>
      <div ref={targetRef} />
    </div>
  );
};
}
export const ListSkeleton = ({ num = 10 }) => {
  return (
    <div>
      {Array.from({ length: num }, (_, index) => (
        <ItemSkeleton key={index} />
      ))}
    </div>
  )
Tenterfield Terrier
console warning is not the case it's a separate issue , u should use element.props.ref instead of element.ref :

https://github.com/radix-ui/primitives/issues/2769#issuecomment-2022804124
Tenterfield Terrier
U sure your fetchMore is correct ?
TonkineseOP
sure in the sense that it worked in my next v13 code
the only other change I made to this component was utilizing Suspense, so that's also a possibility
(and the Skeleton components were previously "Loading..." strings)
Tenterfield Terrier
ill let u know if i find a solution
TonkineseOP
appreciate it bro
oh also using useSuspenseQuery, not sure if relevant (https://www.apollographql.com/docs/react/data/suspense/) (as oppose to just useQuery)
@Tenterfield Terrier ill let u know if i find a solution
TonkineseOP
ok i just checked and it's definitely the IntersectionObserver causing the infinite re-rendering
removing that from useEffect stops those constant requests
TonkineseOP
after further digging, I've discovered that endCursor from
fetchMore({
  variables: {
    cursor: pageInfo.endCursor,
  },
});
isn't even being used in the query requests, which explains a lot...
the exact same code was working pre next v15 / useSuspenseQuery upgrade mind you
@Tenterfield Terrier ill let u know if i find a solution
TonkineseOP
ok dude after switching first: 10 to first: 5, it works now 😂
I have no idea why, guessing it's got something to do with the <Item /> element size and how intersection observer works, that's my best guess
appreciate your help 🙏
it was the Skeletons 💀
IntersectionObserver doesn't like them for whatever reason
Tenterfield Terrier
Let's go .....
TonkineseOP
further update: it's a combination of the number of items fetched and the the skeletons 💀
so at this point I'm not sure if it's actually problem with me not understanding IntersectionObserver well enough (because the old version worked perfectly), or if the upgrade to using next v15 / useSuspenseQuery / etc was the issue, or some combination of the two