Infinite Scroll With Firebase, React, Intersection Observer & Redux Saga

chandrapantachhetri

Chandra Panta Chhetri

Posted on January 7, 2021

Infinite Scroll  With Firebase, React, Intersection Observer & Redux Saga

While working on a React project with Redux-Saga and Firebase, I wanted to add infinite scrolling to improve site performance and user experience. However, structuring the Firestore, Redux, Redux-Saga, and React code to maximize readability and maintainability was difficult.

End Result

We will be building a simple UI that displays 6 products initially and as the user scrolls to the end, we will load 6 more products. Building a simple UI will let us focus on the Redux, Firestore, and Redux-Saga logic.

Trulli


Figure 1: Loading more products once user scrolls to the end

The code with all configurations can be found at https://github.com/Chandra-Panta-Chhetri/infinite-scroll-firebase-tutorial.

Prerequisite

Redux

To setup the Redux portion, we will need the following dependencies:

Redux Store, Root Reducer & Root Saga

As with any React, Redux, and Redux-Saga project, the convention is to set up a root reducer, a root saga, and the Redux store.

In the root reducer, we will combine all the reducers, which in this case will only be a product reducer, and export it.

import productReducer from "./product/product.reducer";
import { combineReducers } from "redux";

export default combineReducers({
  product: productReducer
});
Enter fullscreen mode Exit fullscreen mode

Similar to the root reducer, in the root saga we will combine all the sagas, which in this case will only be a product saga.

import { all, call } from "redux-saga/effects";
import productSagas from "./product/product.sagas";

export default function* rootSaga() {
  yield all([call(productSagas)]);
}
Enter fullscreen mode Exit fullscreen mode

Now we need to connect the root saga and root reducer to the Redux store.

import createSagaMiddleware from "redux-saga";
import rootReducer from "./root.reducer";
import rootSaga from "./root.saga";
import { createStore, applyMiddleware } from "redux";

const sagaMiddleware = createSagaMiddleware();

const middlewares = [sagaMiddleware];

export const store = createStore(rootReducer, applyMiddleware(...middlewares));

sagaMiddleware.run(rootSaga);
Enter fullscreen mode Exit fullscreen mode

To put it simply, the configuration above connects the root saga to the Redux store by passing the saga middleware to the applyMiddleware function and then calling the run method on the saga middleware.

If you want to understand the configurations in greater depth, refer to https://www.codementor.io/@rajjeet/step-by-step-how-to-add-redux-saga-to-a-react-redux-app-11xqieyj67.

When working with Redux, the convention is to define the action types, action creators, selectors, and a reducer so we can manage independent parts of the Redux store.

And so, we will create the action types, action creators, selectors, sagas, and a reducer to manage the product states in the Redux store.

Product Action Types

Let's start by defining the action types our product reducer and action creators will use. By defining constants, we will have consistent naming in the product reducer and action creators.

const PRODUCT_ACTION_TYPES = {
  START_INITIAL_PRODUCTS_FETCH: "START_INITIAL_PRODUCTS_FETCH",
  INITIAL_PRODUCTS_FETCH_FAIL: "INITIAL_PRODUCTS_FETCH_FAIL",
  INITIAL_PRODUCTS_FETCH_SUCCESS: "INITIAL_PRODUCTS_FETCH_SUCCESS",
  START_LOADING_MORE_PRODUCTS: "START_LOADING_MORE_PRODUCTS",
  LOADING_MORE_PRODUCTS_FAIL: "LOADING_MORE_PRODUCTS_FAIL",
  LOADING_MORE_PRODUCTS_SUCCESS: "LOADING_MORE_PRODUCTS_SUCCESS",
  NO_MORE_PRODUCTS_TO_LOAD: "NO_MORE_PRODUCTS_TO_LOAD"
};

export default PRODUCT_ACTION_TYPES;
Enter fullscreen mode Exit fullscreen mode

If you are wondering why we are considering the initial product fetch and the subsequent product fetch as different action types, don't worry the reason will become quite clear when we write the sagas and Firestore queries.

Product Action Creators

Now that we have defined the action types, we will use them when creating the action creators we will dispatch to update the Redux store.

