A product catalog, support ticket dashboard, or employee directory can quickly grow beyond a few dozen records. Showing every item at once slows the browser, creates a long page, and makes the interface harder to use.
That is where an infinite scroll component in React helps. Instead of loading everything up front, the frontend loads a small batch, then fetches another batch when the user reaches the bottom of the list.
Below, you will build a reusable infinite scroll pattern for a React 18+ employee directory using functional components, hooks, browser APIs, and a paginated API request.
How an Infinite Scroll Component in React Works
An infinite scroll component loads more list items as the user moves through a page. It is common in customer portals, sales dashboards, activity feeds, and product listing pages.
The basic flow looks like this:
- Render the first page of employees.
- Place a small invisible element after the last employee card.
- Watch that element with
IntersectionObserver. - When the element enters the browser viewport, fetch the next page.
- Add the new employees to the existing state.
- Stop requesting data when the API has no more results.
This approach is usually better than listening to the browser scroll event on every movement. IntersectionObserver lets the browser tell your React component when the target element becomes visible.
Before building this feature, make sure you are comfortable creating React components and passing props in React. You will use both throughout this example.
Create the Employee Card Component
Start with a focused component that displays one employee. A component is a reusable piece of UI, while props are values passed into that component.
Create EmployeeCard.jsx:
function EmployeeCard({ employee }) {
return (
<article className="employee-card">
<h3>{employee.name}</h3>
<p>{employee.role}</p>
<p className="employee-meta">{employee.department}</p>
</article>
);
}
export default EmployeeCard;This component receives one employee object through props and returns JSX, which is the HTML-like syntax React uses to describe the UI. Keeping the card separate makes the main list easier to read and lets you reuse it elsewhere in your React application.
Build the Infinite Scroll Component in React
Now create EmployeeDirectory.jsx. This component manages the employee list, current page, loading state, errors, and the observer target.
import { useCallback, useEffect, useRef, useState } from "react";
import EmployeeCard from "./EmployeeCard";
const PAGE_SIZE = 12;
function EmployeeDirectory() {
const [employees, setEmployees] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const observerRef = useRef(null);
useEffect(() => {
const controller = new AbortController();
async function loadEmployees() {
if (!hasMore) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/employees?page=${page}&limit=${PAGE_SIZE}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Unable to load employees.");
}
const data = await response.json();
setEmployees((currentEmployees) => [
...currentEmployees,
...data.employees,
]);
setHasMore(data.employees.length === PAGE_SIZE);
} catch (requestError) {
if (requestError.name !== "AbortError") {
setError(requestError.message);
}
} finally {
setIsLoading(false);
}
}
loadEmployees();
return () => controller.abort();
}, [page, hasMore]);
const lastEmployeeRef = useCallback(
(node) => {
if (isLoading || !hasMore) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setPage((currentPage) => currentPage + 1);
}
},
{ rootMargin: "200px" }
);
if (node) {
observerRef.current.observe(node);
}
},
[isLoading, hasMore]
);
return (
<section className="employee-directory">
<h1>Employee Directory</h1>
<div className="employee-list">
{employees.map((employee, index) => {
const isLastEmployee = index === employees.length - 1;
return (
<div
key={employee.id}
ref={isLastEmployee ? lastEmployeeRef : null}
>
<EmployeeCard employee={employee} />
</div>
);
})}
</div>
{isLoading && <p className="status-message">Loading employees...</p>}
{error && (
<div className="error-message">
<p>{error}</p>
<button onClick={() => setPage((currentPage) => currentPage + 1)}>
Try again
</button>
</div>
)}
{!hasMore && employees.length > 0 && (
<p className="status-message">You have reached the end of the directory.</p>
)}
</section>
);
}
export default EmployeeDirectory;This is the core infinite scroll component. The useState hook stores values that can change during the component lifecycle, such as the loaded employees, page number, and loading status.
The useEffect hook runs the API request whenever page changes. It adds the returned employee records to the existing array instead of replacing the old list.
The AbortController is important. If a user navigates away while a request is still running, the cleanup function cancels that request. This prevents outdated results from updating the component after it unmounts.
Understand the Observer Logic
The lastEmployeeRef callback is attached only to the final employee in the list. When that card approaches the viewport, the observer updates the page number.
const lastEmployeeRef = useCallback(
(node) => {
if (isLoading || !hasMore) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setPage((currentPage) => currentPage + 1);
}
},
{ rootMargin: "200px" }
);
if (node) {
observerRef.current.observe(node);
}
},
[isLoading, hasMore]
);
The useCallback hook keeps this callback stable until isLoading or hasMore changes. Without it, React could recreate the function unnecessarily, which can lead to repeated observer setup.
The rootMargin: "200px" setting tells the observer to load records before the user reaches the exact bottom. In a real customer-facing website, this makes scrolling feel smoother because the next batch begins loading early.
Pro Tip: In my experience, the most common infinite scroll bug is allowing multiple requests for the same page. Always block the observer while
isLoadingis true, and disconnect the old observer before creating a new one.
Return Paginated Data from Your API
Your frontend needs an endpoint that understands pagination. Pagination means returning data in smaller numbered chunks rather than sending an entire database table.
A typical response from /api/employees?page=1&limit=12 could look like this:
{
"employees": [
{
"id": "emp-101",
"name": "Asha Patel",
"role": "Support Specialist",
"department": "Customer Success"
},
{
"id": "emp-102",
"name": "Rahul Mehta",
"role": "Frontend Developer",
"department": "Engineering"
}
]
}The component checks whether the API returned a full page of results. If it receives fewer than 12 records, it sets hasMore to false, which stops future requests.
If your React frontend connects to a backend application, keep the pagination logic on the server. The browser should request only the records it currently needs. For example, a Django backend can expose a paginated endpoint using the same patterns used to create an API in Django.
Add Basic Styling for the List
You do not need complex styling to test infinite scrolling. Add the following CSS to make the employee cards readable in a browser-based frontend.
.employee-directory {
max-width: 760px;
margin: 40px auto;
padding: 0 16px;
}
.employee-list {
display: grid;
gap: 12px;
}
.employee-card {
padding: 16px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #ffffff;
}
.employee-card h3 {
margin: 0 0 6px;
}
.employee-card p {
margin: 4px 0;
}
.employee-meta {
color: #666666;
}
.status-message,
.error-message {
padding: 16px;
text-align: center;
}
.error-message {
color: #9b1c1c;
}This CSS creates a simple single-column directory. The actual scroll happens on the main browser page, so you do not need a fixed-height container for this example.
If you need a scrollable panel inside an admin dashboard, set a fixed height and overflow-y: auto on the list container. Then pass that container as the root option when creating IntersectionObserver.
Add a Reset When Filters Change
Most production directories include search, department filters, or status filters. When a filter changes, reset the current data before loading page one again.
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
setEmployees([]);
setPage(1);
setHasMore(true);
}, [searchTerm]);This state management pattern prevents old results from mixing with newly filtered records. Your API request should also include the search term.
const response = await fetch(
`/api/employees?page=${page}&limit=${PAGE_SIZE}&search=${encodeURIComponent(
searchTerm
)}`,
{ signal: controller.signal }
);
You can see the output in the screenshot below.

