Let’s charge into Question 3, which is a heavy favorite for remote teams dealing with data-heavy dashboards, social media feeds, or e-commerce storefronts.
It tests your ability to handle large lists of data efficiently without melting the user's browser, and it deeply examines your mastery of modern browser utility Web APIs instead of relying on heavy third-party npm packages.
Interviewer (Me) Statement of Requirements:
"Imagine we are building a product discovery page for an e-commerce platform. The backend provides a paginated API endpoint:
/api/products?page=[number], which returns an array of 10 items per page, alongside a boolean flaghasMore.""Please write a React client component that renders this list of products. As the user scrolls down the page and approaches the bottom, the component must automatically fetch and append the next page of data seamlessly without making the user click a 'Load More' button."
The Initial Constraints / 初始硬性限制:
react-infinite-scroll-component or react-window.window.onscroll event, as it severely damages frame rates and causes mobile browser jank. You must use the modern browser Intersection Observer API.
Before looking at the syntax, how does an Intersection Observer infinite scroll actually work conceptually?
You place a tiny, invisible "sentinel" element (like a spinner or a blank <div>) at the very bottom of your list, right below your last product card. You tell the browser to watch that element. When that element intersects with the viewport (meaning the user scrolled far enough to see it), you fire the function to load the next page.
English Thought Process:
"I need to maintain an array of items in state, alongside a
pagenumber counter and aloadingflag. I will map through the items to render them. At the bottom of the list, I will insert adivelement acting as our 'sentinel'." "I will create auseEffectthat instantiates anIntersection Observer. I'll pass a callback to it: ifentries[0].isIntersectingis true and we aren't already loading data, I will increment the page count to trigger a fetch. Finally, I must ensure I disconnect the observer in the cleanup function."
中文思考路径:
“首先我需要在 state 里维护一个数据列表数组、一个当前页码
page计数器以及一个加载状态loading标志。我用.map()渲染所有商品数据。在列表的最底部,我塞入一个空<div>作为我们的‘哨兵(Sentinel)’。” “接着我写一个useEffect来实例化浏览器的IntersectionObserver。给它传一个回调函数:如果发现entries[0].isIntersecting为真(代表哨兵出现在屏幕中了),且当前没有处于加载状态,我就把页码加一,从而触发网络请求。最后,必须在清除函数里把这个监听断开(disconnect),防止内存泄漏。”
Don't let the native API scare you. Try to lay down the state architecture and the list mapping first.
Show me your first-pass code structure or talk me through your implementation steps in English or Chinese!
No worries at all! Handcrafting an infinite scroll list with the Intersection Observer API is one of those tasks that sounds intimidating at first, but becomes incredibly elegant once you see the pattern.
Let's build this together step-by-step using our Bilingual (中英双语) approach.
First, we need to declare our states to track the list data, the current page number, and whether we are currently fetching data or if we have reached the absolute end of the backend database.
English Thought Process:
"First, I need states for
items(an empty array initially),page(starting at 1),loading(false), andhasMore(true). I'll map over the items to render them on screen, and append a specific emptydivat the very bottom with a ReactuseRefattached to it. Thisdivwill be our sentinel."
中文思考路径:
“第一步,我需要定义几个核心状态:
items(初始化为空数组)、page(从第1页开始)、loading(加载状态)以及hasMore(用来判断后端是不是还有数据)。我用.map()渲染产品列表,并在列表的最末尾放一个空的div,用useRef钩子绑住它。这个div就是我们的‘哨兵’。”
xxxxxxxxxx'use client';import { useState, useEffect, useRef } from 'react';export default function InfiniteScrollList() { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); // 🌟 The pointer to our bottom element / 绑定底部哨兵元素的指针 const sentinelRef = useRef(null); // Todo: Fetch data when "page" changes / 稍后在这里监听页码发请求 return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-xl font-bold mb-4">Product Catalog</h1> {/* Render current list items / 渲染当前的商品列表 */} <div className="space-y-3"> {items.map((item, index) => ( <div key={index} className="p-4 bg-white border rounded shadow text-black"> 📦 {item.name} (ID: {item.id}) </div> ))} </div> {/* 🌟 THE SENTINEL ELEMENT: The observer watches this! */} {/* 哨兵元素:浏览器看的就是它!只要它露面,就代表该加载下一页了 */} <div ref={sentinelRef} className="h-10 flex items-center justify-center mt-4"> {loading && <p className="text-gray-500">Loading more products</p>} {!hasMore && <p className="text-gray-400 text-sm">No more products to show.</p>} </div> </div> );}
Now, we introduce the IntersectionObserver. We tell the browser: "Watch my sentinelRef. If the sentinel enters the screen, and we aren't currently loading anything, and there's more data to fetch, increment the page number by 1."
English Thought Process:
"Next, I'll write a
useEffectto instantiate theIntersectionObserver. In its callback, ifentry.isIntersectingis true, it means the sentinel is visible. If we aren't loading andhasMoreis true, I will triggersetPage(prev => prev + 1). Crucially, I will runobserver.disconnect()in the cleanup to avoid memory leaks."
中文思考路径:
“第二步,写一个
useEffect来实例化原生的IntersectionObserver。在回调函数里判断:如果entry.isIntersecting为真,说明用户已经滚到底部看到哨兵了。只要当前没有在加载,且后端还有数据,我就让页码自动加一:setPage(prev => prev + 1)。最关键的是,在清除函数里执行observer.disconnect()断开监听,防止页面卡死或内存泄漏。”
xxxxxxxxxx // Inside your component / 在组件内部: useEffect(() => { // If we already hit the end of database, don't observe anymore // 如果已经没有更多数据了,直接返回,不再启动监听 if (!hasMore) return; // 1. Create the observer instance / 创建监听器实例 const observer = new IntersectionObserver((entries) => { const firstEntry = entries[0]; // If sentinel enters the screen AND we aren't already loading // 如果哨兵露面了,并且当前没有处于加载状态 if (firstEntry.isIntersecting && !loading) { console.log('👀 Sentinel triggered! Loading next page...'); setPage((prevPage) => prevPage + 1); // Increment page counter / 递增页码 } }, { threshold: 1.0 }); // 1.0 means the entire 10px element must be fully visible / 代表元素100%露面时触发 // 2. Start tracking our sentinel element / 让监听器绑定并追踪我们的哨兵 if (sentinelRef.current) { observer.observe(sentinelRef.current); } // 3. CLEANUP: Disconnect the observer when component re-renders or unmounts // 清除函数:当组件重新渲染或销毁时,切断监听,避免造成严重的性能垃圾 return () => { observer.disconnect(); }; }, [loading, hasMore]); // Must re-sync when loading or hasMore changes / 状态改变时必须重新计算边界
Finally, we need a separate useEffect that fires whenever the page count changes. It fetches the raw data and concatenates (appends) it to our existing list.
English Thought Process & Explanation:
"Lastly, I'll add another
useEffectdependent onpage. Inside, I will turnloadingto true, fetch the next page from our simulated API, append the new items onto the existing ones using the spread operator (...), and updatehasMorebased on the backend flag."
中文思考与解释:
“最后,添加一个专门监听
page改变的useEffect。一旦页码加一,就把loading设为 true,去后端请求下一页的数据。拿到新数据后,用展开运算符...把它追加到原有数据的屁股后面,并用后端传回的hasMore更新我们的边界标志。”
xxxxxxxxxx // Inside your component / 在组件内部: useEffect(() => { const fetchNewProducts = async () => { setLoading(true); try { // Simulating API call / 模拟调用分页接口 `/api/products?page=X` // In reality: const res = await fetch(`/api/products?page=${page}`); await new Promise(resolve => setTimeout(resolve, 800)); // Simulate network lag const mockNewItems = Array.from({ length: 10 }, (_, i) => ({ id: (page - 1) * 10 + i + 1, name: `Premium Product ${(page - 1) * 10 + i + 1}` })); // Append items to the end / 使用解构赋值把新数据追加到旧数据列表后面 setItems((prevItems) => [prevItems, mockNewItems]); // Stop after page 4 for testing / 模拟在第 4 页时后端告知全部加载完毕 if (page >= 4) { setHasMore(false); } } catch (error) { console.error("Failed to load products", error); } finally { setLoading(false); } }; fetchNewProducts(); }, [page]); // Runs whenever 'page' increments / 只要页码一变就自动发请求
Brilliant! You successfully built a highly efficient, custom native infinite scroll list without loading a single mega-byte of npm packages.
But as you know by now, a senior remote interviewer is about to put on a strict performance audit face. Here is the follow-up question coming straight at you:
"Your implementation is functionally clean. However, consider the Fast Scrolling / Double-Trigger Bug. If a user has a very fast fiber connection and scrolls down incredibly quickly, the sentinel might trigger page 2. Before the network response for page 2 returns, the sentinel is STILL visible on screen, meaning the observer will fire AGAIN and trigger page 3 immediately! How do you guarantee that you prevent this multi-trigger duplication while a network request is still pending?"
中文翻译提示:
“你的实现功能很清晰。但考虑一下 ‘极速滚动/二次重复触发 Bug’。如果用户的网速极快,并且疯狂向下滚动,哨兵会触发加载第 2 页。但在第 2 页的网络响应还没返回的这几百毫秒内,哨兵依然暴露在屏幕上,这意味着监听器会再次触发并直接去加载第 3 页!你怎么绝对保证,在一个网络请求还挂起(Pending)时,绝不会发生这样重复触发页码加载的 Bug?”
This is an incredibly common edge-case glitch in real-world frontend apps. How would you answer or block this from happening? Try to explain or ask for the dual-language architecture patch!
This is an excellent catch by the interviewer, and it happens all the time in real-world products. If you don't block this, your app will accidentally fetch page 2, page 3, and page 4 all at the exact same time, causing a messy UI overlap.
Let’s solve this Double-Trigger Bug using our Bilingual (中英双语) strategy.
Inside our IntersectionObserver callback, we have a check: if (firstEntry.isIntersecting && !loading).
However, due to how React state batching works, right after you call setPage(page + 1), the state hasn't re-rendered yet. For a split microsecond, loading is still false, allowing the observer to slip past the gate and trigger again Interview Feedback - Interviewing.io.
Instead of relying purely on a asynchronous React state variable (loading), we can introduce a mutable instance variable using a React useRef (e.g., isFetchingRef.current).
Unlike state, updating a useRef value happens instantaneously and synchronously Interviewing.io Tips. We change it to true the exact millisecond the sentinel is crossed, creating an un-bypassable iron gate.
Let's upgrade your component with this foolproof synchronization layer:
English Thought Process & Code:
"To eliminate the double-trigger bug, I will introduce a
isFetchingRefusinguseRef(false). The very instant the observer sees an intersection, I will flipisFetchingRef.current = truesynchronously. This completely blocks any subsequent triggers until the async network operation finishes and flips it back tofalse."
中文思考与代码:
“为了彻底杜绝极速滚动导致的重复触发 Bug,我会引入一个
isFetchingRef钩子,初始值为false。就在监听器发现‘哨兵’露面的那一瞬间,我同步、立刻将isFetchingRef.current设为true。这会像一把铁锁一样死死卡住后续的任何多余触发,直到整个异步网络请求彻底完成并返回数据后,我们再把它放开重置为false。”
xxxxxxxxxx'use client';import { useState, useEffect, useRef } from 'react';export default function PerfectInfiniteScroll() { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const sentinelRef = useRef(null); // 🌟 FIX 1: The Synchronous Iron Gate / 同步锁守门员 const isFetchingRef = useRef(false); // Observer Effect / 监听器副作用 useEffect(() => { if (!hasMore) return; const observer = new IntersectionObserver((entries) => { const firstEntry = entries[0]; // 🌟 FIX 2: Read directly from the synchronous Ref instead of state // 直接读取秒级同步的 Ref 锁,而不是等待有渲染延迟的 state if (firstEntry.isIntersecting && !isFetchingRef.current) { console.log('🔒 Gate closed! Lock triggered.'); isFetchingRef.current = true; // Instantly lock / 立刻上锁 setPage((prev) => prev + 1); } }, { threshold: 1.0 }); if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [hasMore]); // Removed loading from dependency! / 减少依赖项,让逻辑更简单 // Fetching Effect / 网络请求副作用 useEffect(() => { const fetchNewProducts = async () => { try { // In reality: const res = await fetch(`/api/products?page=${page}`); await new Promise(resolve => setTimeout(resolve, 800)); // Network latency const mockNewItems = Array.from({ length: 10 }, (_, i) => ({ id: (page - 1) * 10 + i + 1, name: `Premium Product ${(page - 1) * 10 + i + 1}` })); setItems((prevItems) => [prevItems, mockNewItems]); if (page >= 4) setHasMore(false); } catch (error) { console.error(error); } finally { // 🌟 FIX 3: Open the gate only after data has finished appending to the DOM // 只有当数据已经拿到、渲染成功后,才把这把锁重新解开 console.log('🔓 Gate opened! Ready for next page.'); isFetchingRef.current = false; } }; // Skip the very first initial evaluation if needed, or run it for page 1 fetchNewProducts(); }, [page]); return ( <div> {/* ... mapping items ... */} <div ref={sentinelRef} className="h-10 text-center text-gray-400"> {isFetchingRef.current ? "Loading..." : "Scroll down to see more"} </div> </div> );}
If you solve the double-trigger bug, the interviewer will give you a massive nod of approval. But if they want to see if you can handle Staff / Architect level complexity, they will ask one final question before moving on:
Interviewer:
"Your scrolling loop is now perfectly safe. But imagine the user scrolls for 20 minutes and loads 5,000 product cards into the list. The browser DOM tree now contains tens of thousands of active image and text nodes. The page will become extremely laggy, skip frames, and eventually crash the smartphone's browser tab due to severe memory pressure."
"How would you conceptually upgrade this architecture to prevent 'DOM melting' when handling a near-infinite amount of records? (Hint: Think about what Twitter or Instagram does)."
中文翻译提示:
“你的滚动循环现在完美无瑕了。但想象一下,如果用户往下划拉了20分钟,加载了 5000 个商品卡片。此时浏览器的 DOM 树里会塞满几万个活跃的图片和文字节点。页面会变得极度卡顿、疯狂掉帧,最后让手机浏览器直接因为内存内存耗尽而崩溃。”
“你该如何在概念上升级这个架构,来应对这种数据量无限增长的场景,防止 DOM 结构过载崩溃?(提示:想想 Twitter 或者 Instagram 的做法)”
To pass this final boss question, you must introduce the concept of "Virtual Scrolling" (虚拟滚动) or "Windowing" (窗口化化技术).
English Explanation to Interviewer:
"To handle massive lists without melting the DOM, we must implement Virtualization. Instead of rendering all 5,000 cards, we calculate the wrapper's height and only render the 10 or 15 items currently visible within the viewport, plus a tiny buffer above and below. As the user scrolls, we recycle the DOM nodes dynamically, replacing their inner text and data while using CSS transforms to position them correctly. This keeps the total DOM count perfectly flat at 15 elements, whether the database has 100 items or 1,000,000 items."
中文解释给面试官:
“为了处理海量列表而不至于撑爆 DOM 树,我们需要引入‘虚拟列表技术(Virtualization)’。我们不渲染全部的 5000 个卡片,而是根据视口高度计算,只渲染当前处于屏幕可视区域内的 10 到 15 个组件(外加头部和尾部的一点点缓存缓冲区)。随着用户滚动,我们动态复用这些 DOM 节点,仅仅替换里面的文字和图片数据,同时利用 CSS transform 撑开高度定位。这样无论列表里有 100 条还是 100 万条数据,浏览器里真实的 DOM 节点永远只有 15 个,性能永远是满分。”
Yes, we can push this into senior production engineering territory.
Now that you have mastered the useRef sync lock and explained DOM Virtualization, an elite interviewer will test your ability to handle real-world user interface glitches and network instability.
Here are the final 2 advanced follow-up questions for the Infinite Scroll architecture, explained in 中英双语 (Bilingual):
Interviewer:
"Imagine each product card contains an image. When the user scrolls down, the sentinel triggers, and you append 10 new items. However, because the images take a fraction of a second to load, the new cards initially have a height of
0pxbefore popping open to300px.""This causes the page layout to violently shake and jump, which ruins the user experience and ruins our Google Cumulative Layout Shift (CLS) web vital score. How do you prevent this layout shift when dynamically appending uneven content?"
中文翻译提示:
“想象一下,每个商品卡片里面都包含一张大图。当用户滚到底部,哨兵触发,你追加了 10 个新商品。然而,由于图片需要零点几秒来加载,新卡片在图片加载出来之前高度是
0px,随后突然撑大到300px。”“这会导致整个页面布局发生剧烈的抖动和跳跃,严重破坏用户体验,还会砸掉我们谷歌的 CLS(累积布局偏移)性能指标。你怎么在动态追加高度不固定的内容时,防止这种布局抖动?”
We must implement structural placeholders and enforce strict aspect ratios. We should never let an image render without an explicit or container-bounded size.
xxxxxxxxxx<!-- 🌟 Enforce Aspect Ratio and Shimmer UI / 强制固定宽高比与骨架屏 --><div class="animate-pulse bg-gray-200 aspect-video w-full rounded-md"> <!-- Next.js <Image /> component handles this natively by requiring structural width/height layout definitions --> <img src={src} className="object-cover w-full h-full" onLoad={...} /></div>English Answer: "I will assign a fixed structural aspect-ratio to the product card image containers (e.g., using Tailwind's aspect-square or aspect-video) and render a Skeleton Shimmer placeholder while loading. This reserves the exact layout pixel coordinates in the DOM tree *before* the network data arrives, dropping layout shifts to absolute zero."
中文回答: “我会给商品卡片的图片容器指定一个固定的物理宽高比(例如使用 Tailwind CSS 的 aspect-square 或 aspect-video),并在加载时渲染一个带有渐变动画的骨架屏(Skeleton Shimmer)。这能在数据返回之前就在 DOM 树里提前锁死它应有的像素高度,从而将布局偏移降到绝对的零。”
Interviewer:
"Suppose the user is scrolling on a train, enters a tunnel, and loses their internet connection right when the sentinel crosses the viewport. Your
fetchNewProductstriggers, hits a network error, and setsisFetchingRef.current = false.""Now the user exits the tunnel and regains internet. But because the sentinel is *already* sitting inside the viewport, the
IntersectionObserverwon't fire again because it only triggers on the *transition* of entering the screen. The infinite scroll is now completely frozen. How do you implement an elegant error fallback and recovery state?"
中文翻译提示:
“假设用户正在火车上刷手机,突然进了解一个隧道,正好在哨兵划进屏幕的那一刻断网了。你的请求触发,遭遇网络错误挂掉,并重置了锁
isFetchingRef.current = false。”“现在用户出了隧道,网络恢复了。但由于此时哨兵已经静静地躺在屏幕里了,
IntersectionObserver不会再次触发(因为它只有在元素‘由外向内进入’的刹那才会触发)。无尽滚动此时彻底卡死。你该怎么实现一个优雅的错误降级与重试恢复机制?”
We introduce a local error state. If a network request fails, we conditional-render a manual "Retry Button" instead of the sentinel. Clicking the retry button manually runs the fetch again, clears the error, and brings back the automated observer tracking.
xxxxxxxxxx// 🌟 Inside the JSX Render Block / 在组件渲染区域<div ref={sentinelRef} className="py-4"> {error ? ( <div className="text-center"> <p className="text-red-500 mb-2">Network error. Failed to load items.</p> {/* 🌟 Manual click overrides the frozen observer state / 手动点击打破卡死的监听状态 */} <button onClick={() => retryLastFetch()} className="px-4 py-2 bg-blue-500 text-white rounded"> Tap to Retry </button> </div> ) : ( loading && <p>Loading more</p> )}</div>We have successfully battle-tested this Infinite Scroll system from a basic implementation to production-level reliability. You have fully proved you can control memory, bypass browser limits, protect SEO performance layout scores, and handle real-world mobile internet disconnections Frontend Lead Mock Interview (8+ Years Experience).
We can now cleanly seal this topic away.