For each action type, we will create a function that returns an action. An action is an object of the form { type, payload }.

import PRODUCT_ACTION_TYPES from "./product.action.types";

export const startInitialProductsFetch = () => ({
  type: PRODUCT_ACTION_TYPES.START_INITIAL_PRODUCTS_FETCH
});

export const initialProductsFetchFail = (errorMsg) => ({
  type: PRODUCT_ACTION_TYPES.INITIAL_PRODUCTS_FETCH_FAIL,
  payload: errorMsg
});

export const initialProductsFetchSuccess = (products, lastVisibleDoc) => ({
  type: PRODUCT_ACTION_TYPES.INITIAL_PRODUCTS_FETCH_SUCCESS,
  payload: { products, lastVisibleDoc }
});

export const startLoadingMoreProducts = () => ({
  type: PRODUCT_ACTION_TYPES.START_LOADING_MORE_PRODUCTS
});

export const loadingMoreProductsFail = (errorMsg) => ({
  type: PRODUCT_ACTION_TYPES.LOADING_MORE_PRODUCTS_FAIL,
  payload: errorMsg
});

export const loadingMoreProductsSuccess = (newProducts, lastVisibleDoc) => ({
  type: PRODUCT_ACTION_TYPES.LOADING_MORE_PRODUCTS_SUCCESS,
  payload: { newProducts, lastVisibleDoc }
});

export const noMoreProductsToLoad = () => ({
  type: PRODUCT_ACTION_TYPES.NO_MORE_PRODUCTS_TO_LOAD
});
Enter fullscreen mode Exit fullscreen mode

Product Reducer

The product reducer will manipulate the following states depending on the action types being dispatched.

const INITIAL_STATE = {
  products: [],
  isFetchingProducts: false,
  productsPerPage: 6,
  lastVisibleDoc: null,
  hasMoreToFetch: true
};
Enter fullscreen mode Exit fullscreen mode

The purpose of each is as follows:

  • products
    • Stores the product data fetched from Firestore
  • isFetchingProducts
    • Indicates whether we are fetching products from Firestore
  • productsPerPage
    • The maximum number of products we want to get on each request to Firestore
  • lastVisibleDoc
    • Stores the last document snapshot from the most recent Firestore request
    • When getting the next set of products from Firestore, we need to provide the last document snapshot. We will see an example when we write the Firestore queries later.
  • hasMoreToFetch
    • Indicates whether there are more products to fetch from Firestore (Prevents making requests to Firestore if we have fetched all the products)

We can now define the skeleton of the reducer like so:

import PRODUCT_ACTION_TYPES from "./product.action.types";

const INITIAL_STATE = {
  products: [],
  isFetchingProducts: false,
  productsPerPage: 6,
  lastVisibleDoc: null,
  hasMoreToFetch: true
};

const productReducer = (prevState = INITIAL_STATE, action) => {
  switch (action.type) {
    default:
      return prevState;
  }
};

export default productReducer;
Enter fullscreen mode Exit fullscreen mode

Using the action type constants, we can now add case statements so that we can manipulate the state when an action occurs.

import PRODUCT_ACTION_TYPES from "./product.action.types";

const INITIAL_STATE = {
  products: [],
  isFetchingProducts: false,
  productsPerPage: 6,
  lastVisibleDoc: null,
  hasMoreToFetch: true
};

const productReducer = (prevState = INITIAL_STATE, action) => {
  switch (action.type) {
    case PRODUCT_ACTION_TYPES.START_INITIAL_PRODUCTS_FETCH:
      return {
        ...prevState,
        isFetchingProducts: true,
        products: [],
        hasMoreToFetch: true,
        lastVisibleDoc: null
      };
    case PRODUCT_ACTION_TYPES.INITIAL_PRODUCTS_FETCH_FAIL:
    case PRODUCT_ACTION_TYPES.LOADING_MORE_PRODUCTS_FAIL:
    case PRODUCT_ACTION_TYPES.NO_MORE_PRODUCTS_TO_LOAD:
      return {
        ...prevState,
        isFetchingProducts: false,
        hasMoreToFetch: false
      };
    case PRODUCT_ACTION_TYPES.INITIAL_PRODUCTS_FETCH_SUCCESS:
      return {
        ...prevState,
        products: action.payload.products,
        lastVisibleDoc: action.payload.lastVisibleDoc,
        isFetchingProducts: false
      };
    case PRODUCT_ACTION_TYPES.START_LOADING_MORE_PRODUCTS:
      return {
        ...prevState,
        isFetchingProducts: true
      };
    case PRODUCT_ACTION_TYPES.LOADING_MORE_PRODUCTS_SUCCESS:
      return {
        ...prevState,
        isFetchingProducts: false,
        products: [...prevState.products, ...action.payload.newProducts],
        lastVisibleDoc: action.payload.lastVisibleDoc
      };
    default:
      return prevState;
  }
};

