Infinite Scroll hooks

This feature is currently in beta version. We want to test it in real projects before releasing the v1, just in case we have to modify the API. If you’re interested in this feature, it’d be great if you could install it, test it, and share your feedback in this Feature Discussion. To try it out, you have to install the affected packages using these tags:

Description

Allow the use of some hooks to facilitate the implementation of infinite scroll to Frontity users.

User Stories

As a Frontity theme developer
I want to use a hook to add infinite scroll to my archive pages
so that I can keep my code clean and bullet-proof

As a Frontity theme developer
I want to use a hook to add infinite scroll to my postType pages
so that I can keep my code clean and bullet-proof

Examples

Examples of how they could be used:

import { connect } from "frontity";
import useArchiveInfiniteScroll from "@frontity/hooks/use-archive-infinite-scroll";

const Archive = () => {
  const { pages, isLimit, isFetching, fetchNext } = useArchiveInfiniteScroll();

  return (
    <>
      {/* <ArchivePage /> would be a custom component created by the developer. */}
      {pages.map(({ key, link, isLast, Wrapper }) => (
        <Wrapper key={key}>
          <ArchivePage link={link} />
          {!isLast && <hr />}
        </Wrapper>
      ))}
      {/* <Loading /> would also be a custom component created by the developer. */}
      {isFetching && <Loading />}
      {isLimit && <button onClick={fetchNext}>Load Next Page</button>}
    </>
  );
};

export default connect(Archive);
import { connect } from "frontity";
import usePostTypeInfiniteScroll from "@frontity/hooks/use-post-type-infinite-scroll";

const PostTypeList = ({ state }) => {
  const data = state.source.get(state.router.link);

  const { posts, isLimit, isFetching, fetchNext } = usePostTypeInfiniteScroll({
    active: !!data.isPost,
  });

  return (
    <>
      {/* <PostType /> would be a custom component created by the developer. */}
      {posts.map(({ key, link, isLast, Wrapper }) => (
        <Wrapper key={key}>
          <PostType link={link} />
          {!isLast && <hr />}
        </Wrapper>
      ))}
      {/* <Loading /> would also be a custom component created by the developer. */}
      {isFetching && <Loading />}
      {isLimit && <button onClick={fetchNext}>Load Next Post</button>}
    </>
  );
};

export default connect(PostTypeList);

We’re planning to have two different hooks: useArchiveInfiniteScroll and usePostTypeInfiniteScroll.

These are the types of the hooks in the first beta:

type UseArchiveInfiniteScroll = (options?: {
  limit?: number;
  active?: boolean;
}) => {
  pages: {
    key: string;
    link: string;
    isLast: boolean;
    Wrapper: React.FC;
  }[];
  isLimit: boolean;
  isFetching: boolean;
  fetchNext: () => Promise<void>;
};
type UsePostTypeInfiniteScroll = (options?: {
  limit?: number;
  active?: boolean;
  archive?: string;
  fallback?: string;
}) => {
  posts: {
    key: string;
    link: string;
    isLast: boolean;
    Wrapper: React.FC;
  }[];
  isLimit: boolean;
  isFetching: boolean;
  fetchNext: () => Promise<void>;
};

You can find more information on the Pull Request where the beta version was implemented. Bear in mind that docs and e2e tests are still lacking.

In order to work with the beta, we’re going to work on the infinite-scroll-beta branch until the v1 is ready.

Hi @SantosGuillamot

I am trying to load 10 blogposts using usePostTypeInfiniteScroll. Following is my code. When I scroll down it automatically fetches the latests blogposts. Can I fetch blogposts for a specific category?

const Post = ({ state, actions, libraries }) => {
  const data = state.source.get(state.router.link);

  const { posts, isLimit, isFetching, fetchNext } = usePostTypeInfiniteScroll({
    limit: 10,
    active: !!data.isPost,
  });

  return (
    <div>
      {posts.map(({ key, link, isLast, Wrapper }) => {
        let id = state.source.get(link).id;
        let type = state.source.get(link).type;

        let post = state.source[type][id];
        let formatPost = formatPostData(state, post);
        return (
          <Wrapper key={key}>
            <div/>
            <Post post={formatPost} />
            {!isLast && <hr />}
          </Wrapper>
        );
      })}
      {isFetching && <>...loading</>}
    </div>
  );
};

export default connect(Post);
2 Likes

Found it:

  let clickedPost = getPostData(state);
  let categoryId = clickedPost.categories[0];
  let categoryLink = state.source.category[categoryId].link;

  const { posts, isLimit, isFetching, fetchNext } = usePostTypeInfiniteScroll({
    limit: 10,
    fallback: "/",
    active: !!data.isPost,
    archive: categoryLink,
  });

2 Likes

Hey, awesome :slightly_smiling_face:

@sarang please remind that this is still a beta. I’d be great if you could give us feedback on both the bugs you find and the API.

Sure!! Till now everything is working great. Thanks!!

1 Like

I have published a new beta version with a bug fix made by @orballo:

Please update your local projects.

1 Like

I ran into a new scenario where the next entity fetched by usePostTypeInfiniteScroll returns an error. I think this case should be handled by the hook, but not sure how.

Maybe it should deactivate the infinite scroll when it runs into an entity that couldn’t be fetched? Maybe just jump to the next one?

Right now, what is happening in my case, is that when I keep scrolling and the route change happens, all of a sudden my screen becomes an error screen, and it’s very confusing, even though I’m rendering an error component, any previous context just disappears.

Interesting.

What about adding a some error handling to the hook? If there’s an error, it won’t let the user continue, but it will let him/her try to do the fetching again.

