This question was asked in Adobe’s MTS 2 frontend interview, where it was asked to create a custom hook for Infinite scroll with cursor-based pagination.
There are two types of navigation, offset-based and cursor-based.
Pagination
Offset-based
In offset-based navigation we can jump to any page for navigation.
GET https://rickandmortyapi.com/api/character/page/10 // jump directly to the 10th page
Cursor-based
In cursor-based navigation we get pointer to the next list of items, so we go to the list where we have last left from. The cusor based navigation can be any direction, forward or backward, with the next and prev props.
GET https://rickandmortyapi.com/api/character
{
"info": {
"count": 826,
"pages": 42,
"next": "https://rickandmortyapi.com/api/character/?page=2",
"prev": null
},
"results": [
// ...
]
}
Here the next contains the whole URL, but this can change depending upon the implementation.
Infinite scrolling
Infinite-scroll is a navigation and performance optimization technique, in which we provide infinite scrolling experience to the user, where when the user nears to the end of the current list, we fetch the new list and thus the scroll size increases.
This helps to progressively render the large datasets with the fetching the next list at the end.
We will create a custom hook in React, that will support the infinite scroll and with the cursor-based pagination.
Solution
I am going to use this dummy API https://rickandmortyapi.com/api/character that supports the cursor-based pagination. It will return cursor details and the result.
{
"info": {
"count": 826,
"pages": 42,
"next": "https://rickandmortyapi.com/api/character/?page=2",
"prev": null
},
"results": [
// ...
]
}
We are going to maintain 5 different states in our hook.
- currentUrl – This will store the next pointer of the cursor based pagination.
info.nextfrom the response. - triggerAPICall – This flag will help to trigger the next api call, we will update it this when the scroll nears to end and will reset it once api call is finished.
- results – Combined result from all the API call.
- isLoading – Whether an API call is in pipeline.
- done – If we have fetched all the items from the list.
With the cursor based pagination, we do progressive fetching of data, which means we store all the results we have fetched.
import { useState, useEffect, useTransition } from "react";
const URL = "https://rickandmortyapi.com/api/character";
const delay = (duration = 0) =>
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
export const useInfiniteScroll = (ref) => {
const [currentUrl, setCurrentUrl] = useState(URL);
const [triggerAPICall, setTriggerAPICall] = useState(true);
const [results, setResults] = useState([]);
const [isLoading, startTransition] = useTransition();
const [done, setDone] = useState(false);
useEffect(() => {
const fetchAction = async (url) => {
try {
await delay(3000);
const _response = await fetch(url);
const response = await _response.json();
const { info, results } = response;
if (info.next) {
setCurrentUrl(info.next);
setResults((prev) => {
return [...prev, ...results];
});
} else {
setDone(true);
}
} catch (e) {
console.error("Something went wrong", e);
} finally {
setTriggerAPICall(false);
}
};
if (triggerAPICall) {
startTransition(async () => {
await fetchAction(currentUrl);
});
}
}, [currentUrl, triggerAPICall]);
return { isLoading, results, done };
};
We have set the triggerAPICall to true by default as this will trigger the first API call on the mount of the hook. We also have a delay function as the API call is resolved instantly thus, this will helps to view the loading state.
We are using the useTransition() hook to handle the loading state.
startTransition(async () => {
await fetchAction(currentUrl);
});
This will show the loading as true until await fetchAction(currentUrl); is completed.
We can use this hook to render the result from the first hook.
import "./styles.css";
import { useInfiniteScroll } from "./useInfiniteScroll";
import { useRef } from "react";
export default function App() {
const areaRef = useRef();
const { isLoading, results } = useInfiniteScroll(areaRef);
return (
<div className="App">
<div className="wrapper" ref={areaRef}>
{results.map((e) => (
<Characters key={e.id} {...e} />
))}
</div>
{isLoading && <div className="loading">Loading...</div>}
</div>
);
}
const Characters = ({ name, status, species, image }) => {
return (
<div>
<div>
<img src={image} alt={name} />
<div>
<p>Name: {name}</p>
<p>Status: {status}</p>
<p>Species: {species}</p>
</div>
</div>
</div>
);
};
.wrapper {
display: flex; /* Makes the div a flex container */
flex-wrap: wrap; /* Allows columns to wrap to the next line if needed */
justify-content: space-around;
gap: 10px;
text-align: center;
}
.wrapper > div {
flex: 1 1 calc(50% - 30px);
padding: 10px;
border: 1px solid;
box-shadow: 0 3px 6px gray;
overflow: hidden;
}
.loading {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
font-size: 3em;
color: black;
background-color: rgba(225, 146, 43, 0.5);
}

If you notice our useInfiniteScroll(ref) accepts a ref. This ref you can attach to your list area and then listen to the scroll event to add the infinite scroll.
I am handling it on windows, but you can update the code and handle on the ref as well.
useEffect(() => {
const onScroll = () => {
// if scrolled to the bottom
if (
window.innerHeight + window.scrollY >=
window.document.body.offsetHeight - 50
) {
// update the state
setTriggerAPICall(true);
}
};
// scroll event
window.addEventListener("scroll", onScroll);
// memory cleanup, remove listener
return () => window.removeEventListener("scroll", onScroll);
}, [ref]);
This is a basic calculation where we listen to the scroll event and check if we are nearing to the end of the scroll. If we are then update the setTriggerAPICall(true) which is the dependency to the other useEffect() hook that will make the API call with the new URL (cursor) and update the next pointer accordingly.
Putting everything together, this is how you can create the custom hook for infinite scroll with cursor-based pagination.
import { useState, useEffect, useTransition } from "react";
const URL = "https://rickandmortyapi.com/api/character";
const delay = (duration = 0) =>
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
export const useInfiniteScroll = (ref) => {
const [currentUrl, setCurrentUrl] = useState(URL);
const [triggerAPICall, setTriggerAPICall] = useState(true);
const [results, setResults] = useState([]);
const [isLoading, startTransition] = useTransition();
const [done, setDone] = useState(false);
useEffect(() => {
const fetchAction = async (url) => {
try {
await delay(3000);
const _response = await fetch(url);
const response = await _response.json();
const { info, results } = response;
if (info.next) {
setCurrentUrl(info.next);
setResults((prev) => {
return [...prev, ...results];
});
} else {
setDone(true);
}
} catch (e) {
console.error("Something went wrong", e);
} finally {
setTriggerAPICall(false);
}
};
if (triggerAPICall) {
startTransition(async () => {
await fetchAction(currentUrl);
});
}
}, [currentUrl, triggerAPICall]);
useEffect(() => {
const onScroll = () => {
// if scrolled to the bottom
if (
window.innerHeight + window.scrollY >=
window.document.body.offsetHeight - 50
) {
// update the state
setTriggerAPICall(true);
}
};
// scroll event
window.addEventListener("scroll", onScroll);
// memory cleanup, remove listener
return () => window.removeEventListener("scroll", onScroll);
}, [ref]);
return { isLoading, results, done };
};