export default productReducer;
Enter fullscreen mode Exit fullscreen mode

Now that we have implemented the product reducer, based on how the state is being manipulated, it should be more clear as to why we defined the action types we did.

Product Selectors

Selectors are functions that accept the entire Redux state as a parameter and return a part of the state.

export const selectProductsPerPage = (state) => state.product.productsPerPage;

export const selectLastVisibleDoc = (state) => state.product.lastVisibleDoc;

export const selectProducts = (state) => state.product.products;

export const selectIsFetchingProducts = (state) =>
  state.product.isFetchingProducts;

export const selectHasMoreProductsToFetch = (state) =>
  state.product.hasMoreToFetch;
Enter fullscreen mode Exit fullscreen mode

For example, the selectIsFetchingProducts selector takes the Redux state and returns the isFetchingProducts state (the one we set up in the product reducer).

Product Sagas

Sagas can be thought of as event listeners as they watch the Redux store for any specified actions and call a specified callback when that action(s) occurs. In the callback, we can perform asynchronous code such as API requests and even dispatch additional actions.

Let's start by creating 2 sagas - one to watch for the latest "START_INITIAL_PRODUCTS_FETCH" action type and the other for the latest "START_LOADING_MORE_PRODUCTS" action type.

import PRODUCT_ACTION_TYPES from "./product.action.types";
import { takeLatest, put, call, all, select } from "redux-saga/effects";

function* watchProductsFetchStart() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_INITIAL_PRODUCTS_FETCH,
    fetchProducts
  );
}

function* watchLoadMoreProducts() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_LOADING_MORE_PRODUCTS,
    fetchMoreProducts
  );
}
Enter fullscreen mode Exit fullscreen mode

We will define the fetchMoreProducts and fetchProducts functions soon.

To reduce the changes we need to make to the root saga, it is a good practice to create a main saga export instead of exporting each saga (i.e. watchProductsFetchStart and watchLoadMoreProducts).

import PRODUCT_ACTION_TYPES from "./product.action.types";
import { takeLatest, put, call, all, select } from "redux-saga/effects";

function* watchProductsFetchStart() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_INITIAL_PRODUCTS_FETCH,
    fetchProducts
  );
}

function* watchLoadMoreProducts() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_LOADING_MORE_PRODUCTS,
    fetchMoreProducts
  );
}

export default function* productSagas() {
  yield all([call(watchProductsFetchStart), call(watchLoadMoreProducts)]);
}
Enter fullscreen mode Exit fullscreen mode

To create the fetchProducts function used above, we will import the action creators and selectors we created as we will need to access the Redux state and dispatch actions within fetchProducts.

import { takeLatest, put, call, all, select } from "redux-saga/effects";
import {
  initialProductsFetchFail,
  initialProductsFetchSuccess,
  noMoreProductsToLoad
} from "./product.actions";
import {
  getProducts
} from "../../firebase-utils/firebase.product_utils";
import {
  selectProductsPerPage
} from "./product.selectors";

