import { useState, useEffect, useRef, useCallback } from "react"; // ============================================= // Mock API (DO NOT MODIFY) // ============================================= // Simulates /api/v1/posts?page=1&limit=10 // Returns { data: Post[], meta: Meta } interface Post { id: number; title: string; body: string; author: string; createdAt: string; } interface Meta { page: number; limit: number; total: number; totalPages: number; hasMore: boolean; } const ALL_POSTS: Post[] = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, title: `Post #${i + 1}`, body: `This is the body of post ${i + 1}. It contains some sample text to simulate a real feed item you might see in a social media app or blog.`, author: `user_${(i % 12) + 1}`, createdAt: new Date(Date.now() - i * 3600000).toISOString(), })); async function fetchPosts(page: number, limit = 10): Promise<{ data: Post[]; meta: Meta }> { // Simulate network latency await new Promise((r) => setTimeout(r, 500)); const start = (page - 1) * limit; const end = start + limit; return { data: ALL_POSTS.slice(start, end), meta: { page, limit, total: ALL_POSTS.length, totalPages: Math.ceil(ALL_POSTS.length / limit), hasMore: end < ALL_POSTS.length, }, }; } // ============================================= // TODO: Implement Infinite Scroll // ============================================= // // Requirements: // 1. Load the first page of posts on mount // 2. Display posts in a scrollable list // 3. Automatically load the next page when the user // scrolls near the bottom (no "Load More" button) // 4. Show a loading spinner while fetching // 5. Show an "end of list" message when all posts are loaded // 6. Don't fetch if already loading or no more pages // // Approach — IntersectionObserver: // - Create a "sentinel" div at the bottom of the list // - Use IntersectionObserver to watch it // - When it enters the viewport → fetch next page // - Disconnect/reconnect observer as needed // // Hints: // - useRef to hold the IntersectionObserver instance // - useCallback ref on the sentinel element // - Check meta.hasMore before fetching // - Append new posts to existing: setPosts(prev => [...prev, ...newData]) // - Guard against double-fetches with a loading flag export default function App() { const [posts, setPosts] = useState<Post[]>([]); const [meta, setMeta] = useState<Meta | null>(null); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); // TODO: Create a ref to hold the IntersectionObserver // const observer = useRef<IntersectionObserver | null>(null); // ============================================= // TODO: Implement loadPage // ============================================= // Should: // - Set loading to true // - Call fetchPosts(pageNum) // - Append results to posts state // - Update meta state // - Set loading to false const loadPage = async (pageNum: number) => { // Your code here }; // ============================================= // TODO: Implement the sentinel ref callback // ============================================= // This function receives the sentinel DOM node. // It should: // 1. Return early if loading // 2. Disconnect any existing observer // 3. Create a new IntersectionObserver that calls loadMore // when the sentinel is intersecting AND meta.hasMore // 4. Observe the node // // Wrap with useCallback and include [loading, meta, page] // in the dependency array // // const sentinelRef = useCallback((node: HTMLDivElement | null) => { // // Your code here // }, [loading, meta, page]); // ============================================= // TODO: Load first page on mount // ============================================= // useEffect(() => { // loadPage(1); // }, []); return ( <div style={{ maxWidth: 600, margin: "0 auto", padding: 24, fontFamily: "system-ui" }}> <h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 4 }}> Infinite Scroll </h2> <p style={{ fontSize: 14, color: "#666", marginBottom: 24 }}> Implement infinite scrolling using IntersectionObserver. Posts load from a mock API with 500ms latency. </p> {/* Stats bar */} <div style={{ display: "flex", justifyContent: "space-between", padding: "10px 14px", background: "#f9fafb", border: "1px solid #e5e7eb", borderRadius: 8, fontSize: 13, marginBottom: 20, }}> <span> Showing <strong>{posts.length}</strong> of <strong>{meta?.total ?? "..."}</strong> posts </span> <span style={{ color: "#999" }}> Page {page} / {meta?.totalPages ?? "..."} </span> </div> {/* Post list */} <div style={{ height: "60vh", overflowY: "auto", display: "flex", flexDirection: "column", gap: 12 }}> {posts.map((post) => ( <article key={post.id} style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: 16, }} > <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}> <div style={{ width: 28, height: 28, borderRadius: "50%", background: "#e5e7eb", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 600, color: "#666", }}> {post.author.slice(-2)} </div> <span style={{ fontSize: 13, color: "#666" }}>{post.author}</span> <span style={{ color: "#ccc", fontSize: 11 }}>·</span> <time style={{ fontSize: 11, color: "#999" }}> {new Date(post.createdAt).toLocaleDateString()} </time> </div> <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>{post.title}</h3> <p style={{ fontSize: 13, color: "#666", lineHeight: 1.6, margin: 0 }}>{post.body}</p> </article> ))} {/* ============================================= */} {/* TODO: Add a sentinel div here */} {/* ============================================= */} {/* This invisible div sits at the bottom of the list. Attach your sentinelRef to it so IntersectionObserver can detect when the user scrolls to the bottom. Example: <div ref={sentinelRef} style={{ height: 1 }} /> */} </div> {/* Loading spinner */} {loading && ( <div style={{ display: "flex", justifyContent: "center", padding: 24 }}> <div style={{ width: 24, height: 24, border: "2px solid #e5e7eb", borderTopColor: "#111", borderRadius: "50%", animation: "spin 0.6s linear infinite", }} /> </div> )} {/* End state */} {meta && !meta.hasMore && posts.length > 0 && ( <p style={{ textAlign: "center", color: "#999", fontSize: 13, padding: 24 }}> You've reached the end — {meta.total} posts loaded. </p> )} {/* Fallback button (remove once auto-scroll works) */} {meta?.hasMore && !loading && ( <div style={{ display: "flex", justifyContent: "center", padding: 20 }}> <button onClick={() => { const next = page + 1; setPage(next); loadPage(next); }} style={{ padding: "10px 24px", background: "#111", color: "#fff", borderRadius: 999, fontSize: 13, fontWeight: 600, cursor: "pointer", }} > Load More </button> </div> )} {/* Hint */} <div style={{ marginTop: 24, padding: 16, background: "#f9fafb", borderLeft: "4px solid #111", borderRadius: "0 8px 8px 0", fontSize: 13, color: "#555", }}> <strong>Goal:</strong> Remove the "Load More" button and replace it with automatic loading. Add a sentinel <code><div ref={sentinelRef} /></code> at the bottom of the post list. When IntersectionObserver detects it's visible, fetch the next page. Guard against double-fetches with the loading flag. </div> </div> ); }