Infinite Scroll hooks

Thanks for the last PR @orballo!

I’ve just release a new beta version of @frontity/hooks: 2.1.0-infinite-scroll-beta.3

@orballo could you please update your project and see if everything is fine?

After that, I will take a deep look to see if we don’t forget anything. I think there may be some tests still missing, right? When everything is ok we will merge the infinite-scroll-beta branch with dev and we will release the hook :slightly_smiling_face:

@SantosGuillamot Could you please also check that the bug you mentioned is working properly now? Thanks!

It works great now for me :clap:

2 Likes

My issue has been solved too :slight_smile:

I have released a new version of the required packages to test the infinite scroll hook:

:new: Hey! Some updates here:

The PR (#652) where we’ve been preparing the infinite scroll hooks to be officialy released is almost done. I’ll be working on writing the Final Implementation post in the meantime. Just so you know, the API will remain the same.

Keep tuned! :raised_hands:

Pull Request

Requirements

Feature:

Dependencies:

  • @frontity/tiny-router (to be filled once the latest version is released)
  • @frontity/wp-source (to be filled once the latest version is released)

Functionalities

Three React hooks were implemented. Two for general use:

  • useArchiveInfiniteScroll
  • usePostTypeInfiniteScroll

And a basic one for implementing custom infinite scroll hooks (used internally
by the previous two hooks):

  • useInfiniteScroll

NOTE: useInfiniteScroll is not intented to be used directly by theme
developers unless they are creating their own infinite scroll logic.

The main idea behind these hooks is that they return a list of Wrapper
components, one for each entity listed while scrolling, that handle both the
route updating and fetching of the next entity.

Also, the following action was added to the @frontity/router API and the
@frontity/tiny-router package.

  • actions.router.updateState - Function to update the content of the browser
    history state for the current link.

Infinite scroll hooks

useInfiniteScroll

This is the core hook with the basic logic to build an infinite scroll hook.

It basically receives two links, currentLink and nextLink, and returns two
React refs that should be attached to react elements. The hook uses useInView
internally to track the visibility of those elements and trigger an
actions.router.set to update the current link or an actions.source.fetch to
fetch the next entity. You can pass options for these useInView hooks as well,
using the fetchInViewOptions and the routeInViewOptions params.

useInfiniteScroll also keeps a record of the fetched & ready entities in the
browser history state, in order to restore the list when you go back and
forward while navigating. That record is accessible from the browser history
state under the infiniteScroll.links array.

Note: the history state is also accessible from the Frontity state, in
state.router.state.

It was designed to be used inside a Wrapper component that would wrap the
entity pointed by currentLink.

Parameters

It requires an object with the following props:

Name Type Default Required Description
currentLink string - yes The current link that should be used to start the infinite scroll.
nextLink string - no The next link that should be fetched and loaded once the user scrolls down.
fetchInViewOptions IntersectionOptions - no The intersection observer options for fetching.
routeInViewOptions IntersectionOptions - no The intersection observer options for routing.

NOTE: The IntersectionOptions type refers to the type of the the parameters
received by the useInView hook.

Return value

Name Type Description
supported boolean Boolean indicating if the Intersection Observer is supported or not by the browser.
routeRef React.Ref The ref that should be attached to the element used to trigger actions.router.set.
fetchRef React.Ref The ref that should be attached to the element used to trigger actions.source.fetch.
routeInView boolean Boolean that indicates when the element used to trigger actions.router.set is in the screen.
fetchInView boolean Boolean that indicates when the element used to trigger actions.source.fetch is in the screen.

Usage

Note: this is just an example to illustrate how the useInfiniteScroll works.
For better examples, see the useArchiveInfiniteScroll and the
usePostTypeInfiniteScroll implementation.

import { useConnect, connect, css } from "frontity";
import useInfiniteScroll from "../use-infinite-scroll";
import { isArchive, isError } from "@frontity/source";

export const wrapperGenerator = ({
  link,
  fetchInViewOptions,
  routeInViewOptions,
}) => {
  const Wrapper = ({ children }) => {
    const { state } = useConnect();

    const current = state.source.get(link);
    const next =
      isArchive(current) && current.next
        ? state.source.get(current.next)
        : null;

    const { supported, fetchRef, routeRef } = useInfiniteScroll({
      currentLink: link,
      nextLink: next?.link,
      fetchInViewOptions,
      routeInViewOptions,
    });

    if (!current.isReady || isError(current)) return null;
    if (!supported) return children;

    const container = css`
      position: relative;
    `;

    const fetcher = css`
      position: absolute;
      width: 100%;
      bottom: 0;
    `;

    return (
      <div css={container} ref={routeRef}>
        {children}
        {<div css={fetcher} ref={fetchRef} />}
      </div>
    );
  };

  return connect(Wrapper, { injectProps: false });
};

useArchiveInfiniteScroll

This hook implements the logic needed to include infinite scroll in archives
(i.e. categories, tags, the posts page, etc.).

The hook receives options to set a limit of pages shown automatically, to
disable it, and also settings for the intersection observers that are passed to
the useInfiniteScroll hooks used internally.

useArchiveInfiniteScroll is designed to be used inside an Archive component.
That component would render all the archive pages from the pages returned by the
hook.

Apart from that list, it returns a set of boolean values to know if the next
pages is being fetched, if the limit has been reached or if the next page
returned an error, and a function to fetch the next page manually.

Parameters

It accepts an optional object with the following props:

Name Type Default Required Description
active boolean true no A boolean indicating if this hook should be active or not. It can be useful in situations where users want to share the same component for different types of Archives, but avoid doing infinite scroll in some of them.
limit number infinite no The number of pages that the hook should load automatically before switching to manual fetching.
fetchInViewOptions IntersectionOptions - no The intersection observer options for fetching.
routeInViewOptions IntersectionOptions - no The intersection observer options for routing.

NOTE: The IntersectionOptions type refers to the type of the the parameters
received by the useInView hook.

Return value

An object with the following properties:

Name Type Description
pages Array of page props An array of the existing pages. Users should iterate over this array in their own layout. The content of each element of this array is explained below.
isFetching boolean If it’s fetching the next page. Useful to add a loader.
isLimit boolean If it has reached the limit of pages and it should switch to manual mode.
isError boolean If the next page returned an error. Useful to try again.
fetchNext function A function that fetches the next page. Useful when the limit has been reached (isLimit === true) and the user pushes a button to get the next page.

Each element of the pages array has the following structure:

Name Type Description
key string A unique key to be used in the iteration.
link string The link of this page.
isLast boolean If this page is the last page. Useful to add separators between pages, but avoid adding it for the last one.
Wrapper React.FC The Wrapper component that should wrap the real Archive component.

Usage

import { connect, useConnect } from "frontity";
import { useArchiveInfiniteScroll } from "@frontity/hooks";
import ArchivePage from "./archive-page";

/**
 * Simple component showing the usage of the `useArchiveInfiniteScroll` hook.
 *
 * @example
 * ```
 * // In the Theme component:
 * <Switch>
 *   {...}
 *   <Archive when={data.isArchive} />
 * </Switch>
 * ```
 */
const Archive = () => {
  const { state } = useConnect();

  // Get the data object of the current link.
  const current = state.source.get(state.router.link);

  // Get the list of pages from the hook.
  const { pages, isFetching } = useArchiveInfiniteScroll();

  // Just render nothing if the current link is not ready.
  if (!current.isReady) return null;

  return (
    <>
      {pages.map(({ Wrapper, key, link, isLast }) => (
        <Wrapper key={key}>
          <ArchivePage link={link} />
          {isLast && <div>You reached the end!</div>}
        </Wrapper>
      ))}
      {isFetching && <div>Loading more...</div>}
    </>
  );
};

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

usePostTypeInfiniteScroll

Hook that implements the logic needed to include infinite scroll in a post type
view (i.e. posts, pages, galleries, etc.).

This hook is more complex than the previous one, as it works getting the post
type entities from the specified archive and thus it doesn’t fetch the next post
but the next page of posts.

It recevies an archive and a fallback prop ―both links―, to specify the
source of the post entities. If none of them is specified,
state.source.postsPage is used. When the penultimate post of the first page is
rendered, the next page of the archive is fetched. A list of the fetched pages
is stored in the browser history state along with the list of posts.

The limit prop in this case stands for the number of posts being shown, not
the number of fetched pages. In the same way, the fetchNext shows the next
post, and only fetches the next page of posts if needed.

Parameters

It accepts an optional object with the following props:

Name Type Default Required Description
active boolean true no A boolean indicating if this hook should be active or not. It can be useful in situations where users want to share the same component for different types of Archives, but avoid doing infinite scroll in some of them.
limit number infinite no The number of pages that the hook should load automatically before switching to manual fetching.
archive string - no The archive that should be used to get the next posts. If none is present, the previous link is used. If the previous link is not an archive, the homepage is used.
fallback string - no The archive that should be used if the archive option is not present and the previous link is not an archive.
fetchInViewOptions IntersectionOptions - no The intersection observer options for fetching.
routeInViewOptions IntersectionOptions - no The intersection observer options for routing.

NOTE: The IntersectionOptions type refers to the type of the the parameters
received by the useInView hook.

Return value

The output of this hooks is pretty similar to the previous one’s:

Name Type Description
posts Array of post props An array of the existing posts. Users should iterate over this array in their own layout. The content of each element of this array is explained below.
isFetching boolean If it’s fetching the next page. Useful to add a loader.
isLimit boolean If it has reached the limit of pages and it should switch to manual mode.
isError boolean If the next page returned an error. Useful to try again.
fetchNext function A function that fetches the next page. Useful when the limit has been reached (isLimit === true) and the user pushes a button to get the next page.

Each element of the posts array has the following structure:

Name Type Description
key string A unique key to be used in the iteration.
link string The link of this page.
isLast boolean If this post is the last post. Useful to add separators between posts, but avoid adding it for the last one.
Wrapper React.FC The Wrapper component that should wrap the real Post component.

Usage

import { connect, useConnect } from "frontity";
import { usePostTypeInfiniteScroll } from "@frontity/hooks";
import PostTypeEntity from "./post-type-entity";

/**
 * Simple component showing the usage of the `usePostTypeInfiniteScroll` hook.
 *
 * @example
 * ```
 * // In the Theme component:
 * <Switch>
 *   {...}
 *   <PostType when={data.isPostType} />
 * </Switch>
 * ```
 */
const PostType = () => {
  const { state } = useConnect();

  // Get the data object of the current link.
  const current = state.source.get(state.router.link);

  // Get the list of posts from the hook.
  const { posts, isFetching } = usePostTypeInfiniteScroll();

  // Render nothing if the link is not ready yet.
  if (!current.isReady) return null;

  return (
    <>
      {posts.map(({ Wrapper, key, link, isLast }) => (
        <Wrapper key={key}>
          <PostTypeEntity link={link} />
          {isLast && <div>You reached the end!</div>}
        </Wrapper>
      ))}
      {isFetching && <div>Loading more...</div>}
    </>
  );
};

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

More things added

actions.source.updateState

Action that replaces the value of state.router.state with the give object. The
same object is stored in the browser history state using the
history.replaceState()
function.

Parameters

Name Type Default Required Description
historyState object - yes The object to set as the history state.

Out of Scope

I was out of the scope of this PR to implement a way to let developers to change
the logic that usePostTypeInfiniteScroll uses to get the next post.

Right now, that logic is the following:

  • If the post is the first post rendered and it’s not included in the first page
    of the archive, the next post is the first post of the archive.
  • For any other case, get the index where the post appears in the fetched pages.
    The next post will be the one with index + 1.

API changes

Backward compatible changes

Instead of having to import each hook from its module, hooks can be imported now
from the package root:

import { useInView, useInfiniteScroll } from "@frontity/hooks";

They can still be imported directly from each module:

import useInView from "@frontity/hooks/use-in-view";
import useInfiniteScroll from "@frontity/hooks/use-infnite-scroll";

Breaking changes

No breaking changes.

Deprecated APIs

No deprecated APIs.

Technical explanation

Work in progress.

1 Like