function* fetchProducts() {
  try {
    const productsPerPage = yield select(selectProductsPerPage);
    const { products, lastVisibleDoc } = yield getProducts(productsPerPage);
    if (!products.length) {
      return yield put(noMoreProductsToLoad());
    }
    yield put(initialProductsFetchSuccess(products, lastVisibleDoc));
  } catch (err) {
    yield put(
      initialProductsFetchFail("There was a problem displaying the products.")
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

In the function above, we are getting the productsPerPage state using the selectProductsPerPage selector and passing it to getProducts. Although we have not implemented getProducts yet, it is evident that it takes the number of products we want to fetch initially and returns an object of the form { products, lastVisibleDoc }. If there are no products, we dispatch the noMoreProductsToLoad action creator, which then changes the hasMoreToFetch state to true. Otherwise, we dispatch the initialProductsFetchSuccess action creator which updates the lastVisibleDoc and products state.

Now, anytime an action with the type of "START_INITIAL_PRODUCTS_FETCH" is dispatched, the fetchProducts saga will run and update the Redux store accordingly.

The fetchMoreProducts function will be similar to fetchProducts except we will call the getMoreProducts function and pass it the lastVisibleDoc and productsPerPage state. The getMoreProducts will also be implemented later on.

import { takeLatest, put, call, all, select } from "redux-saga/effects";
import {
  initialProductsFetchFail,
  initialProductsFetchSuccess,
  loadingMoreProductsFail,
  loadingMoreProductsSuccess,
  noMoreProductsToLoad
} from "./product.actions";
import {
  getProducts,
  getMoreProducts
} from "../../firebase-utils/firebase.product_utils";
import {
  selectProductsPerPage,
  selectLastVisibleDoc
} from "./product.selectors";

function* fetchMoreProducts() {
  try {
    const productsPerPage = yield select(selectProductsPerPage);
    const lastDoc = yield select(selectLastVisibleDoc);
    const { products: newProducts, lastVisibleDoc } = yield getMoreProducts(
      lastDoc,
      productsPerPage
    );
    if (!newProducts.length) {
      return yield put(noMoreProductsToLoad());
    }
    yield put(loadingMoreProductsSuccess(newProducts, lastVisibleDoc));
  } catch (err) {
    yield put(
      loadingMoreProductsFail("There was a problem loading more products.")
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

For reference, here is the complete saga code:

import PRODUCT_ACTION_TYPES from "./product.action.types";
import { takeLatest, put, call, all, select } from "redux-saga/effects";
import {
  initialProductsFetchFail,
  initialProductsFetchSuccess,
  loadingMoreProductsFail,
  loadingMoreProductsSuccess,
  noMoreProductsToLoad
} from "./product.actions";
import {
  getProducts,
  getMoreProducts
} from "../../firebase-utils/firebase.product_utils";
import {
  selectProductsPerPage,
  selectLastVisibleDoc
} from "./product.selectors";

function* fetchProducts() {
  try {
    const productsPerPage = yield select(selectProductsPerPage);
    const { products, lastVisibleDoc } = yield getProducts(productsPerPage);
    if (!products.length) {
      return yield put(noMoreProductsToLoad());
    }
    yield put(initialProductsFetchSuccess(products, lastVisibleDoc));
  } catch (err) {
    yield put(
      initialProductsFetchFail("There was a problem displaying the products.")
    );
  }
}

function* fetchMoreProducts() {
  try {
    const productsPerPage = yield select(selectProductsPerPage);
    const lastDoc = yield select(selectLastVisibleDoc);
    const { products: newProducts, lastVisibleDoc } = yield getMoreProducts(
      lastDoc,
      productsPerPage
    );
    if (!newProducts.length) {
      return yield put(noMoreProductsToLoad());
    }
    yield put(loadingMoreProductsSuccess(newProducts, lastVisibleDoc));
  } catch (err) {
    yield put(
      loadingMoreProductsFail("There was a problem loading more products.")
    );
  }
}

function* watchProductsFetchStart() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_INITIAL_PRODUCTS_FETCH,
    fetchProducts
  );
}

function* watchLoadMoreProducts() {
  yield takeLatest(
    PRODUCT_ACTION_TYPES.START_LOADING_MORE_PRODUCTS,
    fetchMoreProducts
  );
}

export default function* productSagas() {
  yield all([call(watchProductsFetchStart), call(watchLoadMoreProducts)]);
}
Enter fullscreen mode Exit fullscreen mode

Recap

Now that we are done with the Redux portion, anytime we dispatch the startInitialProductsFetch and the startLoadingMoreProducts action creators, the product sagas will call the getProducts and getMoreProducts functions and dispatch additional actions to update the product states we defined in the product reducer.

Firebase Paginated Queries

For this portion, we will need the following dependency:

Before we can use Firestore, we need to configure Firebase like so:

import firebase from "firebase/app";
import "firebase/firestore";

const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL,
  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.REACT_APP_FIREBASE_APP_ID
};
firebase.initializeApp(firebaseConfig);

export const firestore = firebase.firestore();
export default firebase;
Enter fullscreen mode Exit fullscreen mode

If you are confused about the configuration above, refer to https://dev.to/itnext/react-with-firebase-firestore-setup-4ch3.

We will now implement the getProducts and getMoreProducts function we used when we wrote the product sagas.

import { firestore } from "./firebase.config"; //We exported this earlier in the Firebase configuration
const productCollectionRef = firestore.collection("products");

export const getProducts = async (productsPerPage) => {
  const paginatedProductsQuery = productCollectionRef
    .orderBy("name", "asc")
    .limit(productsPerPage);
  const productsAndLastVisibleDoc = await excutePaginatedProductQuery(
    paginatedProductsQuery
  );
  return productsAndLastVisibleDoc;
};
Enter fullscreen mode Exit fullscreen mode

As with any Firestore query, we first need a reference to a Firestore collection. Since we will be using the product collection ref in both getProducts and getMoreProducts, we should define it globally.

In the getProducts function, we are querying the product collection and ordering the documents by name in ascending order. Then we are selecting the first productsPerPage documents. Next, we call excutePaginatedProductQuery, which takes a paginated query, executes it, returns an object of the form: { products, lastVisibleDoc } and then we return this object from getProducts.

To improve code reusability, we are creating the excutePaginatedProductQuery function as the only difference between the getProducts and getMoreProducts function is the query we execute.

export const executePaginatedQuery = async (paginatedQuery) => {
  const querySnapshot = await paginatedQuery.get();
  const docSnapshots = querySnapshot.docs;
  const lastVisibleDoc = docSnapshots[docSnapshots.length - 1];
  return { lastVisibleDoc, docSnapshots };
};

export const excutePaginatedProductQuery = async (paginatedProductQuery) => {
  try {
    const {
      lastVisibleDoc,
      docSnapshots: productSnapshots
    } = await executePaginatedQuery(paginatedProductQuery);
    const products = productSnapshots.map((ps) => ({
      id: ps.id,
      ...ps.data()
    }));
    return { products, lastVisibleDoc };
  } catch (err) {
    return { products: [], lastVisibleDoc: null };
  }
};
Enter fullscreen mode Exit fullscreen mode

The executePaginatedProductQuery function executes a query and returns the products and the last document snapshot from the query result.

Since we can abstract the process of executing a query, getting the document snapshots, and the last document snapshot, we have moved that logic to the executePaginatedQuery and called it within the executePaginatedProductQuery function.

"Why do we need the last document snapshot?"

Many databases have their own ways of skipping documents to get the next documents. In Firestore, we use the startAfter or startAt methods and pass a document snapshot to define the starting point for a query. We will see an example shortly.

So far, we have a function (getProducts) that queries the product collection and gets the first 6 products.

To get the next 6 products, we need to another function that uses the startAfter method.

export const getMoreProducts = async (lastVisibleDoc, productsPerPage) => {
  const nextProductsQuery = productCollectionRef
    .orderBy("name", "asc")
    .startAfter(lastVisibleDoc)
    .limit(productsPerPage);
  const productsAndLastVisibleDoc = await excutePaginatedProductQuery(
    nextProductsQuery
  );
  return productsAndLastVisibleDoc;
};
Enter fullscreen mode Exit fullscreen mode

From above, it is clear that the getMoreProducts function is similar to the getProducts function except for the query. More specifically, the query uses the startAfter method which skips all the product documents before the lastVisibleDoc.

For reference, here is the complete code for this portion.

import { firestore } from "./firebase.config";
const productCollectionRef = firestore.collection("products");

export const executePaginatedQuery = async (paginatedQuery) => {
  const querySnapshot = await paginatedQuery.get();
  const docSnapshots = querySnapshot.docs;
  const lastVisibleDoc = docSnapshots[docSnapshots.length - 1];
  return { lastVisibleDoc, docSnapshots };
};

export const excutePaginatedProductQuery = async (paginatedProductQuery) => {
  try {
    const {
      lastVisibleDoc,
      docSnapshots: productSnapshots
    } = await executePaginatedQuery(paginatedProductQuery);
    const products = productSnapshots.map((ps) => ({
      id: ps.id,
      ...ps.data()
    }));
    return { products, lastVisibleDoc };
  } catch (err) {
    return { products: [], lastVisibleDoc: null };
  }
};

export const getProducts = async (productsPerPage) => {
  const paginatedProductsQuery = productCollectionRef
    .orderBy("price")
    .limit(productsPerPage);
  const productsAndLastVisibleDoc = await excutePaginatedProductQuery(
    paginatedProductsQuery
  );
  return productsAndLastVisibleDoc;
};

export const getMoreProducts = async (lastVisibleDoc, productsPerPage) => {
  const nextProductsQuery = productCollectionRef
    .orderBy("price")
    .startAfter(lastVisibleDoc)
    .limit(productsPerPage);
  const productsAndLastVisibleDoc = await excutePaginatedProductQuery(
    nextProductsQuery
  );
  return productsAndLastVisibleDoc;
};
Enter fullscreen mode Exit fullscreen mode

Recap

Going back to why we considered the initial product fetch different from the subsequent product fetches, now that we have the getProducts and getMoreProducts function, the reason should be more clear. Put simply, when we make the initial request we cannot use the startAfter method as the last document snapshot is null. So, we need to make the initial product request, update the lastVisibleDoc state, and use that when fetching the next products.

usePaginationOnIntersection hook

The logic we have implemented so far will only work once the startInitialProductsFetch and startLoadingMoreProducts action creators are dispatched.

We can dispatch the startInitialProductsFetch action once a component mounts. But for the startLoadingMoreProducts action, we need to dispatch that each time the user has scrolled to the last product.

To do that, we can use the Intersection Observer. The Intersection Observer can run a callback once a specified DOM element appears on the screen.

In other words, we just need to observe the last product in the products state and dispatch the startLoadingMoreProducts action once it appears on the screen. Although we could put this logic in a component, this will reduce code reusability so instead we will create a hook.

The hook will have the following parameters:

  • fetchMore
    • a function to call once an DOM element appears on screen
  • isFetchingMore
    • Indicates whether more products are already being fetched
  • hasMoreToFetch
    • Indicates whether there are more products to fetch
  • options
    • When creating a new Intersection Observer instance, we can pass an options object. For example, we can set the threshold to 0.5, which will trigger the fetchMore function when the element is 50% visible.
import { useRef, useCallback } from "react";

const DEFAULT_OPTIONS = { threshold: 0.9 };

const usePaginationOnIntersection = (
  fetchMore,
  isFetchingMore,
  hasMoreToFetch,
  options = DEFAULT_OPTIONS
) => {
  const observer = useRef();
  const triggerPaginationOnIntersection = useCallback(
    (elementNode) => {
      if (isFetchingMore) return;
      //Removes the previously observed DOM node before observing another
      if (observer.current) {
        observer.current.disconnect();
      }
      if (!hasMoreToFetch) return;
      observer.current = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) {
          fetchMore();
        }
      }, options);
      if (elementNode) {
        observer.current.observe(elementNode);
      }
    },
    [isFetchingMore, fetchMore, hasMoreToFetch]
  );

  return triggerPaginationOnIntersection;
};

