Infinite Scroll With Firebase, React, Intersection Observer & Redux Saga
Chandra Panta Chhetri
Posted on January 7, 2021
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.
The code with all configurations can be found at https://github.com/Chandra-Panta-Chhetri/infinite-scroll-firebase-tutorial.
Prerequisite
- Basic knowledge of Redux, Redux Saga, React
- Basis understanding of Firestore
- Basic understanding of generator functions as it will be used with Redux Saga
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
});
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, 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;
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
});
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:
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. 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 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. 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
to0.5
, which will trigger thefetchMore
function when the element is 50% visible.
- When creating a new Intersection Observer instance, we can pass an options object. For example, we can set the
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
- 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);
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);
Now anytime the user scrolls to the last product, the following events will happen if hasMoreToFetch
is true:
-
startLoadingMoreProducts
action will be dispatched -
products
state in Redux store will update - Component will re-render
- A new Intersection Observer will be attached to last product and the previous observed element will be removed
- Steps 1-4 will be repeated until
hasMoreToFetch
is false
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
October 15, 2019