Skip to Content
Module 9: Global & Server State🎯 Mini Project: Social Feed

Module 9.4: Mini Project 7 - Social Media Feed

A vertical smartphone screen displaying an infinite social media feed. As the user swipes up, new hexagonal content cards dynamically load and lock into place at the bottom. The cards feature vibrant images and interactive 'Like' buttons that are glowing with energy (Optimistic UI). The background serves as a digital data stream in a cyan and purple palette.

🛠️ 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.

System architecture diagram illustrating infinite scroll. A user's viewport scrolls down, hitting an 'Invisible Div' (Sensor). This sensor triggers the fetchNextPage() function. React Query then merges 'Page 1' + 'Page 2' and subsequent pages into a single, continuous list. The visual style is a flowchart with clear scroll interactions.

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:

  1. Infinite Scroll: Nobody uses traditional pagination (1, 2, 3) for social media feeds anymore.
  2. 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.


5. Resources

Last updated on