export default usePaginationOnIntersection;
Enter fullscreen mode Exit fullscreen mode

In the code above, we are using these hooks from React in the following way:

  • useRef
    • To store a DOM reference to the element we are going to observe
  • useCallback
    • To return a memoized function for performance reasons.

The triggerPaginationOnIntersection memoized function attaches a new Intersection Observer to the current property of the observer variable. Then it observes the DOM node passed to the function using the observe method (we can use this because current property is an Intersection Observer object). Doing this will trigger the fetchMore function whenever the elementNode appears on the screen.

Conclusion

Now the last thing remaining is to get the state from the Redux store so we can display the products and dispatch the actions to fetch products.

To get the state, we will use the selectors we created earlier.

import React, { useEffect } from "react";

import { connect } from "react-redux";
import {
  selectHasMoreProductsToFetch,
  selectIsFetchingProducts,
  selectProducts
} from "./redux/product/product.selectors";
import {
  startInitialProductsFetch
} from "./redux/product/product.actions";

function App({
  products,
  fetchProducts,
  fetchMoreProducts,
  hasMoreProductsToFetch,
  isFetchingProducts
}) {
  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]);

  return (
    <section>
      <h1>Products</h1>
      <div>
        {(products || []).map((product, index) => (
          <div
            key={product.id}
          >
            <span>Name: {product.name}</span>
            <span>Price: ${product.price}</span>
          </div>
        ))}
        {isFetchingProducts && <p>Loading...</p>}
      </div>
    </section>
  );
}

