Module 9.4: Mini Project 7 - Social Media Feed

🛠️ Image Assets Script
Run the following script in your terminal to create the placeholder image files:
mkdir -p images/b09-4
touch images/b09-4/b09-4-banner.png
touch images/b09-4/b09-4-infinite-scroll-logic.png🎯 Learning Objectives
Integrate your TanStack Query knowledge to build a smooth social media feed:
- Infinite Scroll: Load data endlessly as you scroll (using
useInfiniteQuery). - Intersection Observer: Automatically detect when the user scrolls to the bottom to trigger more data loading.
- Optimistic Updates: Deliver an “instant feel” Like feature (Instant Like).
1. Mock API Setup
First, we need an API function capable of returning paged data (using a cursor).
Create the file src/api/posts.ts:
// src/api/posts.ts
export interface Post {
id: number;
content: string;
likes: number;
isLiked: boolean;
}
interface FetchPostsResponse {
data: Post[];
nextPage: number | undefined;
}
// Giả lập DB
const POSTS_DB: Post[] = Array.from({ length: 50 }).map((_, i) => ({
id: i + 1,
content: `This is post #${i + 1}`,
likes: Math.floor(Math.random() * 100),
isLiked: false,
}));
export const fetchPosts = async ({ pageParam = 0 }): Promise<FetchPostsResponse> => {
// Giả lập delay mạng 1s
await new Promise((resolve) => setTimeout(resolve, 1000));
const LIMIT = 10;
const start = pageParam * LIMIT;
const end = start + LIMIT;
const slice = POSTS_DB.slice(start, end);
return {
data: slice,
nextPage: end < POSTS_DB.length ? pageParam + 1 : undefined,
};
};
export const likePost = async ({ id, isLiked }: { id: number; isLiked: boolean }): Promise<Post | undefined> => {
await new Promise((resolve) => setTimeout(resolve, 500));
// Logic server (giả định) sẽ update DB thật
const post = POSTS_DB.find((p) => p.id === id);
if (post) {
post.isLiked = isLiked;
post.likes += isLiked ? 1 : -1;
}
return post;
};2. useInfiniteQuery (Infinite Scroll Logic)
useInfiniteQuery differs from useQuery by returning an array of pages (data.pages) instead of a flat array.

Create the file src/components/Feed.tsx:
// src/components/Feed.tsx
import React, { useEffect } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { fetchPosts } from '../api/posts';
import PostItem from './PostItem';
export default function Feed() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0,
});
// Automatically load more when the div at the end is in view
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, fetchNextPage, hasNextPage]);
if (status === 'pending') return <div>Loading initial feed...</div>;
if (status === 'error') return <div>Error loading feed</div>;
return (
<div className="max-w-md mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold mb-6">Social Feed</h1>
{/* React Query returns an array of pages; we need to map through each page */}
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.data.map((post) => (
<PostItem key={post.id} post={post} />
))}
</React.Fragment>
))}
{/* Invisible div to detect scroll */}
<div ref={ref} className="h-10 flex justify-center items-center">
{isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Scroll for more' : 'Nothing more to load'}
</div>
</div>
);
}3. PostItem & Optimistic Like
Create the file src/components/PostItem.tsx. Here, we’ll apply the Optimistic UI technique for the Like button.
// src/components/PostItem.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Heart } from 'lucide-react';
import { likePost, Post } from '../api/posts';
interface PostItemProps {
post: Post;
}
export default function PostItem({ post }: PostItemProps) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: likePost,
// Step 1: Runs immediately when the button is clicked (before the API call)
onMutate: async ({ id, isLiked }) => {
// Cancel previous queries to avoid conflicts
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot old data (to rollback if an error occurs)
const previousData = queryClient.getQueryData(['posts']);
// Manually update the Cache (Optimistic Update)
queryClient.setQueryData(['posts'], (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page: any) => ({
...page,
data: page.data.map((p: Post) => {
if (p.id === id) {
return {
...p,
isLiked: isLiked,
likes: p.likes + (isLiked ? 1 : -1),
};
}
return p;
}),
})),
};
});
return { previousData };
},
// Step 2: If an error occurs, roll back to previous data
onError: (err, newTodo, context) => {
queryClient.setQueryData(['posts'], context?.previousData);
},
// Step 3: Always refetch at the end to ensure consistency with the server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleToggleLike = () => {
mutation.mutate({
id: post.id,
isLiked: !post.isLiked, // Toggle the current state
});
};
return (
<div className="border rounded-lg p-4 shadow-sm bg-white">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-gray-200"></div>
<span className="font-bold text-sm">User {post.id}</span>
</div>
<p className="mb-4 text-gray-800">{post.content}</p>
<button
onClick={handleToggleLike}
disabled={mutation.isPending} // Disable while a real request is being sent
className={`flex items-center gap-2 text-sm font-medium transition-colors ${
post.isLiked ? 'text-red-500' : 'text-gray-500 hover:text-red-500'
}`}
>
<Heart className={`w-5 h-5 ${post.isLiked ? 'fill-current' : ''}`} />
{post.likes} Likes
</button>
</div>
);
}4. Recap
This project, though small, incorporates two of the most advanced UI/UX techniques available today:
- Infinite Scroll: Nobody uses traditional pagination (1, 2, 3) for social media feeds anymore.
- Optimistic UI: Delivers an “instantaneous” app feel by providing immediate feedback, even with slow network connections.
Note: The onMutate logic for updating Deep Nested Arrays (Pages -> Data -> Post) can be quite complex. Familiarize yourself with immutable data structure manipulation.