encodeURIComponent() safely formats user-entered text for a URL query string. It is especially helpful when a search value contains spaces, ampersands, or other special characters.
When the filter UI grows, use controlled inputs and clear event handlers. You can review a practical example of handling React events before connecting search fields to your directory.
Improve Performance for Larger Lists
Infinite scrolling reduces the amount of data loaded from the API, but it does not automatically limit the number of rendered cards. After a user loads hundreds of employees, React still has to keep those cards in the DOM.
For a moderate dashboard list, the component above works well. For very large datasets, consider these improvements:
- Use server-side filters and sorting so the browser receives fewer records.
- Avoid expensive calculations inside
employees.map(). - Keep employee cards small and focused.
- Memoize expensive derived values with useMemo only when measurement shows it helps.
- Use list virtualization when the screen may render thousands of rows.
Also avoid unnecessary renders in child components. If your card receives stable props but still renders too often, review ways to prevent a React component from re-rendering.
Things to Keep in Mind
- Use stable list keys: Use a unique employee ID for the React
key, not the array index. Stable keys help React update the correct card when the list changes. - Block duplicate requests: Check
isLoadingbefore creating a new observer or loading another page. Otherwise, fast scrolling can trigger overlapping API requests. - Handle loading and errors: Show a loading message, a retry action, and a clear end-of-results message. Good error handling makes a dashboard feel reliable.
- Clean up side effects: Abort unfinished fetch calls in the
useEffectcleanup function. This prevents stale requests from changing state after navigation. - Reset data for new filters: Clear the loaded list and reset the page whenever a search term, sort order, or department filter changes.
- Do not expose secrets: Keep database credentials and private API keys on the backend. A React frontend runs in the user’s browser, where its code can be inspected.
Frequently Asked Questions
What is infinite scrolling in React?
Infinite scrolling is a UI pattern that loads more content as a user reaches the end of a list. In React, you usually manage the loaded items with state and trigger a new API request when a target element enters the viewport.
Do I need a library for infinite scroll in React?
No. You can build an infinite scroll component with the browser IntersectionObserver API and React hooks. A library may help in a complex application, but the native approach is lightweight and gives you full control.
Why does my React infinite scroll load the same data twice?
This usually happens when the observer triggers again while a previous request is still active. Check isLoading, disconnect the previous observer, and ensure your backend returns separate pages for each request.
How do I stop infinite scroll when there are no more records?
Track a hasMore Boolean value in state. Set it to false when the API returns fewer records than your page size or returns an explicit nextPage value of null.
Is infinite scrolling better than pagination?
It depends on the task. Infinite scroll works well for browsing feeds, product cards, and activity timelines, while numbered pagination is often better for searchable business tables where users need to jump to a specific page.
How do I add a retry button after an API request fails?
Store the request message in an error state variable and conditionally render a button. The button can retry the failed page request, but make sure it does not advance to the next page by mistake.
An infinite scroll component in React is mostly about coordinating pagination, browser visibility, and reliable loading states. Start with a small list, test the API response and observer behavior, then add filters and performance improvements once the basics work. I hope you found this article helpful.
You May Also Like
- Set up your first React application
- Understand React component-based architecture
- Manage state with React hooks
- Fix API data not displaying in a React component
- Build a custom table in React

Bijay Kumar is an experienced Python and AI professional who enjoys helping developers learn modern technologies through practical tutorials and examples. His expertise includes Python development, Machine Learning, Artificial Intelligence, automation, and data analysis using libraries like Pandas, NumPy, TensorFlow, Matplotlib, SciPy, and Scikit-Learn. At PythonGuides.com, he shares in-depth guides designed for both beginners and experienced developers. More about us.