const mapStateToProps = (state) => ({
  products: selectProducts(state),
  isFetchingProducts: selectIsFetchingProducts(state),
  hasMoreProductsToFetch: selectHasMoreProductsToFetch(state)
});

const mapDispatchToProps = (dispatch) => ({
  fetchProducts: () => dispatch(startInitialProductsFetch()),
  fetchMoreProducts: () => dispatch(startLoadingMoreProducts())
});

export default connect(mapStateToProps, mapDispatchToProps)(App);
Enter fullscreen mode Exit fullscreen mode

In the component above, we are dispatching the startInitialProductsFetch action when the component mounts. Consequently, this will run the fetchProducts and query Firestore for the first 6 products.

To load more products once the user sees the last product, we can use the usePaginationOnIntersection hook we created.

If you remember correctly, the hook returns a memoized function that takes a DOM node as an argument. To pass a DOM node to the function, a shorthand we can use is to pass the function to the ref attribute if it is the last product in the products state (we only want to fetch more products once the user sees the last product).

import React, { useEffect } from "react";

import { connect } from "react-redux";
import {
  selectHasMoreProductsToFetch,
  selectIsFetchingProducts,
  selectProducts
} from "./redux/product/product.selectors";
import {
  startInitialProductsFetch,
  startLoadingMoreProducts
} from "./redux/product/product.actions";
import usePaginationOnIntersection from "./hooks/usePaginationOnIntersection.hook";

