Infinite Scroll  With Firebase, React, Intersection Observer & Redux Saga
Tags: react, redux, tutorial, javascript
Published: 2021-01-07 00:01:14

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.

<figure>
  <img src="https://dev-to-uploads.s3.amazonaws.com/i/hgqhqyol9hp5pe6l0hzj.png" alt="Trulli">
  <figcaption>Figure 1: Loading more products once user scrolls to the end</figcaption>
</figure>

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

Prerequisite 
- Basic knowledge of [Redux](https://redux.js.org/introduction/getting-started), [Redux Saga](https://redux-saga.js.org/docs/introduction/BeginnerTutorial.html), [React](https://reactjs.org/docs/getting-started.html)
- Basis understanding of [Firestore](https://firebase.google.com/docs/firestore/quickstart) 
- Basic understanding of [generator functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) as it will be used with Redux Saga

### Redux

To setup the Redux portion, we will need the following **dependencies**:
- [Redux](https://www.npmjs.com/package/redux)
- [React-Redux](https://www.npmjs.com/package/react-redux)
- [Redux-Saga](https://www.npmjs.com/package/redux-saga)

#### 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](https://redux-saga.js.org/docs/advanced/RootSaga.html), and the [Redux store](https://redux.js.org/api/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
});
```

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)]);
}
```
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);
```

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](https://redux-resource.js.org/api-reference/action-types), [action creators](https://redux.js.org/understanding/thinking-in-redux/glossary#action-creator), [selectors](https://medium.com/@matthew.holman/what-is-a-redux-selector-a517acee1fe8), and a [reducer](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers#writing-reducers) so we can manage independent parts of the [Redux store](https://redux.js.org/api/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;
```

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](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow#actions). 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
});
```

#### 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
};
```

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;
```

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;
```

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;
```

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
  );
}
```

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)]);
}
```

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.")
    );
  }
}
```

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.")
    );
  }
}
```

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)]);
}
```

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**:
- [Firebase](https://www.npmjs.com/package/firebase)

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;
```

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;
};
```

As with any Firestore query, we first need a reference to a [Firestore collection](https://firebase.google.com/docs/firestore/data-model#collections). 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 };
  }
};
```

The ``executePaginatedProductQuery`` function executes a query and returns the products and the last [document snapshot](https://firebase.google.com/docs/reference/node/firebase.firestore.QueryDocumentSnapshot) 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;
};
```

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;
};
```
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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). 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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer) 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;
```

In the code above, we are using these hooks from React in the following way:
- [``useRef``](https://reactjs.org/docs/hooks-reference.html#useref)
 - To store a DOM reference to the element we are going to observe
- [``useCallback``](https://reactjs.org/docs/hooks-reference.html#usecallback)
 - To return a [memoized](https://en.wikipedia.org/wiki/Memoization#:~:text=In%20computing%2C%20memoization%20or%20memoisation,the%20same%20inputs%20occur%20again.) 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);
```

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](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) 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);
```

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