A feed that loads more content as the user approaches the bottom, without janking scroll position.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 80-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Load the next page of items automatically when the user scrolls near the end of the list.
Show a loading indicator for in-flight fetches and a clear end-of-content state.
Prevent duplicate fetches from firing while one is already in flight.
Support pull-to-retry (or a retry button) if a page fetch fails.
Keep scroll position stable when new items are appended (no jump).
EDGE CASES
Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
An error on page 2 shouldn't discard the already-loaded page 1 content.
Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.
ACCESSIBILITY
Announce 'Loading more items' and 'End of results' via a polite live region so screen reader users get feedback the infinite trigger gives sighted users implicitly.
Ensure the load trigger doesn't rely solely on a mouse-only scroll gesture — keyboard users paging with Page Down/End must still be able to reach and trigger it.
Provide a manual 'Load more' button as a fallback/alternative to the automatic sentinel for predictability.
SUGGESTED APPROACH
Use an IntersectionObserver watching a sentinel element near the bottom of the list, rather than listening to scroll events directly, for performance and simplicity.
Guard the fetch with an isLoading flag checked before triggering, and track the current page/cursor in state.
Append new items immutably to the existing array; don't replace the whole list, which would reset scroll.
For truly large lists, combine with virtualization so DOM node count doesn't grow unbounded as pages accumulate.
EVALUATION RUBRIC
No duplicate or lost fetches under fast scrolling.
Scroll position remains stable when new items append.
Clear, distinct loading, error, and end-of-content states.
Trigger mechanism (IntersectionObserver, not raw scroll listeners) is implemented correctly and cleaned up on unmount.
01Understand the product before coding
Learning goals
Load the next page of items automatically when the user scrolls near the end of the list.
Show a loading indicator for in-flight fetches and a clear end-of-content state.
Prevent duplicate fetches from firing while one is already in flight.
Support pull-to-retry (or a retry button) if a page fetch fails.
Decisions to state aloud
Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
An error on page 2 shouldn't discard the already-loaded page 1 content.
Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.
02State model and invariants
Store pages by cursor and keep already loaded data through later failures. Request status identifies the cursor in flight, and hasNext comes from the server. Observer intersection is a trigger, never the source of truth.
The request command checks status, next cursor, and the last requested cursor. Abort on route teardown and ignore any response that no longer matches the requested cursor.
Append a successful page once by cursor. A later failure leaves previous pages visible and exposes Retry for that cursor. End state appears only from an authoritative null cursor.
Step 4
Add restoration and virtualization
Persist cursor/page state for back navigation when required. For long sessions, window DOM rows while retaining stable keys, measured sizes, focus behavior, and an accessible Load more path.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a feed that loads the next page automatically as you scroll near the bottom, keeps what is already loaded even if a later page fails, never fires two requests for the same page, and still works with the keyboard through a real Load more button. The one big idea: the IntersectionObserver is only a trigger. The truth about what page to load next comes from the server's cursor, and every request is guarded against duplicates.
Feed.tsx
1import{ useCallback, useEffect, useReducer, useRef }from"react";23type Page<T>={items:T[]; nextCursor: string |null};4type State<T>={5pages:Page<T>[];6status:"idle"|"loading"|"error";7requestedCursor: string |null;8};9type Action<T>=10|{type:"requested"; cursor: string |null}11|{type:"received"; cursor: string |null; page:Page<T>}12|{type:"failed"};1314function reducer<T>(state:State<T>,action:Action<T>):State<T>{15switch(action.type){16case"requested":17return{...state,status:"loading",requestedCursor: action.cursor};18case"received":19if(action.cursor!== state.requestedCursor)return state;// stale response, drop it20return{...state,status:"idle",pages:[...state.pages, action.page]};21case"failed":22return{...state,status:"error",requestedCursor:null};// keep existing pages23}24}2526exportfunctionFeed<Textends{id: string }>({27 fetchPage,28 renderItem,29}:{30fetchPage:(cursor: string |null,signal:AbortSignal)=>Promise<Page<T>>;31renderItem:(item:T)=>React.ReactNode;32}){33const[state, dispatch]=useReducer(reducer<T>,{34pages:[],status:"idle",requestedCursor:null,35});36const sentinelRef = useRef<HTMLDivElement>(null);37const controllerRef = useRef<AbortController|null>(null);3839const started = state.pages.length>0;40const nextCursor = started ? state.pages[state.pages.length-1].nextCursor:null;41const done = started && nextCursor ===null;4243const loadNext =useCallback(()=>{44if(state.status==="loading")return;// one at a time45if(started && nextCursor ===null)return;// nothing left46if(started && state.requestedCursor=== nextCursor)return;// same page already asked4748 controllerRef.current?.abort();49const controller =newAbortController();50 controllerRef.current= controller;51dispatch({type:"requested",cursor: nextCursor });52fetchPage(nextCursor, controller.signal)53.then((page)=>dispatch({type:"received",cursor: nextCursor, page }))54.catch((error)=>{if(error.name!=="AbortError")dispatch({type:"failed"});});55},[state.status, state.requestedCursor, started, nextCursor, fetchPage]);5657// First page on mount.58useEffect(()=>{59if(!started && state.status==="idle")loadNext();60},[started, state.status, loadNext]);6162// Cancel any in-flight request when the component goes away.63useEffect(()=>()=> controllerRef.current?.abort(),[]);6465// Watch the sentinel; load the next page when it comes near the viewport.66useEffect(()=>{67const node = sentinelRef.current;68if(!node || done)return;69const observer =newIntersectionObserver(70([entry])=>{if(entry.isIntersecting)loadNext();},71{rootMargin:"400px 0px"},// start early, before the user hits the end72);73 observer.observe(node);74return()=> observer.disconnect();75},[loadNext, done]);7677const items = state.pages.flatMap((page)=> page.items);7879return(80<div>81<ul>{items.map((item)=><li key={item.id}>{renderItem(item)}</li>)}</ul>8283{!done &&<div ref={sentinelRef} aria-hidden="true" style={{height:1}}/>}8485<p role="status" aria-live="polite">86{state.status==="loading"?"Loading more": done ?"You are all caught up":""}87</p>8889{state.status==="error"&&(90<button type="button" onClick={loadNext}>Something went wrong \u2014 retry</button>91)}9293{!done && state.status!=="loading"&& state.status!=="error"&&(94<button type="button" onClick={loadNext}>Load more</button>95)}96</div>97);98}
How each part works
Pages are stored as an array of pages
Each entry is one server response: its items and the nextCursor to fetch after it. The flat list of items shown on screen is state.pages.flatMap(p => p.items). Keeping pages separate (not one merged array) makes it easy to know what has loaded and to keep old pages when a new one fails.
The cursor is the source of truth, not the scroll position
nextCursor is read from the last loaded page. done is 'we have started and the last page said nextCursor is null'. The observer never decides what to load — it only calls loadNext, which figures out the cursor itself.
Three guards stop duplicate requests
loadNext returns early if a request is already in flight, if there is nothing left to load, or if the cursor it is about to request is the exact one it last requested. Without these, a fast scroll fires the observer several times and you get the same page appended two or three times.
Stale responses are dropped in the reducer
The 'received' action carries the cursor it was for. If that does not match the cursor we are currently waiting on (because something changed in between), the reducer returns the state unchanged instead of appending a page out of order.
A failed page keeps everything above it
The 'failed' action only sets status to 'error'; it never touches pages. So page one stays visible while page two shows a retry button. It also clears requestedCursor so the retry guard does not block the retry.
The sentinel is a tiny invisible div
A 1px aria-hidden div sits below the list. IntersectionObserver with rootMargin '400px 0px' fires loadNext when that div is still 400px below the viewport, so the next page is usually loaded before the user actually reaches the bottom.
A real Load more button is always available
Below the list there is an actual button that also calls loadNext. Keyboard users, screen-reader users, and anyone where the observer misbehaves get a predictable way to continue, and the error state turns into a retry button.
Why this is correct
IntersectionObserver is only a trigger; the next page to load is derived from the server's cursor, never from scroll math.
Guard every load against 'already loading', 'nothing left', and 'same cursor as last time' or fast scrolling appends duplicate pages.
Tag responses with the cursor they were for and ignore any that no longer match what you are waiting on.
A failed page must leave earlier pages untouched and expose a retry for just that page.
Always ship a real Load more button as a keyboard and assistive-technology equivalent and as the retry affordance.
05Testing strategy
Critical behavior
No duplicate or lost fetches under fast scrolling.
Scroll position remains stable when new items append.
Clear, distinct loading, error, and end-of-content states.
Trigger mechanism (IntersectionObserver, not raw scroll listeners) is implemented correctly and cleaned up on unmount.
Failure and boundary cases
Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
An error on page 2 shouldn't discard the already-loaded page 1 content.
Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.
Accessibility
Announce 'Loading more items' and 'End of results' via a polite live region so screen reader users get feedback the infinite trigger gives sighted users implicitly.
Ensure the load trigger doesn't rely solely on a mouse-only scroll gesture — keyboard users paging with Page Down/End must still be able to reach and trigger it.
Provide a manual 'Load more' button as a fallback/alternative to the automatic sentinel for predictability.
06Performance and production hardening
Use IntersectionObserver instead of continuous scroll calculations.
Deduplicate items and pages by stable ids and cursors.
Virtualize when DOM count—not network—is measured as the bottleneck.
Reserve media dimensions so appended content does not shift.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
It asynchronously reports threshold crossings without application code calculating scroll geometry on every scroll event.