function App({
  products,
  fetchProducts,
  fetchMoreProducts,
  hasMoreProductsToFetch,
  isFetchingProducts
}) {
  const fetchMoreOnIntersection = usePaginationOnIntersection(
    fetchMoreProducts,
    isFetchingProducts,
    hasMoreProductsToFetch
  );

  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]);

  return (
    <section>
      <h1>Products</h1>
      <div>
        {(products || []).map((product, index) => (
          <div
            key={product.id}
            ref={
              index + 1 === products.length
                ? fetchMoreOnIntersection
                : undefined
            }
          >
            <span>Name: {product.name}</span>
            <span>Price: ${product.price}</span>
          </div>
        ))}
        {isFetchingProducts && <p>Loading...</p>}
      </div>
    </section>
  );
}

const mapStateToProps = (state) => ({
  products: selectProducts(state),
  isFetchingProducts: selectIsFetchingProducts(state),
  hasMoreProductsToFetch: selectHasMoreProductsToFetch(state)
});

const mapDispatchToProps = (dispatch) => ({
  fetchProducts: () => dispatch(startInitialProductsFetch()),
  fetchMoreProducts: () => dispatch(startLoadingMoreProducts())
});

export default connect(mapStateToProps, mapDispatchToProps)(App);
Enter fullscreen mode Exit fullscreen mode

Now anytime the user scrolls to the last product, the following events will happen if hasMoreToFetch is true:

  1. startLoadingMoreProducts action will be dispatched
  2. products state in Redux store will update
  3. Component will re-render
  4. A new Intersection Observer will be attached to last product and the previous observed element will be removed
  5. Steps 1-4 will be repeated until hasMoreToFetch is false
💖 💪 🙅 🚩
chandrapantachhetri
Chandra Panta Chhetri

Posted on January 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related