Building Paginated Lists in Ionic with ion-infinite-scroll and ion-refresher
Learn how to combine Ionic’s ion-infinite-scroll and ion-refresher to build a paginated list that loads more data on scroll, handles errors, disables at the end of data, and addresses accessibility concerns.
28 May 2026, 08:10 UTC

Problem: loading long data sets without blocking the UI
When a list can grow to hundreds or thousands of items, fetching everything at once hurts performance and delays the first paint. Users expect to keep scrolling and see more content appear automatically, while still being able to pull‑to‑refresh to reset the view. Ionic provides two complementary components for this pattern: ion-infinite-scroll for loading more data as the user reaches the bottom, and ion-refresher for pull‑to‑refresh.
Thesis
By placing ion-infinite-scroll inside ion-content, using a threshold to fire ionInfinite, calling complete() after the async request resolves, and disabling the component when the server signals an empty page, you get a reliable infinite‑scroll experience that works across Ionic’s Angular, React, and Vue bindings. Pairing it with ion-refresher gives a full refresh‑and‑load‑more flow.
How the pieces work together
Scroll detection
Ionic’s ion-content creates its own scroll container. The ion-infinite-scroll component must be a descendant of that container (commonly the last child of a list) so it can listen to the internal scroll position. The threshold property determines how close to the bottom the user must be before ionInfinite fires; it accepts CSS units or percentages, e.g. threshold=\'20%\' or threshold=\'100px\'.
Event lifecycle
When the threshold is crossed, Ionic emits ionInfinite. The handler receives an event object that includes a complete() method. Calling this method hides the loading spinner and re‑arms the component for the next trigger. If you forget to call complete()—whether the request succeeds, fails, or returns no data—the spinner stays visible and further ionInfinite events stop firing.
End‑of‑data handling
Most paginated APIs return an empty array or a flag like hasMore: false when the last page is reached. In the handler you can set the disabled property of ion-infinite-scroll to true to prevent any further events. The spinner disappears automatically because the component is no longer active.
Worked example (Ionic React)
The following snippet shows a simple paginated list that fetches 10 items per page, uses a flag to avoid duplicate requests, appends results to state, and disables the infinite scroll when the server returns an empty page.
import { useState, useCallback } from 'react';
import { IonContent, IonList, IonItem, IonLabel, IonInfiniteScroll,
IonInfiniteScrollContent, IonRefresher, IonRefresherContent } from '@ionic/react';
function PaginatedList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchPage = useCallback(async (p) => {
setLoading(true);
try {
const resp = await fetch(`https://example.com/api/items?page=${p}&size=10`);
const data = await resp.json();
setItems(prev => [...prev, ...data]);
setHasMore(data.length > 0);
} finally {
setLoading(false);
}
}, []);
const handleInfinite = async (event) => {
if (loading || !hasMore) {
event.target.complete();
return;
}
await fetchPage(page + 1);
setPage(p => p + 1);
event.target.complete(); // always call complete after the async work
};
const handleRefresh = async (event) => {
setPage(1);
setItems([]);
setHasMore(true);
await fetchPage(1);
event.target.complete();
};
return (
{items.map((it, idx) => (
{it.name}
))}
{/* Infinite scroll placed as last child of IonContent */}
);
}
export default PaginatedList;
Key points:
- The infinite scroll is the last child of
IonContent, satisfying the scroll‑container requirement. threshold=\'20%\'triggers when the user is within 20 % of the bottom.- The
loadingflag prevents overlapping requests while a request is in flight. - After the fetch resolves,
event.target.complete()is called in both success and error paths (via thefinallyblock). - When
hasMorebecomes false, thedisabledproperty stops furtherionInfiniteevents. - The refresher resets page to 1, clears the list, and calls its own
complete()after the reload.
Trade‑offs and limitations
Infinite scroll keeps users in a fluid interaction flow, but it introduces accessibility concerns:
- Keyboard‑only users may never reach the bottom if the list is long, because focus does not automatically move with newly appended items.
- Screen‑reader users can miss content that appears after the initial load unless the application announces updates via ARIA live regions.
- Rendering thousands of DOM nodes can degrade performance and increase memory usage over a session.
A practical mitigation is to provide a fallback “Load more” button that appears when the infinite scroll is disabled or when the user prefers explicit actions. The button can trigger the same fetch logic, ensuring keyboard and assistive‑technology users retain control.
For very large datasets, consider integrating a virtualization library (e.g., @tanstack/react-virtual) instead of rendering every item. Ionic’s former ion-virtual-scroll was removed in recent major versions, so community solutions are the current path.
Actionable closing
To verify that your implementation behaves as expected:
- Run the app in a browser and scroll near the bottom; confirm the spinner appears and disappears after the request finishes.
- Disable the network or simulate a slow API; ensure the spinner stays visible until the request resolves or fails, and that
complete()is still called. - After the last page, check that the spinner no longer appears and that further scrolls do not trigger
ionInfinite. - Test on a real device or emulator via Capacitor to confirm the scroll container matches the browser behavior.
- Audit the page with an accessibility tool (e.g., axe) and verify that newly appended items are announced or that a “Load more” button is available.
By following the pattern above—proper placement, threshold handling, disciplined complete() calls, and end‑of‑data disabling—you get a reliable infinite‑scroll experience that works across Ionic bindings while keeping the door open for accessibility‑friendly alternatives.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.