Build an Instagram Feed in React
Build a virtualized Instagram-style feed with infinite scroll and optimistic likes.
Introduction
30 Second Summary
Every time you scroll through Instagram, posts load seamlessly and likes feel instant. Behind that smooth experience is a set of frontend architecture patterns that power every major social feed.
In this project, you will build an Instagram-style photo feed in React that uses the same frontend system design patterns. You will implement infinite scroll, list virtualization, optimistic likes, and skeleton loading screens.
What You'll Build
When you show your finished app to a friend, they will scroll through hundreds of photo posts and never notice a single stutter, loading spinner, or layout jump.
By the end of this project, you'll have:
- A virtualized infinite-scroll feed that renders thousands of posts using only about 15 DOM nodes at a time, keeping scrolling smooth no matter how far down you go.
- Skeleton loading screens and lazy-loaded images with reserved aspect ratios that eliminate blank screens and layout jumps.
- Optimistic likes that update the UI instantly and automatically roll back if the server request fails.
- Secret Mission: Add a "New posts" banner that polls for fresh content and lets you load new posts without losing your scroll position.
Do I need backend experience?
Not at all. This project uses a mock API powered by a single JSON file, so no backend or database experience is needed. You just need familiarity with React basics like components, hooks, and state.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
The patterns behind Instagram's feed are the same ones powering every high-performance feed on the web. Understanding virtualization, cursor-based pagination, optimistic updates, and skeleton screens gives you the architectural vocabulary to build and discuss production-grade frontends anywhere.
Set Up the Project and Mock API
In the last step, you locked in what you're building and why. Now it's time to lay the foundation.
Every Instagram-style feed needs two things: a frontend that renders posts and an API that serves them. In this step, you'll scaffold a React project with Vite, install the libraries that power the feed, and spin up a mock API using JSON Server so you can focus entirely on frontend architecture.
In this step, get ready to:
- Scaffold a Vite React TypeScript project and install TanStack Query, TanStack Virtual, and JSON Server.
- Create a seed script that generates 200 mock posts for your mock API.
- Configure TanStack Query in the app and verify both servers are running.
Scaffold the Vite project
Before writing any feed code, you need a React project to work in. Vite gives you a fast dev server with hot module replacement, so every change you save appears in the browser instantly.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your project by running this command:
npm create vite@latest instagram-feed -- --template react-ts
What does this command do?
This uses the create-vite scaffolding tool to generate a new project called instagram-feed with the react-ts template, which sets up React with TypeScript out of the box.
The -- separator passes the --template flag through to create-vite instead of npm itself.
You might see an install prompt
Vite may ask whether to install dependencies and start the dev server automatically. Select No if prompted. You'll install dependencies manually in the next step so you can add the extra libraries at the same time.
- Move into your new project folder by running:
cd instagram-feed
- Confirm the scaffold worked by running:
ls src
You should see files like main.tsx, App.tsx, and index.css listed in the output.
Don't see those files?
Make sure you included the -- separator before --template react-ts in the scaffold command. Without it, Vite may ignore the template flag and prompt you interactively. If that happened, delete the folder and run the command again.
Help me fix my Vite scaffold
Install the project dependencies
The feed uses three libraries on top of React. TanStack Query handles data fetching and caching. TanStack Virtual renders only the posts visible on screen. JSON Server gives you a full REST API from a single JSON file.
- Install the two runtime libraries by running:
npm install @tanstack/react-query @tanstack/react-virtual
- Install JSON Server as a dev dependency by running:
npm install --save-dev json-server
Both commands print a summary of added packages when they finish. You should see the package names and versions in the output.
Why is JSON Server a dev dependency?
JSON Server is a mock API you only need during development. The --save-dev flag saves it under devDependencies in package.json, which keeps it out of a production build.
Seeing permission or network errors?
Try running the install commands again. Flaky network connections sometimes cause npm to fail on the first attempt.
Help me fix my npm install
Generate the mock post data
Your feed needs posts to display. Instead of writing 200 entries by hand, you'll create a small Node script that generates a db.json file. JSON Server reads this file and serves each post as if it were a real API.
- Open instagram-feed in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the instagram-feed folder on your Desktop, then click Open.
- Create a new file inside the src folder called generate-db.mjs. Right-click the src folder in the Explorer sidebar and select New File.
- Paste the following code into generate-db.mjs to define the sample usernames and captions:
import { writeFileSync } from 'fs'
// Pool of usernames that rotate across all 200 posts
const usernames = [
'traveler_jane', 'foodie_mark', 'urban_lens', 'nature_pete',
'art_studio', 'code_ninja', 'sunset_chaser', 'cafe_hopper',
'mountain_soul', 'street_photo', 'ocean_vibes', 'pixel_perfect',
'wanderlust_ko', 'golden_hour', 'minimal_life', 'wild_earth',
'city_lights', 'plant_parent', 'analog_days', 'rooftop_view',
]
// Pool of captions that rotate across all 200 posts
const captions = [
'Golden hour never disappoints',
'Found this hidden gem today',
'Weekend vibes',
'No filter needed',
'Living my best life',
'Coffee and code',
'Chasing sunsets',
'The view from up here',
'Simple pleasures',
'Adventure awaits',
'Morning light hits different',
'Lost in the moment',
'City never sleeps',
'Back to nature',
'Perfect day for this',
]
What does this code do?
The usernames and captions arrays are pools of sample data. The generator will rotate through them using the modulo operator so every post gets a username and caption without repeating the same one back-to-back.
The .mjs extension tells Node.js to treat this file as an ES module, which lets you use import syntax.
- Add the generation loop and file write below the captions array:
const posts = []
// Generate 200 posts, each spaced 1 hour apart
for (let i = 1; i <= 200; i++) {
const username = usernames[i % usernames.length]
const date = new Date(2026, 7, 24)
date.setHours(date.getHours() - i)
posts.push({
id: String(i),
username,
avatarUrl: `https://i.pravatar.cc/150?u=${username}`,
imageUrl: `https://picsum.photos/seed/post${i}/600/600`,
caption: captions[i % captions.length],
likes: Math.floor(Math.random() * 500) + 1,
isLiked: false,
timestamp: date.toISOString(),
})
}
// Write the posts array wrapped in an object that JSON Server expects
writeFileSync('db.json', JSON.stringify({ posts }, null, 2))
console.log('Generated db.json with 200 posts')
What does this code do?
The loop creates 200 post objects, each with a unique id, a rotating username and caption, a random like count, and a timestamp spaced one hour apart.
Avatar images come from i.pravatar.cc and post images come from picsum.photos. Both are free placeholder services that return deterministic images based on the seed value you pass.
JSON Server expects a top-level object where each key becomes an API endpoint. Wrapping the array in { posts } means JSON Server serves them at /posts.
- Save generate-db.mjs.
- Run the seed script from your terminal by running this command:
node src/generate-db.mjs
You should see Generated db.json with 200 posts printed in the terminal.
- Confirm the file was created by running:
ls db.json
You should see db.json listed. This file now contains 200 mock posts ready for JSON Server to serve.
Don't see the output?
Make sure you saved the file before running the command. Also check that the file is inside the src folder and named generate-db.mjs (not .js).
Help me run my seed script
✔️ Awesome, I've got everything!
Great. Double check you've saved generate-db.mjs and that db.json exists in your project root.
ⓧ I'd like to double check the full code
Here is the complete src/generate-db.mjs file. Compare your code against it:
Scroll up and confirm you have both chunks in order:
- The import, usernames array, and captions array at the top.
- The for loop, writeFileSync call, and console.log at the bottom.
Configure TanStack Query and start both servers
TanStack Query needs a QueryClient instance that manages caching and refetching for every data request in your app. You set this up once in the app's entry point, and every component can then use query hooks automatically.
- In the Explorer sidebar, open src/main.tsx.
- Select all the existing code and replace it with the following:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
// Create a QueryClient with sensible defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
What does this code do?
You create a QueryClient with two settings. staleTime: 60 * 1000 tells TanStack Query to treat fetched data as fresh for 60 seconds, which prevents unnecessary re-fetches when you switch between components.
Setting refetchOnWindowFocus: false stops the app from re-fetching every time you click back into the browser tab. This keeps your dev experience smooth while you're jumping between the editor and the browser.
Wrapping <App /> inside <QueryClientProvider> makes the client available to every component via React context.
- Save main.tsx.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tsx.
ⓧ I'd like to double check the full code
Here is the complete src/main.tsx:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
Now it's time to start both servers and confirm everything is wired up.
- Start the Vite dev server by running:
npm run dev
You should see output showing the dev server running at http://localhost:5173.
- Open a second terminal tab or window. Press Cmd+T (macOS) or Ctrl+Shift+T (Windows) to open a new tab.
- Navigate back to the project folder by running:
cd ~/Desktop/instagram-feed
- Start JSON Server on port 3001 by running:
npx json-server db.json --port 3001
Why port 3001?
JSON Server defaults to port 3000, but Vite's dev server sometimes uses nearby ports too. Running JSON Server on 3001 keeps the two servers from colliding.
Before you check, what do you expect to see when you open the Vite URL and the JSON Server URL? Think about what each server is responsible for.
- Open http://localhost:5173 in your browser.
You should see the Vite + React starter page with the spinning logos.
- Open http://localhost:3001/posts?_page=1&_per_page=5 in a new browser tab.
You should see a JSON response containing five post objects, each with fields like id, username, imageUrl, and likes.
Vite page not loading?
Check that your first terminal is still running npm run dev. If you accidentally closed it, open a new terminal, navigate to ~/Desktop/instagram-feed, and run npm run dev again.
Help me get my dev servers running
JSON Server returning an empty response?
Make sure db.json is in the project root (instagram-feed/db.json), not inside src/. The generate script writes it to the current working directory, so you need to run node src/generate-db.mjs from the project root.
Help me fix my JSON Server data
Your project is scaffolded, your mock API is serving 200 posts, and TanStack Query is configured. Next up, you'll build the feed itself and load posts using cursor-based pagination.
Build the Basic Feed with Cursor-Based Pagination
Your project is scaffolded, your mock API is serving 200 posts, and both servers are running. Now it's time to build the actual feed that displays those posts.
A feed that loads all 200 posts at once would choke the browser. Cursor-based pagination solves this by loading posts in small batches. Unlike offset-based pagination, where inserting a new post at the top shifts every page down and causes duplicates, cursor-based pagination uses a stable pointer to fetch the next batch.
In this step, get ready to:
- Create a types file, API layer, and PostCard component for the feed.
- Wire up infinite scroll with TanStack Query and the IntersectionObserver API.
- Observe how the DOM grows with every loaded post.
Create the PostCard component and API layer
The feed needs three building blocks: TypeScript interfaces that describe the shape of a post, API functions that fetch data from JSON Server, and a PostCard component that renders each post.
- Create a new file at src/types.ts by adding it in your editor's Explorer sidebar.
- Paste the following interfaces into the file:
// Shape of a single post from the API
export interface Post {
id: string
username: string
avatarUrl: string
imageUrl: string
caption: string
likes: number
isLiked: boolean
timestamp: string
}
// Shape of the paginated response from JSON Server
export interface PostsResponse {
data: Post[]
first: number
prev: number | null
next: number | null
last: number
pages: number
items: number
}
What do these interfaces define?
Post describes the shape of each post object in db.json. Every field from id to timestamp has a matching TypeScript type.
PostsResponse describes what JSON Server returns when you request a paginated page. The data array holds the posts, and fields like next and prev tell you whether more pages exist.
- Save types.ts.
- Create a new file at src/api.ts and paste the following code to build the fetchPosts function:
import { Post, PostsResponse } from './types'
// Base URL for the JSON Server mock API
const API_BASE = 'http://localhost:3001'
// Fetch a page of posts sorted by newest first
export async function fetchPosts(pageParam: number): Promise<PostsResponse> {
const response = await fetch(
`${API_BASE}/posts?_page=${pageParam}&_per_page=10&_sort=-timestamp`
)
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
return response.json()
}
What does this URL do?
The query string _page=1&_per_page=10&_sort=-timestamp tells JSON Server to return 10 posts per page, sorted by newest first.
JSON Server returns a response object with a data array of posts plus pagination metadata like next (the next page number, or null if you've reached the end).
- Save api.ts.
- Verify the API endpoint works by opening this URL in your browser:
http://localhost:3001/posts?_page=1&_per_page=10&_sort=-timestamp
You should see a JSON object with a data array containing 10 posts, plus fields like next, prev, and pages.
Seeing a connection error?
Make sure JSON Server is still running in its terminal tab. If it stopped, restart it by running npx json-server db.json --port 3001 from your project folder.
help me debug why JSON Server is not responding on port 3001
- Back in src/api.ts, add the toggleLike function below fetchPosts:
// Toggle a post's like state on the server
export async function toggleLike(post: Post): Promise<Post> {
const response = await fetch(`${API_BASE}/posts/${post.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
isLiked: !post.isLiked,
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
}),
})
if (!response.ok) {
throw new Error('Failed to toggle like')
}
return response.json()
}
What does toggleLike do?
This function sends a PATCH request to update a single post. It flips isLiked and adjusts the likes count accordingly. You'll wire this up for instant-feeling likes in Step 5.
- Save api.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
import { Post, PostsResponse } from './types'
const API_BASE = 'http://localhost:3001'
export async function fetchPosts(pageParam: number): Promise<PostsResponse> {
const response = await fetch(
`${API_BASE}/posts?_page=${pageParam}&_per_page=10&_sort=-timestamp`
)
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
return response.json()
}
export async function toggleLike(post: Post): Promise<Post> {
const response = await fetch(`${API_BASE}/posts/${post.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
isLiked: !post.isLiked,
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
}),
})
if (!response.ok) {
throw new Error('Failed to toggle like')
}
return response.json()
}
Now you'll build the component that renders each individual post.
- Create a components folder inside src by running:
mkdir src/components
- Create a new file at src/components/PostCard.tsx and paste the following code to render a post's header and image:
import React from 'react'
import { Post } from '../types'
// Props for the post card component
interface PostCardProps {
post: Post
onLike: (post: Post) => void
}
// React.memo prevents re-renders when props haven't changed
export const PostCard = React.memo(function PostCard({
post,
onLike,
}: PostCardProps) {
return (
<article className="post-card">
{/* User avatar and username */}
<div className="post-header">
<img
className="post-avatar"
src={post.avatarUrl}
alt={`${post.username}'s avatar`}
width={32}
height={32}
/>
<span className="post-username">{post.username}</span>
</div>
</article>
)
})
What is React.memo?
React.memo is a higher-order component that skips re-rendering when props haven't changed. In a feed with dozens of visible posts, this matters. Without it, liking one post would re-render every other post too.
The CSS classes like .post-card and .post-header are already defined in your index.css from Step 1.
- Save PostCard.tsx. Confirm your editor shows no red underlines in the file.
The PostCard currently shows only the header. You'll add the image, like button, caption, and timestamp now.
- In PostCard.tsx, find the </article> closing tag near the bottom. Add the following code just above it, after the post-header section's closing </div>:
{/* Post image */}
<div className="post-image-container">
<img
className="post-image"
src={post.imageUrl}
alt={post.caption}
/>
</div>
{/* Like button and count */}
<div className="post-actions">
<button
className={`like-button ${post.isLiked ? 'liked' : ''}`}
onClick={() => onLike(post)}
aria-label={post.isLiked ? 'Unlike post' : 'Like post'}
>
{post.isLiked ? '❤' : '♡'}
</button>
<span className="like-count">
{post.likes} {post.likes === 1 ? 'like' : 'likes'}
</span>
</div>
{/* Caption and timestamp */}
<div className="post-caption">
<span className="post-caption-username">{post.username}</span>{' '}
{post.caption}
</div>
<time className="post-timestamp">
{new Date(post.timestamp).toLocaleDateString()}
</time>
What did you just add?
- post-image-container renders the post's photo with a grey placeholder background while the image loads.
- post-actions renders a heart toggle button and the like count. The button swaps between a filled heart and an outline heart based on post.isLiked.
- post-caption shows the username in bold followed by the caption text.
- post-timestamp formats the timestamp into a human-readable date.
- Save PostCard.tsx. Check your editor for any red underlines. You should see none.
Seeing TypeScript errors in PostCard?
Make sure the import path '../types' matches the file you created earlier at src/types.ts. Also check that the new code sits inside the <article> tag, not outside it.
help me fix TypeScript errors in my PostCard component
✔️ Awesome, I've got everything!
Great. Make sure PostCard.tsx is saved before moving on.
ⓧ I'd like to double check the full code
Your PostCard.tsx is longer than a single block, so scroll up and confirm these sections are present in this order:
- Imports for React and Post at the top.
- The PostCardProps interface with post and onLike fields.
- The React.memo wrapped component opening.
- post-header section with avatar and username.
- post-image-container section with the image.
- post-actions section with the like button and count.
- post-caption and post-timestamp sections.
- Closing </article>, ), and }) at the bottom.
Implement infinite scroll with useInfiniteQuery and IntersectionObserver
The building blocks are ready. Now you'll create the Feed component that fetches posts using TanStack Query's useInfiniteQuery and renders them as a scrollable list.
- Create a new file at src/components/Feed.tsx and paste the following code:
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchPosts } from '../api'
import { PostCard } from './PostCard'
export function Feed() {
// Set up paginated fetching with TanStack Query
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.next,
})
// Combine all loaded pages into one flat array
const allPosts = data?.pages.flatMap((page) => page.data) ?? []
if (status === 'pending') return <div>Loading...</div>
if (status === 'error') return <div className="feed-error">Failed to load feed. Please refresh.</div>
return (
<div className="feed">
{allPosts.map((post) => (
<PostCard key={post.id} post={post} onLike={() => {}} />
))}
</div>
)
}
How does useInfiniteQuery work?
useInfiniteQuery manages paginated data automatically. It calls fetchPosts with initialPageParam: 1 for the first page.
getNextPageParam reads the next field from JSON Server's response. When next is null, it means there are no more pages and hasNextPage becomes false.
The flatMap call combines every loaded page's data array into one flat list of posts for rendering.
- Now update src/App.tsx to render the Feed. Replace the entire file contents with:
import { Feed } from './components/Feed'
function App() {
return (
<div className="app">
<header className="app-header">
<h1>Instagram</h1>
</header>
<main className="app-main">
<Feed />
</main>
</div>
)
}
export default App
Before you refresh the browser, what do you expect to see?
- Save both Feed.tsx and App.tsx.
- Switch to your browser and refresh the page at http://localhost:5173.
You should see an "Instagram" header at the top and a feed of 10 photo posts, each with an avatar, username, image, like button, caption, and date.
Why cursor-based pagination instead of offset?
With offset pagination (offset=20&limit=10), a new post inserted at the top shifts every index down by one. The user's next page request silently skips a post or shows a duplicate.
Cursor-based pagination uses a stable pointer (here, the page number from getNextPageParam) that always points to the correct next batch. JSON Server's _page parameter gives you stable page slices regardless of what's been inserted or deleted.
Seeing a blank page or an error?
- Check your browser's DevTools console for import errors. A common cause is a typo in './components/Feed' in App.tsx. The path is case-sensitive.
- Make sure both the Vite dev server and JSON Server are still running.
- Confirm Feed.tsx is inside src/components/, not directly in src/.
help me debug why my Instagram feed is not rendering any posts
The feed currently shows only the first page of posts. Scrolling to the bottom does nothing because there is no trigger to fetch the next page.
You need to add the IntersectionObserver API so the feed automatically loads more posts when the user scrolls near the bottom. This requires four changes to Feed.tsx.
- In Feed.tsx, find the import line at the very top:
import { useInfiniteQuery } from '@tanstack/react-query'
- Replace it with these two import lines:
import { useRef, useCallback, useEffect } from 'react'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
- Inside the Feed function, add these two lines right after export function Feed() {, before the useInfiniteQuery call:
const queryClient = useQueryClient()
const sentinelRef = useRef<HTMLDivElement>(null)
- Add this useEffect block after the allPosts line, just above the if (status === 'pending') check:
// Watch the sentinel div and fetch the next page when it enters the viewport
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
})
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
- Finally, update the return JSX. Find the closing </div> tag at the bottom of the return statement (the one closing the .feed div). Add the sentinel div just above it:
<div ref={sentinelRef} className="feed-loading">
{isFetchingNextPage ? 'Loading more posts...' : ''}
</div>
What is IntersectionObserver?
IntersectionObserver is a browser API that fires a callback when an element enters or leaves the viewport. Here, the sentinel <div> sits at the bottom of the feed.
When the user scrolls far enough for the sentinel to become visible, the observer calls fetchNextPage(), which tells useInfiniteQuery to load the next batch. The result: new posts appear seamlessly as you scroll.
Before you test, what do you think will happen when you scroll to the bottom of the feed?
- Save Feed.tsx.
- Switch to your browser and scroll down to the bottom of the feed.
As you reach the bottom, you should see "Loading more posts..." flash briefly, then a new batch of 10 posts appears below.
- Open the browser DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Scroll down in the feed again to trigger another page load.
In the Network tab, you should see a new request to posts?_page=3&_per_page=10&_sort=-timestamp (or whichever page you're on). Each request fetches exactly 10 posts using the _page and _per_page parameters.
Posts not loading on scroll?
- Make sure the sentinel <div ref={sentinelRef}> is inside the .feed div, not outside it.
- Confirm the useEffect dependency array includes hasNextPage, isFetchingNextPage, and fetchNextPage.
- Check that the useRef import was added to the react import line at the top.
help me debug why IntersectionObserver infinite scroll is not loading more posts
✔️ Awesome, I've got everything!
Great. Make sure Feed.tsx is saved before moving on.
ⓧ I'd like to double check the full code
Your Feed.tsx is longer than a single block. Scroll up and confirm these sections are present in order:
- Two import lines: one for useRef, useCallback, useEffect from react, one for useInfiniteQuery, useQueryClient from @tanstack/react-query.
- Imports for fetchPosts and PostCard.
- queryClient and sentinelRef declarations at the top of the function.
- The useInfiniteQuery call with queryKey, queryFn, initialPageParam, and getNextPageParam.
- The allPosts line with flatMap.
- The useEffect block with IntersectionObserver.
- Early returns for pending and error states.
- The return JSX with allPosts.map and the sentinelRef div at the bottom.
Observe the problem: the feed slows down as you scroll
The feed works. Posts load seamlessly as you scroll. But there is a hidden problem growing with every page you load.
Before you check, how many post elements do you think exist in the DOM right now after scrolling through several pages?
- Scroll through the feed until you've loaded at least 100 posts (about 10 pages).
- Open the browser DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Elements tab.
- Expand the .feed div and count how many <article> elements you see.
Every single post you've ever scrolled past is still in the DOM. If you loaded 100 posts, there are 100 heavy <article> elements, each with images, buttons, and text. The browser is tracking all of them even though you can only see about 2-3 on screen.
Why does this matter?
Each DOM node costs memory and slows down layout calculations. At 100 posts the feed might feel fine, but at 500 or 1000 posts scrolling starts to jank. Every style recalculation and paint operation has to process hundreds of nodes you cannot even see.
This is exactly the problem list virtualization solves. Instead of rendering every post, virtualization keeps only the ~15 posts visible on screen (plus a small buffer) in the DOM and swaps them as you scroll.
Your feed works, your infinite scroll loads pages seamlessly, and you've seen the pagination parameters flowing through the Network tab. You've also uncovered the scaling problem: the DOM grows forever. In the next step, you'll fix this with virtualization so the DOM stays constant no matter how far you scroll.
Add Virtualization So Only Visible Posts Exist in the DOM
Your infinite-scroll feed works. But in the last step, you saw the cost: every post the user scrolls past stays in the DOM forever.
List virtualization fixes this by rendering only the posts visible on screen (plus a small buffer). TanStack Virtual's useVirtualizer hook tracks which items are in view and swaps them in and out as the user scrolls. The DOM stays at roughly 15 nodes no matter how far down the feed goes.
In this step, get ready to:
- Replace the flat post list with TanStack Virtual's useVirtualizer for virtualized rendering.
- Wire up infinite scroll to work through the virtualizer instead of IntersectionObserver.
Integrate TanStack Virtual's useVirtualizer with the feed
The virtualizer needs three things: how many items exist, a reference to the scroll container, and an estimated height for each row. It uses these to calculate which items are in the viewport and renders only those.
Since almost every line of Feed.tsx changes, you will replace the entire file. The query logic stays the same. The rendering is completely new.
- Open src/components/Feed.tsx in your editor.
- Select all the code in the file and delete it.
- Start rebuilding the file by pasting the imports, hooks, and virtualizer configuration:
import { useRef, useCallback, useEffect } from 'react'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { fetchPosts } from '../api'
import { PostCard } from './PostCard'
export function Feed() {
const queryClient = useQueryClient()
const parentRef = useRef<HTMLDivElement>(null)
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.next,
})
const allPosts = data?.pages.flatMap((page) => page.data) ?? []
// Configure virtualizer for visible-only rendering
const virtualizer = useVirtualizer({
count: hasNextPage ? allPosts.length + 1 : allPosts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 600,
overscan: 3,
})
What changed from the old Feed.tsx?
The imports and query logic are nearly identical. Three things are new:
- useVirtualizer is imported from @tanstack/react-virtual.
- parentRef replaces the old sentinelRef. It points to the scrollable container instead of a bottom sentinel div.
- count tells the virtualizer how many total items exist. The + 1 reserves a slot for the "Loading more posts..." row when more pages are available.
- estimateSize gives the virtualizer a rough pixel height per post (600px). It refines this with measureElement once the item renders.
- overscan: 3 renders 3 extra items above and below the viewport, so scrolling feels seamless.
- Continue building the file by pasting the virtual items getter, status checks, and the start of the virtualized return statement directly below the code you just added:
const virtualItems = virtualizer.getVirtualItems()
if (status === 'pending') return <div>Loading...</div>
if (status === 'error') return <div className="feed-error">Failed to load feed. Please refresh.</div>
// Render only the items the virtualizer reports as visible
return (
<div ref={parentRef} className="feed-scroll-container">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index >= allPosts.length
const post = allPosts[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
- Finish the file by pasting the conditional content and all closing tags directly below:
{/* Show loader row or the post card */}
{isLoaderRow ? (
<div className="feed-loading">Loading more posts...</div>
) : (
<PostCard key={post.id} post={post} onLike={() => {}} />
)}
</div>
)
})}
</div>
</div>
)
}
How does virtualized rendering work?
The outer div (feed-scroll-container) is the scrollable viewport. The inner div's height is set to virtualizer.getTotalSize(), which is the full pixel height of all items combined. This gives the scrollbar the correct size even though most items don't exist in the DOM.
Each rendered item uses position: absolute and transform: translateY(...) to place it at the correct scroll offset. The ref={virtualizer.measureElement} callback lets the virtualizer measure actual rendered heights, replacing the initial estimate.
- Save Feed.tsx.
- Switch to your browser and refresh the page at http://localhost:5173.
You should see the first page of posts rendered in the feed. The posts are now virtualized. Scrolling down shows posts, but new pages won't load automatically yet because the infinite scroll trigger hasn't been reconnected.
Seeing a blank page or console errors?
- Check that you pasted all three code blocks in order, one directly after the other. Missing a closing brace or pasting in the wrong order causes syntax errors.
- Make sure your file starts with the import lines and ends with a single closing }. No leftover code from the old version should remain.
- Confirm that both the Vite dev server and JSON Server are still running in their terminals.
Still stuck? Help me debug why my virtualized Feed component isn't rendering posts.
Wire up infinite scroll to the virtualizer
The old feed used an IntersectionObserver watching a sentinel div at the bottom. That sentinel is gone now. Instead, the virtualizer already knows which items are visible. A useEffect can check whether the last visible item is near the end of the loaded data and trigger a fetch.
- In Feed.tsx, find this line:
const virtualItems = virtualizer.getVirtualItems()
- Add the following useEffect directly below that line, above the if (status === 'pending') check:
// Fetch the next page when the last visible item nears the end
useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (
lastItem.index >= allPosts.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [virtualItems, allPosts.length, hasNextPage, isFetchingNextPage, fetchNextPage])
What does this useEffect do?
Every time the list of visible virtual items changes (the user scrolls), this effect grabs the last visible item. If that item's index is at or past the end of the loaded posts AND more pages exist, it calls fetchNextPage().
This replaces the old IntersectionObserver completely. The virtualizer already tracks scroll position, so there is no need for a second observer.
- Save Feed.tsx.
Before you check the browser, predict: after scrolling through all 200 posts, how many article elements do you expect to find in the Elements panel?
- Refresh the browser at http://localhost:5173.
- Scroll all the way down through the feed. New posts should load seamlessly as you scroll, just like before.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Go to the Elements panel. Press Cmd+F (macOS) or Ctrl+F (Windows) and search for article.post-card.
You should see roughly 15 matches, not 200. No matter how far you scrolled, the DOM only holds the visible posts plus a small buffer. Scrolling stays smooth at 60fps.
Why is this such a big deal?
In the previous step, 200 posts meant 200 DOM nodes. Each node has an image, avatar, text, and buttons. The browser had to paint and track all of them. With virtualization, the DOM stays constant at around 15 nodes no matter how long the feed grows. This is the same technique Instagram, Twitter, and Facebook use for their feeds.
Posts not loading as you scroll?
- Make sure the useEffect is placed between const virtualItems = virtualizer.getVirtualItems() and the if (status === 'pending') line. Placing it after the return statement means it never runs.
- Check the dependency array. It must include virtualItems, allPosts.length, hasNextPage, isFetchingNextPage, and fetchNextPage.
- Confirm that JSON Server is still running on port 3001 in its terminal.
Still stuck? Help me debug why infinite scroll isn't triggering in my virtualized feed.
✔️ Awesome, I've got everything!
Great. Double check you've saved Feed.tsx and your feed loads new posts as you scroll with only ~15 DOM nodes at any time.
ⓧ I'd like to double check the full code
Scroll up and confirm your Feed.tsx contains these sections in order:
- Five import lines, including useVirtualizer from @tanstack/react-virtual.
- parentRef, useInfiniteQuery setup, allPosts flatMap, and useVirtualizer config with count, getScrollElement, estimateSize, and overscan.
- const virtualItems = virtualizer.getVirtualItems() followed by the useEffect that calls fetchNextPage when the last virtual item nears the end.
- Status checks for pending and error.
- A return with feed-scroll-container div, an inner div with virtualizer.getTotalSize() height, and a virtualItems.map that positions each item with position: absolute and translateY.
If anything is missing, scroll up to the matching code block and copy it again.
Your feed now handles thousands of posts without breaking a sweat. Next up, you'll make it look polished with lazy-loaded images and skeleton loading screens.
Implement Optimistic Likes with Rollback
Your feed scrolls smoothly through hundreds of posts, loads images lazily, and shimmers while data arrives. But try clicking a heart icon on any post. Nothing happens.
When a user taps "like" on Instagram, the heart fills instantly. It does not wait 200-500ms for the server round-trip before updating the UI. In this step, you'll wire up optimistic updates that apply the like change to the UI immediately, then roll back automatically if the server request fails.
In this step, get ready to:
- Wire up optimistic likes that update the UI instantly before the server responds.
- Add automatic rollback when the server request fails.
- Verify that only the liked post re-renders, not the entire feed.
Try the like button and review the existing pieces
Before writing any code, confirm the current state of the like button.
- Switch back to your browser at http://localhost:5173.
- Click the heart icon (♡) on any post in the feed.
Nothing changes. The heart does not fill, and the like count stays the same.
Why does nothing happen?
The like button UI already exists in PostCard.tsx. It calls an onLike prop when clicked. The API function toggleLike also already exists in api.ts.
The missing piece is in Feed.tsx. Right now, the onLike prop is set to () => {}, an empty function that does nothing. You need to connect it to the API through an optimistic mutation.
Add the optimistic mutation to Feed.tsx
The mutation needs three callbacks to make likes feel instant and handle failures gracefully.
- In Feed.tsx, find the import lines at the top of the file.
- Find this line:
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
- Replace it with this line, which adds useMutation:
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
- Find this line:
import { fetchPosts } from '../api'
- Replace it with this line, which adds toggleLike:
import { fetchPosts, toggleLike } from '../api'
- Add this new import line below the PostCardSkeleton import:
import { Post, PostsResponse } from '../types'
Now you have everything imported. Time to build the mutation.
- Scroll down to the useEffect block that handles infinite scroll.
- Directly below it (after its closing })), add the first part of the mutation. This code handles the optimistic update:
const likeMutation = useMutation({
mutationFn: toggleLike,
onMutate: async (post) => {
// Cancel in-flight refetches so they don't overwrite our optimistic change
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot the current cache for rollback
const previousData = queryClient.getQueryData<{
pages: PostsResponse[]
pageParams: number[]
}>(['posts'])
// Update the liked post's values across all cached pages
queryClient.setQueryData<{
pages: PostsResponse[]
pageParams: number[]
}>(['posts'], (old) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
data: page.data.map((p) =>
p.id === post.id
? {
...p,
isLiked: !p.isLiked,
likes: p.isLiked ? p.likes - 1 : p.likes + 1,
}
: p
),
})),
}
})
return { previousData }
},
What does onMutate do?
onMutate fires BEFORE the server request is sent. It does three things:
- cancelQueries stops any in-flight refetches from overwriting the optimistic change.
- getQueryData takes a snapshot of the current cache. This is your safety net for rollback.
- setQueryData walks through every cached page and flips the liked post's isLiked and likes values. The UI re-renders instantly with the new values.
- Directly below the onMutate callback you just pasted, add the error handling, cleanup, and the closing of the useMutation call:
// Restore the snapshot if the server request fails
onError: (_err, _post, context) => {
if (context?.previousData) {
queryClient.setQueryData(['posts'], context.previousData)
}
},
// Refetch from the server to reconcile with the truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
// Stable callback so React.memo works on PostCard
const handleLike = useCallback(
(post: Post) => {
likeMutation.mutate(post)
},
[likeMutation]
)
What do onError and onSettled do?
- onError fires when the server request fails. It restores the cache snapshot from onMutate, undoing the optimistic change so the UI rolls back.
- onSettled fires after every mutation (success or failure). It refetches from the server to reconcile the cache with the authoritative data.
- handleLike is wrapped in useCallback so it keeps a stable reference. This lets React.memo on PostCard skip unnecessary re-renders.
The mutation exists, but nothing in the JSX uses it yet. The last wiring step is connecting the onLike prop to handleLike.
- Scroll down to the return block and find this line inside the virtualItems.map:
<PostCard post={post} onLike={() => {}} />
- Replace () => {} with handleLike, so the line now looks like this:
<PostCard post={post} onLike={handleLike} />
- Save Feed.tsx.
- Switch back to your browser and click the heart icon on any post.
The heart fills to a red ❤ and the like count increments by one. It happens instantly, with no visible delay.
Heart not changing?
- Check that your import line includes useMutation alongside useInfiniteQuery and useQueryClient. A missing import causes a compile error.
- Make sure you replaced onLike={() => {}} with onLike={handleLike} in the JSX. The old empty function silently swallows clicks.
- Confirm JSON Server is still running on port 3001. The mutation sends a PATCH request to it.
Why isn't my like button working after adding useMutation?
Test rollback and verify memoization
The like works, but the real power of optimistic updates shows when the network is slow or the server is down.
- Open DevTools in your browser (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows).
- Click the Network tab.
- In the throttling dropdown (usually says No throttling), select Slow 3G.
- Click the heart on a post.
The heart still fills instantly. Even on a crawling connection, the UI updates immediately because onMutate writes directly to the cache before the request leaves the browser.
- Set the throttling dropdown back to No throttling.
Now test the rollback. Before you do this next part, predict what will happen: if you like a post while the API server is completely stopped, will the heart stay filled or revert?
- Switch to the terminal where JSON Server is running.
- Press Ctrl+C to stop it.
- Go back to the browser and click the heart on a post.
The heart fills instantly. Then, after a moment, it unfills and the like count decrements back to its original value. The onError callback restored the cache snapshot, rolling back the optimistic change.
Why is this pattern so powerful?
The user sees instant feedback on every tap. On success, the server confirms what the UI already shows. On failure, the UI quietly corrects itself. The user never stares at a loading spinner for a single like.
- Restart JSON Server by running this command in the same terminal:
npx json-server db.json --port 3001
Finally, verify that React.memo is preventing unnecessary re-renders. PostCard is already wrapped in React.memo from a previous step, and handleLike uses useCallback to keep a stable reference. Together, these ensure only the liked post re-renders.
- Open the browser console (Console tab in DevTools).
- In PostCard.tsx, temporarily add this line at the top of the PostCard function body (right after the opening {):
console.log('Rendering post', post.id)
- Save PostCard.tsx and switch back to the browser.
- Clear the console, then click the heart on one post.
You should see only one log line for the post you liked, not a log for every visible post. This confirms React.memo is working. Without it, liking one post would re-render every post in the virtualized window.
- Remove the console.log line from PostCard.tsx and save.
Seeing multiple log lines when you like one post?
- Check that handleLike is wrapped in useCallback. Without it, handleLike gets a new reference on every render, which defeats React.memo.
- React Strict Mode in development double-renders components on purpose. You may see 2 logs for the liked post, which is normal. What matters is that you do NOT see logs for every other post.
Why is React.memo not preventing re-renders of other PostCards?
✔️ Awesome, I've got everything!
Great. Double check you've saved both Feed.tsx and PostCard.tsx (with the console.log removed).
ⓧ I'd like to double check the full code
Feed.tsx is too long to show in one block. Scroll up and confirm each section is present in this order:
- Imports: useRef, useCallback, useEffect from React. useInfiniteQuery, useMutation, useQueryClient from TanStack Query. useVirtualizer from TanStack Virtual. fetchPosts and toggleLike from ../api. PostCard, PostCardSkeleton, and Post + PostsResponse from ../types.
- useQueryClient and parentRef declarations.
- useInfiniteQuery for fetching paginated posts.
- allPosts via flatMap.
- useVirtualizer setup.
- useEffect for infinite scroll trigger.
- likeMutation with onMutate, onError, and onSettled.
- handleLike wrapped in useCallback.
- Pending and error states, then the virtualized render with onLike={handleLike}.
PostCard.tsx should be unchanged (still wrapped in React.memo, with the temporary console.log removed).
Your feed now handles likes the way Instagram does. The heart fills the instant you tap it, with no spinner, no delay, and no stale data if the server disagrees. Next up, you'll take on a Secret Mission to add a "New posts" banner that polls for fresh content without hijacking your scroll position.
Secret mission
Add a "New Posts" Banner
In a real feed, new posts can arrive at any time. If you auto-inject them at the top, the user's scroll position jumps and they lose their place. Can you add a polling-based "New posts" banner that detects fresh content and lets the user choose when to load it, just like Instagram does?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs or subscriptions to worry about. Decide whether to keep your project files, pause the running servers, or delete everything entirely.
Resources you used:
- The instagram-feed project folder on your Desktop, containing all source code, configuration files, and node_modules.
- The db.json mock database file with 200 generated posts.
- Two running local servers: the Vite dev server (port 5173) and JSON Server (port 3001).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the feed.
- Your Vite dev server and JSON Server will keep running in their terminal windows.
- Your project files stay on your Desktop in the instagram-feed folder.
- To restart the servers later, open two terminal windows, cd into instagram-feed, and run npm run dev in one and npx json-server db.json --port 3001 in the other.
Pause - I'll come back to this later
Stop the running servers to free up terminal windows and memory. Your project files stay intact.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Switch to the terminal running JSON Server and press Ctrl+C to stop it.
- Your instagram-feed folder and all its files remain on your Desktop.
- To pick up where you left off, open two terminal windows, cd into ~/Desktop/instagram-feed, and restart both servers.
Delete - I don't want to use this again
Remove all project files and stop any running processes. This frees up disk space and clears everything you built.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Switch to the terminal running JSON Server and press Ctrl+C to stop it.
- Delete the entire project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/instagram-feed
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\instagram-feed
- Confirm the folder is gone by running:
ls ~/Desktop
- You should no longer see instagram-feed in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade Instagram-style feed that uses the same frontend system design patterns powering apps like Instagram, Twitter, and Facebook.
You've learned how to:
- Build a virtualized infinite-scroll feed that handles thousands of posts with only ~15 DOM nodes at any time, using TanStack Virtual's useVirtualizer and cursor-based pagination with TanStack Query's useInfiniteQuery.
- Eliminate blank loading states with skeleton screens and prevent layout shift with image lazy loading using loading="lazy" and reserved aspect-ratio containers.
- Implement optimistic likes with useMutation that feel instant to the user, complete with snapshot-based rollback on failure, and memoize components with React.memo so liking one post does not re-render the entire feed.
- Secret Mission: Add a polling-based "New posts" banner that detects fresh content every 30 seconds and lets the user tap to load new posts without losing their scroll position.
Ready to quiz yourself?