Infinite scroll that doesn't fight the user
Pagination is easy. Infinite scroll is easy. Making one feel like the other - stable scroll position, a real end state, a back button that works - took me three attempts.
The first version worked in a demo and fell apart in review. Scroll to page four, open a listing, press back, and you were at the top of an empty list again. The data was in the cache; the browser just had nothing to scroll to yet.
Keep the pages, not the rows
The fix started with storing responses as pages rather than one flat array. TanStack Query’s useInfiniteQuery already does this, and once the cache is shaped like the requests, restoring a session becomes a matter of replaying how many pages were open rather than refetching everything.
The sentinel comes next. An Intersection Observer on a small element after the last card is more reliable than scroll maths, and it costs nothing when the list is short. Give it a root margin so the request starts before the reader reaches the bottom.
If the user can reach the end of your list, tell them they have. A spinner that never resolves is a bug with good manners.
The end state matters
Every infinite list needs three visible states: loading more, nothing more to load, and something went wrong with a way to retry. The third is the one that gets skipped, and it is the one people hit on a bad connection.
What finally made it feel calm was reserving space. Skeleton cards at the same height as real ones mean the page never jumps when data lands, and the scroll position you worked to restore stays where you put it.