That would mean that if there’s an error, the hook should “exit” the infinite-scroll mode and switch to the “isLimit” mode.

import { connect } from "frontity";
import useArchiveInfiniteScroll from "@frontity/hooks/use-archive-infinite-scroll";

const Archive = () => {
  const {
    pages,
    isLimit,
    isFetching,
    fetchNext,
    isError,
  } = useArchiveInfiniteScroll();

  return (
    <div>
      {/* <ArchivePage /> would be a custom component created by the developer. */}
      {pages.map(({ key, link, isLast, Wrapper }) => (
        <Wrapper key={key}>
          <ArchivePage link={link} />
          {!isLast && <hr />}
        </Wrapper>
      ))}
      {/* <Loading /> would also be a custom component created by the developer. */}
      {isFetching && <Loading />}
      {isError && (
        <div>
          There was an error fetching the next page. Please, try loading the
          page again.
        </div>
      )}
      {(isLimit || isError) && (
        <button onClick={fetchNext}>Load Next Page</button>
      )}
    </div>
  );
};

export default connect(Archive);

Does it make sense or is it too complex?

I don’t think the interface shown to the user is to complex, though I don’t like the logic behind. I’d not play with the isLimit logic, I’d add an isError logic that prevents the infinite scroll, and it’s set until a new fetch works.

OK, so you would make it two separate things:

  • isLimit and button the get next page.
  • isError and button to try again.

Is that right?

Yes, for the user it would be like you described. But internally the fetchNext function will be the one in charge of reseting isError.

1 Like

The final changes implemented to the infinite-scroll hook with the intention of handling fetch errors are the following:

  • useArchiveInfiniteScroll and usePostTypeInfiniteScroll now return a new boolean called isError which can be used to know if the last fetch inside the infinite scroll hook has failed.
  • In addition, now the useArchiveInfiniteScroll and usePostTypeInfiniteScroll accept the fetchInViewOptions and routeInViewOptions that will be passed to the IntersectionObserver used by useInView.

And example of using the hooks with the new changes could be:

const Archive: React.FC = () => {
  const { state, libraries } = useConnect<Packages>();
  const current = state.source.get(state.router.link);

  const { pages, isFetching, isError, fetchNext } = useArchiveInfiniteScroll({
    fetchInViewOptions: {
      rootMargin: "400px 0px",
      triggerOnce: true,
    },
    routeInViewOptions: {
      rootMargin: "-80% 0% -19.9999% 0%",
    },
  });

  return (
    <div>
      {pages.map(({ Wrapper, key, link }) => {
        const { page } = libraries.source.parse(link);
        const data = state.source.get(link);
        return (
          <Wrapper key={key}>
            <div>
              Page {page}
              <ul>
                {data.items.map((item) => (
                  <li key={item.id}>{item.link}</li>
                ))}
              </ul>
            </div>
          </Wrapper>
        );
      })}
      {isFetching && <div>Fetching</div>}
      {isError && (
        <div>
          <button onClick={fetchNext}>
            Try Again
          </button>
        </div>
      )}
    </div>
  );
};

export default connect(Archive, { injectProps: false });

Thanks for the update @orballo.

So I guess that if the user wants to use a limit, the fetchNext action will be used for both isLimit and isError, right?

const Archive = () => {
  const {
    pages,
    isFetching,
    isError,
    isLimit,
    fetchNext,
  } = useArchiveInfiniteScroll({
    limit: 3,
  });

  return (
    <div>
      {pages.map(({ Wrapper, key, link }) => (
        <Wrapper key={key}>
          <ArchivePage link={link} />
        </Wrapper>
      ))}
      {isFetching && <div>Fetching</div>}
      {isLimit && <button onClick={fetchNext}>Next Page</button>}
      {isError && <button onClick={fetchNext}>Try Again</button>}
    </div>
  );
};

export default connect(Archive);

I’ve published a new beta version with this change. The packages updated were:

Please update your version and let us know how it goes :smile:

That is correct, as long as the last item couldn’t be fetched, the fetchNext action will try to fetch it again, and will never try to get the next item until the last one is fetched.

1 Like

I’ve been testing this and the only packages required are:

I’ve updated the opening post with this info as well.

I’ve also created a CodeSandbox in case you want to test it out in the browser:

2 Likes

I tried to implement this in frontity.org and it works for both the archives and the single post. However, while using the UsePostTypeInfiniteScroll in the posts, it just works if I navigate first to the homepage. If I access the post directly (SSR), it doesn’t. It seems the problem is that we’re using the postsPage setting, so if we go directly to the post, it’s trying to load the link "/", and that’s not correct. I’ve check that if I change this line of code it works nice in our case:

return state.source.postsPage || "/";

I’ve also checked and it seems it happens the same if you use the subdirectory setting, so I guess the solution could be something like this (in case you have both of them defined):

return state.source.subdirectory + state.source.postsPage || "/";

The problem here is that, although it works, TypeScript is complaining because properties subdirectory and postsPage don’t exist. This part I don’t know how to solve it.

Moreover, changing that line as I said only works if you defined the subdirectory with an opening slash (/blog). If we remove it (blog) or add and ending slash (/blog/), it wouldn’t work. So we have to take this into account as well.

Let me know what you think. If it can be solved easily just modifying that line I can open a Pull Request directly. If it’s going to be more difficult I’ll open an issue to keep track of it.

I think we talked about this once already? Maybe related to something else or was it related to infinite scroll?

I’m not familiar with those settings. If it is safe to use them, I can implement that change easily.

Yes, it was this same problem but we didn’t write down our findings.

Maybe @David can help here as he implemented them.