Nguồn: GreatFrontEnd Cập nhật: 2026 Đối tượng: Frontend engineer (junior → senior) muốn hiểu sâu cách thiết kế một news feed như Facebook/Twitter
News feed là một trong những bài toán frontend system design kinh điển vì nó buộc bạn phải đánh đổi (tradeoff) rất nhiều thứ:
Câu hỏi phỏng vấn thường là:
"Thiết kế một web application cho phép user duyệt feed bài viết, react bài viết, và tạo bài mới. Tập trung vào kiến trúc frontend và giao tiếp client/server."
Ví dụ thực tế: Facebook, Twitter/X, Quora, Reddit.
Trước khi bắt tay vào thiết kế, luôn hỏi để xác định scope:
| Câu hỏi | Trả lời mặc định |
|---|---|
| Core features là gì? | Duyệt feed, like/react, tạo bài mới |
| Loại bài nào? | Text + image. Video/poll nếu có thời gian |
| Pagination UX? | Infinite scroll |
| Cần mobile không? | Web-first, mobile-friendly là bonus |
| Cần comments không? | Không trong scope chính, nhưng có thể thảo luận thêm |
| Cần SEO không? | Không — feed đã đăng nhập, cá nhân hóa |
Đây là quyết định đầu tiên và quan trọng nhất.
Server tạo ra toàn bộ HTML rồi gửi về browser. User thấy nội dung ngay khi trang load xong.
Client Server
|--- GET /feed -------->|
| | Tạo HTML với data
|<-- HTML đầy đủ -------|
| Hiển thị ngay |
| (hydration) |
Ưu điểm: First paint nhanh, SEO tốt (crawler đọc được HTML) Nhược điểm: Mỗi request đều tốn server, khó giữ state phức tạp
Server chỉ trả về HTML shell + JavaScript. Browser tự fetch data và render.
Client Server
|--- GET /feed -------->|
|<-- HTML shell + JS ---|
| Parse & run JS |
|--- GET /api/feed ---->|
|<-- JSON data ---------|
| Render UI |
Ưu điểm: Sau lần load đầu, navigation cực nhanh; giữ state tốt trong session dài Nhược điểm: Lần đầu tiên chậm hơn SSR một chút
Kết hợp cả hai: trang đầu render trên server, sau đó dùng CSR cho các route còn lại.
→ Chọn CSR cho signed-in home feed.
Lý do: Feed đã đăng nhập là nội dung cá nhân hóa → SEO không quan trọng. Điều quan trọng hơn là:
Dùng SSR cho: public post permalink, logged-out pages, marketing pages — những trang cần SEO.
Trong interview: Nếu bị hỏi nhanh về rendering strategy, trả lời "CSR cho signed-in home feed, SSR cho public pages" là câu trả lời an toàn.
Framework như Next.js hay TanStack Start cho phép mix cả hai strategies theo từng route.
Mỗi lần navigate → browser load trang mới từ đầu. State bị xóa hoàn toàn.
App load một lần, navigate bằng JavaScript (thay đổi URL, fetch data, update DOM). State tồn tại xuyên suốt session.
→ Chọn SPA.
Lý do: Khi user click vào một bài viết từ feed, trong SPA:
Trong MPA: mỗi navigate đều xóa sạch state → phải fetch lại mọi thứ → chậm và tốn bandwidth.
Sau khi chọn CSR + SPA, chia frontend thành 4 layers rõ ràng:
┌──────────────────────────────────────────────────────┐
│ VIEW LAYER │
│ React Components: FeedPage, FeedPost, Composer... │
│ Chỉ quan tâm: render data + handle user events │
├──────────────────────────────────────────────────────┤
│ STORE LAYER │
│ Zustand / Redux / Jotai │
│ Source of truth: posts, users, feed, composer draft │
│ Chỉ quan tâm: quản lý state, optimistic updates │
├──────────────────────────────────────────────────────┤
│ DATA ACCESS LAYER │
│ TanStack Query / SWR / Relay / Apollo │
│ Chỉ quan tâm: fetch, cache, normalize, retry │
├──────────────────────────────────────────────────────┤
│ SERVER LAYER │
│ HTTP API: /feed, /posts, /reactions, /media │
│ Chỉ quan tâm: cung cấp data canonical │
└──────────────────────────────────────────────────────┘
Tại sao chia layer?
Mỗi layer có trách nhiệm riêng biệt (Separation of Concerns). Nếu không chia:
Ví dụ setup:
// App.tsx — wiring tất cả layers lại với nhau
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import FeedPage from './pages/FeedPage'
import PostDetailPage from './pages/PostDetailPage'
// QueryClient là "Data Access Layer" — xử lý fetch, cache, retry
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Sau 1 phút, data được coi là stale → refetch khi focus lại tab
staleTime: 1000 * 60,
// Giữ cache 5 phút sau khi component unmount
gcTime: 1000 * 60 * 5,
// Tự động retry 3 lần nếu request thất bại
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000),
},
},
})
export default function App() {
return (
// QueryClientProvider cung cấp Data Access Layer cho toàn app
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<FeedPage />} />
<Route path="/posts/:postId" element={<PostDetailPage />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
Data model quyết định:
// Loại reaction mà user có thể chọn
type ReactionType = 'like' | 'love' | 'haha' | 'wow' | 'sad' | 'angry'
// Body của post — lưu dạng plain text + metadata về entities
// KHÔNG lưu HTML để tránh XSS
type PostBody = {
text: string // plain text, ví dụ: "Xem thử @greatfrontend tại đây #webdev"
entities: Array<{
type: 'mention' | 'hashtag' | 'link'
start: number // index bắt đầu trong string (inclusive)
end: number // index kết thúc (exclusive, giống String.slice)
userId?: string // chỉ có nếu type === 'mention'
url?: string // chỉ có nếu type === 'link'
}>
}
// Thống kê tương tác của bài viết
type EngagementSummary = {
reactions: Record<ReactionType, number> // { like: 150, love: 23, ... }
totalReactions: number // tổng tất cả reactions
commentCount: number
shareCount: number
}
// Entity chính — đây là "canonical" post được lưu trong store
type Post = {
id: string
authorId: string // reference đến User, không embed trực tiếp
body: PostBody
mediaIds: string[] // reference đến Media, không embed trực tiếp
engagementSummary: EngagementSummary
viewerReaction: ReactionType | null // reaction hiện tại của người đang xem
viewerHasShared: boolean
createdAt: number // Unix timestamp (seconds)
}
Chú ý quan trọng: authorId và mediaIds là references (ID), không phải object lồng nhau. Lý do sẽ giải thích ở phần Normalized Store.
type RelationshipToViewer = {
isFriend?: boolean // có phải bạn bè không
isFollowing?: boolean // mình có đang follow họ không
isMuted?: boolean // có đang mute họ không
isBlocked?: boolean // có đang block họ không
}
type User = {
id: string
name: string // tên hiển thị: "Nguyễn Văn A"
handle: string // username: "@nguyenvana"
profilePhotoUrl: string
isVerified: boolean // tick xanh
relationshipToViewer: RelationshipToViewer
}
type Media = {
id: string
src: string // URL gốc
previewSrc?: string // URL thumbnail/blur placeholder để show trước khi load xong
alt: string // mô tả cho screen reader và SEO
width: number // cần để reserve không gian → tránh CLS
height: number
}
Feed không phải là một mảng bài viết lồng nhau. Feed là danh sách ID cộng với metadata để phân trang.
type Feed = {
id: string // ví dụ: "home-feed"
postIds: string[] // ["post_1", "post_3", "post_7"...] — ordered list
olderCursor: string | null // dùng để load bài cũ hơn (scroll xuống)
newerCursor: string | null // dùng để check bài mới hơn (khi feed stale)
hasOlder: boolean // còn bài cũ hơn để load không?
hasNewer: boolean // có bài mới hơn không?
lastFetchedAt: number | null // timestamp lần fetch gần nhất
}
Tưởng tượng bạn lưu user info bên trong từng post:
// ❌ Denormalized — user bị duplicate trong nhiều posts
const store = {
posts: [
{
id: 'post_1',
author: { id: 'user_1', name: 'An', profilePhotoUrl: '...' },
// ...
},
{
id: 'post_2',
author: { id: 'user_1', name: 'An', profilePhotoUrl: '...' }, // DUPLICATE!
// ...
},
// User 1 có thể là author của 20 bài → bị copy 20 lần
]
}
Vấn đề:
// ✅ Normalized — mỗi entity lưu đúng MỘT lần, reference bằng ID
type Store = {
feedsById: Record<string, Feed> // { "home-feed": Feed }
postsById: Record<string, Post> // { "post_1": Post, "post_2": Post, ... }
usersById: Record<string, User> // { "user_1": User, "user_2": User, ... }
mediaById: Record<string, Media> // { "media_1": Media, ... }
composerDraft: ComposerDraft // state của ô soạn bài
}
type ComposerDraft = {
body: PostBody
mediaIds: string[]
uploadState: 'idle' | 'uploading' | 'failed'
submitState: 'idle' | 'submitting' | 'submitted' | 'failed'
}
Lợi ích:
usersById['user_1'] → tất cả nơi dùng user đó tự cập nhậtPost object → phản ánh khắp nơiimport { create } from 'zustand'
import { immer } from 'zustand/middleware/immer' // dùng immer để mutate state dễ hơn
interface FeedStore {
// State
postsById: Record<string, Post>
usersById: Record<string, User>
mediaById: Record<string, Media>
feedsById: Record<string, Feed>
composerDraft: ComposerDraft
// Actions
mergeResponse: (data: ApiResponse) => void
appendOlderPosts: (feedId: string, postIds: string[], olderCursor: string | null, hasOlder: boolean) => void
applyReactionOptimistic: (postId: string, reaction: ReactionType | null) => void
rollbackReaction: (postId: string, previousReaction: ReactionType | null) => void
prependNewPost: (post: Post, author: User, media: Media[]) => void
updateComposerDraft: (partial: Partial<ComposerDraft>) => void
}
export const useFeedStore = create<FeedStore>()(
immer((set) => ({
// Initial state
postsById: {},
usersById: {},
mediaById: {},
feedsById: {},
composerDraft: {
body: { text: '', entities: [] },
mediaIds: [],
uploadState: 'idle',
submitState: 'idle',
},
// Nhận response từ API và merge vào store
// API trả về nested objects → ta normalize tại đây
mergeResponse: (data) =>
set((state) => {
// Merge posts
for (const post of data.posts ?? []) {
state.postsById[post.id] = post
}
// Merge users
for (const user of data.users ?? []) {
state.usersById[user.id] = user
}
// Merge media
for (const media of data.media ?? []) {
state.mediaById[media.id] = media
}
// Update feed postIds và cursor
if (data.feedId && data.postIds) {
const feed = state.feedsById[data.feedId]
if (feed) {
feed.postIds = data.postIds
feed.olderCursor = data.olderCursor ?? null
feed.hasOlder = data.hasOlder ?? false
feed.lastFetchedAt = Date.now()
} else {
state.feedsById[data.feedId] = {
id: data.feedId,
postIds: data.postIds,
olderCursor: data.olderCursor ?? null,
newerCursor: null,
hasOlder: data.hasOlder ?? false,
hasNewer: false,
lastFetchedAt: Date.now(),
}
}
}
}),
// Thêm bài cũ hơn khi user scroll xuống
appendOlderPosts: (feedId, newPostIds, olderCursor, hasOlder) =>
set((state) => {
const feed = state.feedsById[feedId]
if (!feed) return
// Thêm ID vào cuối (bài cũ hơn)
feed.postIds = [...feed.postIds, ...newPostIds]
feed.olderCursor = olderCursor
feed.hasOlder = hasOlder
}),
// Cập nhật reaction ngay lập tức (optimistic) không cần chờ server
applyReactionOptimistic: (postId, reaction) =>
set((state) => {
const post = state.postsById[postId]
if (!post) return
const prevReaction = post.viewerReaction
// Cập nhật reaction của viewer
post.viewerReaction = reaction
// Cập nhật totalReactions
if (prevReaction && !reaction) {
// Bỏ reaction
post.engagementSummary.totalReactions -= 1
post.engagementSummary.reactions[prevReaction] -= 1
} else if (!prevReaction && reaction) {
// Thêm reaction mới
post.engagementSummary.totalReactions += 1
post.engagementSummary.reactions[reaction] =
(post.engagementSummary.reactions[reaction] ?? 0) + 1
} else if (prevReaction && reaction && prevReaction !== reaction) {
// Đổi reaction
post.engagementSummary.reactions[prevReaction] -= 1
post.engagementSummary.reactions[reaction] =
(post.engagementSummary.reactions[reaction] ?? 0) + 1
}
}),
// Rollback về state trước nếu server trả lỗi
rollbackReaction: (postId, previousReaction) =>
set((state) => {
const post = state.postsById[postId]
if (!post) return
post.viewerReaction = previousReaction
// Recalculate totalReactions từ reactions object
post.engagementSummary.totalReactions = Object.values(
post.engagementSummary.reactions
).reduce((sum, count) => sum + count, 0)
}),
// Thêm bài mới lên đầu feed sau khi tạo thành công
prependNewPost: (post, author, media) =>
set((state) => {
state.postsById[post.id] = post
state.usersById[author.id] = author
for (const m of media) {
state.mediaById[m.id] = m
}
const homeFeed = state.feedsById['home-feed']
if (homeFeed) {
homeFeed.postIds = [post.id, ...homeFeed.postIds]
}
// Reset composer
state.composerDraft = {
body: { text: '', entities: [] },
mediaIds: [],
uploadState: 'idle',
submitState: 'idle',
}
}),
updateComposerDraft: (partial) =>
set((state) => {
Object.assign(state.composerDraft, partial)
}),
}))
)
Identity phải đến từ session cookie, KHÔNG nhận
userIdtừ request body hay query params.
Nếu client gửi userId, attacker có thể giả mạo và đọc feed của người khác. Server phải lấy userId từ session/token đã được xác thực.
GET /feed?cursor=<olderCursor>&count=20&direction=older
| Parameter | Mô tả |
|---|---|
cursor | ID của bài cuối cùng đã load (để load bài cũ hơn) |
count | Số lượng bài muốn load (mặc định 20) |
direction | older = cuộn xuống, newer = check bài mới |
Response mẫu:
{
"feedId": "home-feed",
"postIds": ["post_3", "post_7", "post_12"],
"olderCursor": "post_12",
"newerCursor": "post_3",
"hasOlder": true,
"hasNewer": false,
"posts": [
{
"id": "post_3",
"authorId": "user_42",
"body": {
"text": "Hello @everyone #webdev",
"entities": [
{ "type": "mention", "start": 6, "end": 14, "userId": "user_99" },
{ "type": "hashtag", "start": 15, "end": 22 }
]
},
"mediaIds": [],
"engagementSummary": {
"reactions": { "like": 150, "love": 23 },
"totalReactions": 173,
"commentCount": 45,
"shareCount": 12
},
"viewerReaction": null,
"viewerHasShared": false,
"createdAt": 1715680000
}
],
"users": [
{ "id": "user_42", "name": "Nguyễn An", "handle": "@an", "profilePhotoUrl": "...", "isVerified": false, "relationshipToViewer": { "isFriend": true } }
],
"media": []
}
Lưu ý: Server trả về nested response (post kèm users và media liên quan). Data Access Layer sẽ normalize trước khi đưa vào store.
Offset pagination: "Cho tôi 3 bài, bắt đầu từ vị trí 3"
Lần 1 fetch: [F, E, D] (offset=0, limit=3)
→ User đang đọc D
Ai đó đăng bài H và G mới
Lần 2 fetch: ?offset=3&limit=3
→ Server trả: [E, D, C] ← BỊ TRÙNG! E và D đã có rồi
Vì dataset thay đổi liên tục (bài mới được đăng), offset bị trật bánh.
Cursor pagination: "Cho tôi 3 bài, cũ hơn bài D"
Lần 1 fetch: [F, E, D] → olderCursor = "D"
→ User đang đọc D
Ai đó đăng bài H và G mới
Lần 2 fetch: ?cursor=D&limit=3
→ Server trả: [C, B, A] ← ĐÚNG! Chỉ load bài cũ hơn D
Cursor là ID của bài cuối cùng — không phụ thuộc vào vị trí tuyệt đối nên ổn định dù feed thay đổi.
// hooks/useFeed.ts
import { useInfiniteQuery } from '@tanstack/react-query'
import { useFeedStore } from '../store/feedStore'
interface FeedPage {
postIds: string[]
olderCursor: string | null
hasOlder: boolean
}
async function fetchFeedPage(cursor: string | null): Promise<FeedPage> {
const params = new URLSearchParams()
params.set('count', '20')
if (cursor) params.set('cursor', cursor)
const res = await fetch(`/feed?${params}`)
if (!res.ok) throw new Error(`Feed fetch failed: ${res.status}`)
const data = await res.json()
// Normalize: lấy entities ra, đưa vào store
// (trong thực tế, ta dùng middleware hoặc onSuccess callback)
return {
postIds: data.postIds,
olderCursor: data.olderCursor,
hasOlder: data.hasOlder,
}
}
export function useFeed() {
return useInfiniteQuery({
queryKey: ['feed', 'home'],
queryFn: ({ pageParam }) => fetchFeedPage(pageParam),
initialPageParam: null as string | null,
// Hàm này quyết định cursor để fetch page tiếp theo
getNextPageParam: (lastPage) =>
lastPage.hasOlder ? lastPage.olderCursor : undefined,
// Không stale trong 30 giây (tránh refetch khi focus lại tab quá sớm)
staleTime: 30_000,
})
}
GET /posts/{postId} → Fetch single post (cho post detail page)
PUT /posts/{postId}/reaction → Set/change reaction
DELETE /posts/{postId}/reaction → Remove reaction
POST /posts → Tạo bài viết mới
POST /media/uploads → Upload ảnh/video
Khi user đăng bài có ảnh:
Step 1: Upload ảnh
POST /media/uploads
Body: FormData với file ảnh
→ Response: { mediaId: "m_1", uploadUrl: "https://cdn.example.com/upload/..." }
Step 2: Upload binary lên CDN (trực tiếp, không qua app server)
PUT {uploadUrl}
Body: binary image data
→ Ảnh được lưu trên CDN
Step 3: Tạo post với mediaId vừa nhận
POST /posts
Body: {
body: { text: "Ảnh đẹp quá!", entities: [] },
mediaIds: ["m_1"]
}
→ Response: post object đầy đủ
Tại sao upload thẳng lên CDN thay vì qua app server?
Vấn đề: Mạng chập chờn, request thất bại, client retry → server tạo 2 bài giống nhau!
Giải pháp: Mỗi mutation đính kèm một unique key. Server nhận key đã thấy → trả về kết quả của lần đầu thay vì tạo mới.
// utils/idempotency.ts
import { v4 as uuidv4 } from 'uuid'
// Tạo key LÚC USER SUBMIT, không phải lúc fire request
// → khi retry, dùng lại cùng key → server biết đây là duplicate
export function generateIdempotencyKey(): string {
return uuidv4()
}
// Wrapper cho mutation requests
export async function mutateWithIdempotency(
url: string,
options: RequestInit,
idempotencyKey: string
): Promise<Response> {
return fetch(url, {
...options,
headers: {
...options.headers,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
})
}
Khi user scroll xuống hàng trăm bài:
Ví dụ: 200 bài × 50 DOM nodes/bài = 10.000 nodes trong DOM. Quá nhiều.
Ý tưởng: Chỉ render ~10-15 bài trong và quanh viewport. Bài ngoài viewport → thay bằng <div> trống với đúng chiều cao.
Viewport (màn hình)
┌─────────────────┐
│ Post 5 [REAL] │ ← đang nhìn thấy
│ Post 6 [REAL] │ ← đang nhìn thấy
│ Post 7 [REAL] │ ← đang nhìn thấy
├─────────────────┤
│ Post 8 [REAL] │ ← overscan (chuẩn bị sẵn)
│ Post 9 [REAL] │ ← overscan
└─────────────────┘
Post 1-4: <div height="1200px"> (spacer, không có content)
Post 10+: <div height="900px"> (spacer, không có content)
Scroll bar vẫn đúng vì tổng height được tính toán.
// components/VirtualFeed.tsx
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useCallback } from 'react'
import { FeedPost } from './FeedPost'
interface VirtualFeedProps {
postIds: string[]
onReachBottom: () => void
isLoadingMore: boolean
}
export function VirtualFeed({ postIds, onReachBottom, isLoadingMore }: VirtualFeedProps) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: postIds.length,
// Hàm này trả về scroll container
getScrollElement: () => parentRef.current,
// Ước tính chiều cao mỗi bài (virtualizer sẽ đo lại sau khi render)
estimateSize: () => 350,
// Render thêm 3 bài trên/dưới viewport (để scroll mượt hơn)
overscan: 3,
})
// Trigger load more khi gần đáy
const handleScroll = useCallback(() => {
const scrollEl = parentRef.current
if (!scrollEl || isLoadingMore) return
const { scrollTop, scrollHeight, clientHeight } = scrollEl
// Khi còn 1 viewport height nữa là chạm đáy → load trước
if (scrollHeight - scrollTop - clientHeight < clientHeight) {
onReachBottom()
}
}, [isLoadingMore, onReachBottom])
return (
<div
ref={parentRef}
onScroll={handleScroll}
style={{
height: '100vh',
overflow: 'auto',
// Quan trọng: contain: strict giúp browser tối ưu paint
contain: 'strict',
}}
>
{/* Container với tổng height đúng → scroll bar đúng */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={postIds[virtualItem.index]}
// ref để virtualizer đo chiều cao thực sau khi render
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// Dịch chuyển đến đúng vị trí trong scroll container
transform: `translateY(${virtualItem.start}px)`,
}}
>
<FeedPost postId={postIds[virtualItem.index]} />
</div>
))}
</div>
{isLoadingMore && (
<div style={{ padding: 16 }}>
<SkeletonPost />
<SkeletonPost />
<SkeletonPost />
</div>
)}
</div>
)
}
Lưu ý khi dùng virtualization:
- Nếu user đang focus vào một element (ví dụ reaction menu) và element đó scroll ra ngoài viewport → bị unmount → mất focus. Cần xử lý trường hợp này bằng cách giữ item mounted nếu đang focused.
- Browser's
Ctrl+F(find in page) chỉ tìm được text đang trong DOM. Bài ngoài viewport sẽ không tìm được.
Thay vì dùng scroll event (sync, tốn main thread), dùng Intersection Observer API — browser tự gọi callback khi element vào/ra viewport.
// hooks/useInfiniteScroll.ts
import { useEffect, useRef } from 'react'
interface UseInfiniteScrollOptions {
onTrigger: () => void
isDisabled?: boolean
// Khoảng cách từ đáy để trigger (ví dụ "200px" = trigger khi còn 200px nữa)
rootMargin?: string
}
export function useInfiniteScroll({
onTrigger,
isDisabled = false,
rootMargin = '200px',
}: UseInfiniteScrollOptions) {
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (isDisabled) return
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
// entries[0] là sentinel element
if (entries[0].isIntersecting) {
onTrigger()
}
},
{
// rootMargin: trigger trước khi sentinel thực sự vào viewport
// "200px" = trigger khi sentinel còn cách đáy viewport 200px
rootMargin,
}
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [onTrigger, isDisabled, rootMargin])
// Trả về ref để gắn vào sentinel element
return sentinelRef
}
// Dùng trong component
function FeedFooter({ onLoadMore, hasMore, isLoading }: {
onLoadMore: () => void
hasMore: boolean
isLoading: boolean
}) {
const sentinelRef = useInfiniteScroll({
onTrigger: onLoadMore,
isDisabled: !hasMore || isLoading,
rootMargin: '300px', // Trigger khi còn 300px nữa → user không thấy loading
})
return (
<>
{/* Sentinel element vô hình — đặt ở cuối list */}
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden="true" />
{isLoading && (
<div role="status" aria-label="Đang tải thêm bài...">
<SkeletonPost />
<SkeletonPost />
</div>
)}
{!hasMore && (
<p style={{ textAlign: 'center', padding: 16, color: '#666' }}>
Bạn đã xem hết feed 🎉
</p>
)}
</>
)
}
Spinner: "Đang load, không biết load bao lâu, không biết layout sẽ trông thế nào"
Skeleton: "Đang load, và đây là hình dạng xấp xỉ của nội dung sắp xuất hiện"
Skeleton giúp user cảm thấy trang tải nhanh hơn vì não họ đã "preview" được layout.
// components/SkeletonPost.tsx
import './SkeletonPost.css'
export function SkeletonPost() {
return (
// aria-hidden: không cần screen reader đọc skeleton
<div className="skeleton-post" aria-hidden="true">
{/* Avatar */}
<div className="skeleton-row">
<div className="skeleton skeleton-avatar" />
<div className="skeleton-meta">
<div className="skeleton skeleton-name" />
<div className="skeleton skeleton-time" />
</div>
</div>
{/* Nội dung text */}
<div className="skeleton skeleton-line" style={{ width: '100%' }} />
<div className="skeleton skeleton-line" style={{ width: '90%' }} />
<div className="skeleton skeleton-line" style={{ width: '75%' }} />
{/* Ảnh (không phải lúc nào cũng có, nhưng reserve không gian) */}
<div className="skeleton skeleton-image" />
{/* Action buttons */}
<div className="skeleton-actions">
<div className="skeleton skeleton-button" />
<div className="skeleton skeleton-button" />
<div className="skeleton skeleton-button" />
</div>
</div>
)
}
/* SkeletonPost.css */
.skeleton {
background: #e0e0e0;
border-radius: 4px;
}
/* Hiệu ứng shimmer — ánh sáng chạy qua */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#e0e0e0 25%,
#f0f0f0 50%,
#e0e0e0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Tắt animation nếu user cài prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: #e0e0e0;
}
}
.skeleton-post { padding: 16px; border-bottom: 1px solid #eee; }
.skeleton-row { display: flex; gap: 12px; margin-bottom: 12px; }
.skeleton-avatar { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
.skeleton-meta { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.skeleton-name { height: 14px; width: 40%; }
.skeleton-time { height: 12px; width: 25%; }
.skeleton-line { height: 14px; margin-bottom: 8px; }
.skeleton-image { height: 300px; width: 100%; margin: 12px 0; border-radius: 8px; }
.skeleton-actions { display: flex; gap: 16px; margin-top: 12px; }
.skeleton-button { height: 32px; width: 80px; border-radius: 4px; }
Sau vài tiếng, feed cũ đi. Cách handle:
// components/StaleFeedBanner.tsx
import { useEffect, useState, useCallback } from 'react'
const STALE_AFTER_MS = 30 * 60 * 1000 // 30 phút
interface StaleFeedBannerProps {
lastFetchedAt: number | null
newPostCount?: number
onRefresh: () => void
}
export function StaleFeedBanner({
lastFetchedAt,
newPostCount = 0,
onRefresh,
}: StaleFeedBannerProps) {
const [isStale, setIsStale] = useState(false)
useEffect(() => {
if (!lastFetchedAt) return
const checkStaleness = () => {
setIsStale(Date.now() - lastFetchedAt > STALE_AFTER_MS)
}
checkStaleness()
// Kiểm tra mỗi phút
const interval = setInterval(checkStaleness, 60_000)
return () => clearInterval(interval)
}, [lastFetchedAt])
if (!isStale && newPostCount === 0) return null
const message = newPostCount > 0
? `Có ${newPostCount} bài viết mới`
: 'Feed của bạn có thể đã cũ'
return (
// role="status" + aria-live="polite": screen reader thông báo
// sau khi user xong nội dung đang đọc, không ngắt giữa chừng
<div
role="status"
aria-live="polite"
className="stale-banner"
style={{
position: 'sticky',
top: 0,
zIndex: 10,
background: '#1877f2',
color: 'white',
padding: '8px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>{message}</span>
<button
onClick={onRefresh}
style={{
background: 'white',
color: '#1877f2',
border: 'none',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
Tải lại
</button>
</div>
)
}
Khi user click vào bài, rồi back về feed → feed phải cuộn về đúng chỗ cũ.
// hooks/useScrollRestoration.ts
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
export function useScrollRestoration(feedId: string) {
const containerRef = useRef<HTMLDivElement>(null)
const location = useLocation()
// Lưu scroll position vào sessionStorage trước khi rời trang
useEffect(() => {
const container = containerRef.current
if (!container) return
const saveScrollPosition = () => {
sessionStorage.setItem(
`scroll-${feedId}`,
String(container.scrollTop)
)
}
// Lưu trước khi navigate đi
window.addEventListener('beforeunload', saveScrollPosition)
return () => window.removeEventListener('beforeunload', saveScrollPosition)
}, [feedId])
// Restore scroll position khi mount lại
useEffect(() => {
const savedPosition = sessionStorage.getItem(`scroll-${feedId}`)
if (!savedPosition || !containerRef.current) return
// Dùng requestAnimationFrame để đảm bảo DOM đã render xong
requestAnimationFrame(() => {
if (containerRef.current) {
containerRef.current.scrollTop = Number(savedPosition)
}
})
}, [feedId])
return containerRef
}
Khi user mở 2 tab: react bài ở tab 1 → tab 2 vẫn hiện trạng thái cũ. Fix:
// utils/crossTabSync.ts
// BroadcastChannel: gửi message đến tất cả tab cùng origin
const channel = new BroadcastChannel('feed-sync')
type SyncMessage =
| { type: 'REACTION_UPDATED'; postId: string; reaction: ReactionType | null }
| { type: 'POST_CREATED'; postId: string }
// Gửi update đến các tab khác
export function broadcastUpdate(message: SyncMessage) {
channel.postMessage(message)
}
// Lắng nghe update từ tab khác
export function listenForUpdates(onMessage: (msg: SyncMessage) => void) {
channel.addEventListener('message', (event) => {
onMessage(event.data as SyncMessage)
})
return () => channel.close()
}
// Dùng trong React component
function useCrossTabSync() {
const { applyReactionOptimistic } = useFeedStore()
useEffect(() => {
return listenForUpdates((msg) => {
if (msg.type === 'REACTION_UPDATED') {
applyReactionOptimistic(msg.postId, msg.reaction)
}
})
}, [applyReactionOptimistic])
}
// components/FeedPost.tsx
import { memo } from 'react'
import { useFeedStore } from '../store/feedStore'
import { PostBody } from './PostBody'
import { FeedImage } from './FeedImage'
import { PostTimestamp } from './PostTimestamp'
import { ReactionBar } from './ReactionBar'
interface FeedPostProps {
postId: string
}
// memo() để tránh re-render khi parent re-render nhưng post không thay đổi
export const FeedPost = memo(function FeedPost({ postId }: FeedPostProps) {
const post = useFeedStore((state) => state.postsById[postId])
const author = useFeedStore((state) =>
post ? state.usersById[post.authorId] : undefined
)
const mediaList = useFeedStore((state) =>
post?.mediaIds.map((id) => state.mediaById[id]).filter(Boolean) ?? []
)
if (!post || !author) return null
return (
<article
role="article"
aria-labelledby={`author-${postId}`}
style={{ padding: 16, borderBottom: '1px solid #eee' }}
>
{/* Header: Avatar + Tên + Thời gian */}
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<img
src={author.profilePhotoUrl}
alt={`Ảnh đại diện của ${author.name}`}
width={40}
height={40}
style={{ borderRadius: '50%' }}
loading="lazy"
/>
<div>
<strong id={`author-${postId}`}>{author.name}</strong>
{author.isVerified && <span aria-label="Đã xác minh"> ✓</span>}
<br />
<PostTimestamp createdAt={post.createdAt} />
</div>
</div>
{/* Nội dung bài viết */}
<PostBody body={post.body} />
{/* Ảnh đính kèm */}
{mediaList.map((media) => (
<FeedImage key={media.id} media={media} />
))}
{/* Reaction bar + comment/share buttons */}
<ReactionBar post={post} />
</article>
)
})
Quy tắc bất biến: KHÔNG bao giờ inject HTML trực tiếp từ server vào DOM. Đây là lỗ hổng XSS kinh điển.
Thay vào đó, dùng entity ranges để render React nodes:
// components/PostBody.tsx
import React from 'react'
import type { PostBody as PostBodyType } from '../types'
// Validate URL scheme: chỉ cho phép http, https, mailto
// javascript:, data:, vbscript: đều bị block — đây là XSS vectors!
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url)
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol)
} catch {
return false
}
}
function renderEntities(body: PostBodyType): React.ReactNode[] {
const { text, entities } = body
const nodes: React.ReactNode[] = []
let cursor = 0
// Sắp xếp entities theo thứ tự xuất hiện trong text
const sortedEntities = [...entities].sort((a, b) => a.start - b.start)
for (const entity of sortedEntities) {
// 1. Thêm text thường trước entity này
if (cursor < entity.start) {
nodes.push(
<React.Fragment key={`text-${cursor}`}>
{text.slice(cursor, entity.start)}
</React.Fragment>
)
}
// 2. Render entity
const entityText = text.slice(entity.start, entity.end)
switch (entity.type) {
case 'mention':
nodes.push(
<a
key={`mention-${entity.start}`}
href={`/profile/${entity.userId}`}
style={{ color: '#1877f2', fontWeight: 500, textDecoration: 'none' }}
>
{entityText}
</a>
)
break
case 'hashtag':
nodes.push(
<a
key={`hashtag-${entity.start}`}
href={`/hashtag/${entityText.slice(1)}`}
style={{ color: '#1877f2', textDecoration: 'none' }}
>
{entityText}
</a>
)
break
case 'link':
// Validate trước khi render!
if (entity.url && isSafeUrl(entity.url)) {
nodes.push(
<a
key={`link-${entity.start}`}
href={entity.url}
target="_blank"
// noopener: ngăn tab mới truy cập window.opener (tab-nabbing attack)
// noreferrer: không gửi Referer header (privacy)
rel="noopener noreferrer"
style={{ color: '#1877f2' }}
>
{entityText}
</a>
)
} else {
// URL không an toàn → render là text thường, không làm link
nodes.push(
<span key={`unsafe-link-${entity.start}`}>{entityText}</span>
)
}
break
}
cursor = entity.end
}
// 3. Thêm phần text còn lại sau entity cuối
if (cursor < text.length) {
nodes.push(
<React.Fragment key={`text-end`}>
{text.slice(cursor)}
</React.Fragment>
)
}
return nodes
}
interface PostBodyProps {
body: PostBodyType
maxLines?: number
}
export function PostBody({ body, maxLines = 5 }: PostBodyProps) {
const [isExpanded, setIsExpanded] = React.useState(false)
const lineCount = body.text.split('\n').length
const needsTruncation = lineCount > maxLines
return (
<div style={{ marginBottom: 12 }}>
<p
style={{
margin: 0,
// CSS line-clamp để truncate text dài
...(needsTruncation && !isExpanded
? {
display: '-webkit-box',
WebkitLineClamp: maxLines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}
: {}),
}}
>
{renderEntities(body)}
</p>
{needsTruncation && !isExpanded && (
<button
onClick={() => setIsExpanded(true)}
style={{
background: 'none',
border: 'none',
color: '#606770',
cursor: 'pointer',
fontWeight: 'bold',
padding: 0,
marginTop: 4,
}}
>
Xem thêm
</button>
)}
</div>
)
}
// components/FeedImage.tsx
import { useState } from 'react'
import type { Media } from '../types'
interface FeedImageProps {
media: Media
}
export function FeedImage({ media }: FeedImageProps) {
const [isLoaded, setIsLoaded] = useState(false)
return (
<div
style={{
position: 'relative',
// Giữ aspect ratio trước khi ảnh load xong → tránh CLS (layout shift)
aspectRatio: `${media.width} / ${media.height}`,
background: '#f0f0f0',
overflow: 'hidden',
borderRadius: 8,
marginBottom: 12,
}}
>
{/* Blur placeholder hiện khi ảnh chưa load xong */}
{media.previewSrc && !isLoaded && (
<img
src={media.previewSrc}
alt=""
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
filter: 'blur(20px)',
transform: 'scale(1.1)', // tránh viền trắng do blur
}}
/>
)}
{/* Ảnh thực sự */}
<picture>
{/* Browser chọn format tốt nhất mà nó hỗ trợ */}
<source
srcSet={`${media.src}?format=avif`}
type="image/avif"
/>
<source
srcSet={`${media.src}?format=webp`}
type="image/webp"
/>
<img
src={media.src}
alt={media.alt}
width={media.width}
height={media.height}
loading="lazy" // browser chỉ load khi gần viewport
decoding="async" // decode ảnh không block main thread
onLoad={() => setIsLoaded(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s ease', // fade in khi load xong
}}
/>
</picture>
</div>
)
}
Vấn đề: Network request mất 200-500ms. Nếu chờ server response mới update UI → user thấy lag.
Giải pháp: Cập nhật UI ngay, gửi request song song. Nếu lỗi → rollback.
// components/ReactionBar.tsx
import { useState, useRef } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useFeedStore } from '../store/feedStore'
import { broadcastUpdate } from '../utils/crossTabSync'
import { generateIdempotencyKey } from '../utils/idempotency'
import type { Post, ReactionType } from '../types'
const REACTION_EMOJIS: Record<ReactionType, string> = {
like: '👍',
love: '❤️',
haha: '😂',
wow: '😮',
sad: '😢',
angry: '😡',
}
interface ReactionBarProps {
post: Post
}
export function ReactionBar({ post }: ReactionBarProps) {
const [showPicker, setShowPicker] = useState(false)
const { applyReactionOptimistic, rollbackReaction } = useFeedStore()
// Lưu snapshot để rollback nếu lỗi
const previousReactionRef = useRef<ReactionType | null>(null)
const { mutate: react } = useMutation({
mutationFn: async (newReaction: ReactionType | null) => {
const key = generateIdempotencyKey() // tạo key LÚC submit
const res = await (newReaction
? fetch(`/posts/${post.id}/reaction`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ reaction: newReaction }),
})
: fetch(`/posts/${post.id}/reaction`, {
method: 'DELETE',
headers: { 'Idempotency-Key': key },
}))
if (!res.ok) throw new Error('Reaction failed')
return res.json()
},
onMutate: (newReaction) => {
// Lưu state hiện tại để rollback nếu cần
previousReactionRef.current = post.viewerReaction
// Cập nhật UI ngay TRƯỚC KHI gửi request
applyReactionOptimistic(post.id, newReaction)
// Sync sang tab khác
broadcastUpdate({ type: 'REACTION_UPDATED', postId: post.id, reaction: newReaction })
},
onError: () => {
// Request thất bại → rollback về state cũ
rollbackReaction(post.id, previousReactionRef.current)
// Thông báo user
alert('Không thể react bài viết. Vui lòng thử lại.')
},
onSuccess: (serverPost: Post) => {
// Server là authoritative — nếu count khác với optimistic → sync lại
applyReactionOptimistic(post.id, serverPost.viewerReaction)
},
})
const handleReactionClick = (reaction: ReactionType) => {
// Click cùng reaction đang có → bỏ reaction
const newReaction = post.viewerReaction === reaction ? null : reaction
react(newReaction)
setShowPicker(false)
}
const totalCount = post.engagementSummary.totalReactions
return (
<div style={{ marginTop: 8 }}>
{/* Hiển thị tổng reactions */}
{totalCount > 0 && (
<div style={{ fontSize: 14, color: '#606770', marginBottom: 8 }}>
{post.viewerReaction && REACTION_EMOJIS[post.viewerReaction]}{' '}
{totalCount >= 1000
? `${(totalCount / 1000).toFixed(1)}K`
: totalCount}{' '}
lượt thích
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', gap: 8 }}>
{/* Like button với hold để mở reaction picker */}
<div style={{ position: 'relative' }}>
<button
onClick={() => handleReactionClick('like')}
onMouseEnter={() => setShowPicker(true)}
onMouseLeave={() => setShowPicker(false)}
aria-label={post.viewerReaction ? `Đang ${post.viewerReaction}, click để bỏ` : 'Thích bài viết'}
aria-pressed={post.viewerReaction !== null}
style={{
background: 'none',
border: '1px solid #ddd',
borderRadius: 20,
padding: '6px 16px',
cursor: 'pointer',
color: post.viewerReaction ? '#1877f2' : '#606770',
fontWeight: post.viewerReaction ? 'bold' : 'normal',
}}
>
{post.viewerReaction
? `${REACTION_EMOJIS[post.viewerReaction]} ${post.viewerReaction}`
: '👍 Thích'}
</button>
{/* Reaction picker — lazy load */}
{showPicker && (
<div
role="toolbar"
aria-label="Chọn reaction"
style={{
position: 'absolute',
bottom: '100%',
left: 0,
background: 'white',
borderRadius: 24,
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
padding: '8px 12px',
display: 'flex',
gap: 4,
zIndex: 10,
}}
>
{(Object.entries(REACTION_EMOJIS) as [ReactionType, string][]).map(
([type, emoji]) => (
<button
key={type}
onClick={() => handleReactionClick(type)}
aria-label={type}
title={type}
style={{
background: 'none',
border: 'none',
fontSize: 28,
cursor: 'pointer',
transform: post.viewerReaction === type ? 'scale(1.3)' : 'scale(1)',
transition: 'transform 0.15s ease',
}}
>
{emoji}
</button>
)
)}
</div>
)}
</div>
<button
style={{ background: 'none', border: '1px solid #ddd', borderRadius: 20, padding: '6px 16px', cursor: 'pointer', color: '#606770' }}
aria-label={`${post.engagementSummary.commentCount} bình luận`}
>
💬 {post.engagementSummary.commentCount}
</button>
<button
style={{ background: 'none', border: '1px solid #ddd', borderRadius: 20, padding: '6px 16px', cursor: 'pointer', color: '#606770' }}
aria-label={`${post.engagementSummary.shareCount} lượt chia sẻ`}
>
🔁 {post.engagementSummary.shareCount}
</button>
</div>
</div>
)
}
Vấn đề: "2 phút trước" được render lúc 10:00, nhưng đến 11:00 vẫn hiện "2 phút trước" vì không có gì trigger re-render.
// components/PostTimestamp.tsx
import { useEffect, useState } from 'react'
function getRelativeTime(createdAtSeconds: number): string {
const now = Date.now()
const diffMs = now - createdAtSeconds * 1000
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) return 'Vừa xong'
if (diffMinutes < 60) return `${diffMinutes} phút trước`
if (diffHours < 24) return `${diffHours} giờ trước`
if (diffDays < 7) return `${diffDays} ngày trước`
// Bài cũ hơn 1 tuần: dùng Intl để format đẹp theo locale
return new Intl.DateTimeFormat('vi-VN', {
day: 'numeric',
month: 'long',
year: diffDays > 365 ? 'numeric' : undefined,
}).format(new Date(createdAtSeconds * 1000))
}
export function PostTimestamp({ createdAt }: { createdAt: number }) {
const [displayText, setDisplayText] = useState(() => getRelativeTime(createdAt))
useEffect(() => {
const diffMinutes = (Date.now() - createdAt * 1000) / 60_000
// Chỉ setup timer cho bài DƯỚI 1 GIỜ TUỔI
// Bài cũ hơn không cần update thường xuyên (giờ → ngày không thay đổi cảm nhận)
if (diffMinutes >= 60) return
const timer = setInterval(() => {
setDisplayText(getRelativeTime(createdAt))
}, 30_000) // update mỗi 30 giây
return () => clearInterval(timer)
}, [createdAt])
return (
// <time> với dateTime để máy tính/screen reader đọc được thời gian chính xác
<time
dateTime={new Date(createdAt * 1000).toISOString()}
title={new Intl.DateTimeFormat('vi-VN', {
dateStyle: 'full',
timeStyle: 'short',
}).format(new Date(createdAt * 1000))}
style={{ fontSize: 12, color: '#606770' }}
>
{displayText}
</time>
)
}
<textarea> chỉ render plain text — không highlight được @mention hay #hashtag. Cần dùng contenteditable hoặc editor library.
Đừng dùng contenteditable thô — nó có rất nhiều bugs (clipboard handling, IME, undo history). Dùng library:
Bảo mật khi paste: Khi user paste HTML từ nơi khác → đây là vector XSS!
// Intercept paste event — chỉ nhận plain text, bỏ HTML
function handlePaste(event: ClipboardEvent) {
event.preventDefault()
// Chỉ lấy plain text, bỏ qua text/html
const plainText = event.clipboardData?.getData('text/plain') ?? ''
// Insert vào editor position hiện tại
document.execCommand('insertText', false, plainText)
}
Không phải ai cũng dùng emoji picker hay GIF picker. Chỉ load khi cần:
// components/PostComposer.tsx
import { lazy, Suspense, useState, useRef } from 'react'
import { useFeedStore } from '../store/feedStore'
// Các feature nặng — chỉ import khi user click
const EmojiPicker = lazy(() => import('./EmojiPicker'))
const GifPicker = lazy(() => import('./GifPicker'))
const ImageUploader = lazy(() => import('./ImageUploader'))
export function PostComposer() {
const [showEmoji, setShowEmoji] = useState(false)
const [showGif, setShowGif] = useState(false)
const [showImageUpload, setShowImageUpload] = useState(false)
const { composerDraft, updateComposerDraft, prependNewPost } = useFeedStore()
const handleSubmit = async () => {
if (!composerDraft.body.text.trim() && composerDraft.mediaIds.length === 0) return
updateComposerDraft({ submitState: 'submitting' })
try {
const res = await fetch('/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: composerDraft.body,
mediaIds: composerDraft.mediaIds,
}),
})
if (!res.ok) throw new Error('Submit failed')
const data = await res.json()
prependNewPost(data.post, data.users[0], data.media)
} catch {
updateComposerDraft({ submitState: 'failed' })
}
}
return (
<div style={{ padding: 16, background: 'white', borderRadius: 8, marginBottom: 16, boxShadow: '0 1px 4px rgba(0,0,0,0.1)' }}>
<div
// contenteditable thô — trong thực tế dùng Lexical/TipTap
contentEditable
onInput={(e) =>
updateComposerDraft({
body: { text: e.currentTarget.textContent ?? '', entities: [] },
})
}
style={{
minHeight: 60,
padding: 12,
borderRadius: 8,
background: '#f0f2f5',
outline: 'none',
fontSize: 16,
}}
role="textbox"
aria-multiline="true"
aria-label="Bạn đang nghĩ gì?"
data-placeholder="Bạn đang nghĩ gì?"
/>
{/* Toolbar: các feature optional */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
onClick={() => setShowImageUpload(true)}
aria-label="Thêm ảnh"
>
🖼️
</button>
<button
onClick={() => setShowEmoji(true)}
aria-label="Thêm emoji"
>
😊
</button>
<button
onClick={() => setShowGif(true)}
aria-label="Thêm GIF"
>
GIF
</button>
<button
onClick={handleSubmit}
disabled={
composerDraft.submitState === 'submitting' ||
(!composerDraft.body.text.trim() && composerDraft.mediaIds.length === 0)
}
style={{
marginLeft: 'auto',
background: '#1877f2',
color: 'white',
border: 'none',
borderRadius: 20,
padding: '8px 20px',
cursor: 'pointer',
fontWeight: 'bold',
}}
aria-label="Đăng bài"
>
{composerDraft.submitState === 'submitting' ? 'Đang đăng...' : 'Đăng'}
</button>
</div>
{/* Lazy load feature panels */}
{showImageUpload && (
<Suspense fallback={<div>Đang tải...</div>}>
<ImageUploader onClose={() => setShowImageUpload(false)} />
</Suspense>
)}
{showEmoji && (
<Suspense fallback={null}>
<EmojiPicker onClose={() => setShowEmoji(false)} />
</Suspense>
)}
{showGif && (
<Suspense fallback={null}>
<GifPicker onClose={() => setShowGif(false)} />
</Suspense>
)}
</div>
)
}
Phân loại lỗi để xử lý đúng cách:
// utils/apiError.ts
type ErrorSeverity = 'transient' | 'permanent' | 'auth'
function classifyHttpError(status: number): ErrorSeverity {
if ([401, 403].includes(status)) return 'auth' // hết session → redirect login
if ([400, 404, 422].includes(status)) return 'permanent' // sẽ không thành công nếu retry
return 'transient' // 5xx, network error → có thể retry
}
// Exponential backoff: 1s, 2s, 4s, 8s... max 30s, có jitter để tránh thundering herd
function getRetryDelay(attempt: number): number {
const base = Math.min(1000 * 2 ** attempt, 30_000)
// Thêm random jitter ±25% để các client không retry đồng thời
const jitter = base * 0.25 * (Math.random() * 2 - 1)
return base + jitter
}
Vấn đề: User click Like → mất mạng → action bị mất!
Giải pháp: Outbox pattern — lưu action vào IndexedDB trước, gửi network sau.
// utils/outbox.ts
import { openDB } from 'idb'
interface OutboxEntry {
idempotencyKey: string // unique key để dedup khi retry
url: string
method: string
body: string
createdAt: number
retryCount: number
}
// Khởi tạo IndexedDB
const dbPromise = openDB('feed-outbox', 1, {
upgrade(db) {
db.createObjectStore('entries', { keyPath: 'idempotencyKey' })
},
})
// API của outbox
export const outbox = {
// Thêm mutation vào hàng đợi
async add(entry: Omit<OutboxEntry, 'createdAt' | 'retryCount'>) {
const db = await dbPromise
await db.put('entries', {
...entry,
createdAt: Date.now(),
retryCount: 0,
})
},
// Xóa sau khi thành công
async remove(idempotencyKey: string) {
const db = await dbPromise
await db.delete('entries', idempotencyKey)
},
// Lấy tất cả entries chưa gửi
async getAll(): Promise<OutboxEntry[]> {
const db = await dbPromise
return db.getAll('entries')
},
}
// Drain outbox khi có mạng
async function drainOutbox() {
const entries = await outbox.getAll()
for (const entry of entries) {
try {
const res = await fetch(entry.url, {
method: entry.method,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': entry.idempotencyKey,
},
body: entry.body,
})
if (res.ok) {
await outbox.remove(entry.idempotencyKey)
} else if ([400, 404, 422].includes(res.status)) {
// Permanent error → xóa khỏi outbox, thông báo user
await outbox.remove(entry.idempotencyKey)
console.warn('Permanent error, dropping mutation:', entry)
}
// 5xx → giữ trong outbox để retry sau
} catch {
// Network error → retry sau
}
}
}
// Lắng nghe khi có mạng lại → drain outbox
window.addEventListener('online', drainOutbox)
// Wrapper để submit mutation có outbox support
export async function submitWithOutbox(
url: string,
method: string,
body: object,
idempotencyKey: string
) {
const entry: Omit<OutboxEntry, 'createdAt' | 'retryCount'> = {
idempotencyKey,
url,
method,
body: JSON.stringify(body),
}
// 1. Lưu vào outbox (IndexedDB) TRƯỚC
await outbox.add(entry)
try {
// 2. Thử gửi ngay nếu có mạng
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(body),
})
if (res.ok) {
// 3. Thành công → xóa khỏi outbox
await outbox.remove(idempotencyKey)
return res
}
throw new Error(`HTTP ${res.status}`)
} catch {
// Mất mạng hoặc server lỗi → giữ trong outbox
// UI đang hiện optimistic state → user không thấy gì khác
// Sẽ retry khi có mạng lại
throw new Error('Saved to outbox, will retry when online')
}
}
| Phương pháp | Cơ chế | Ưu điểm | Nhược điểm |
|---|---|---|---|
| Short polling | Client hỏi server mỗi N giây | Đơn giản | Tốn bandwidth, nhiều request thừa |
| Long polling | Request giữ open cho đến khi có data | Đơn giản hơn WebSocket | Latency cao, server phức tạp hơn |
| SSE | Server push qua HTTP connection | Auto-reconnect, đơn giản | Chỉ server → client |
| WebSocket | Full-duplex, bidirectional | Realtime thật sự, multiplex | Phức tạp hơn, cần WS infrastructure |
Với Facebook-scale feed → WebSocket vì:
// hooks/useLiveUpdates.ts
import { useEffect, useRef } from 'react'
import { useFeedStore } from '../store/feedStore'
type LiveEvent =
| { type: 'REACTION_COUNT_UPDATED'; postId: string; engagementSummary: EngagementSummary }
| { type: 'NEW_COMMENT'; postId: string; commentCount: number }
| { type: 'NEW_POSTS_AVAILABLE'; count: number }
export function useLiveUpdates(visiblePostIds: string[]) {
const wsRef = useRef<WebSocket | null>(null)
const { applyReactionOptimistic } = useFeedStore()
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/live')
wsRef.current = ws
ws.onopen = () => {
// Subscribe chỉ bài đang nhìn thấy (tối ưu bandwidth)
ws.send(JSON.stringify({
type: 'SUBSCRIBE',
postIds: visiblePostIds,
}))
}
ws.onmessage = (event) => {
const liveEvent: LiveEvent = JSON.parse(event.data)
switch (liveEvent.type) {
case 'REACTION_COUNT_UPDATED':
// Cập nhật count từ server (authoritative)
// Dùng server value thay vì optimistic value
useFeedStore.setState((state) => ({
postsById: {
...state.postsById,
[liveEvent.postId]: {
...state.postsById[liveEvent.postId],
engagementSummary: liveEvent.engagementSummary,
},
},
}))
break
case 'NEW_POSTS_AVAILABLE':
// Hiện banner "Có X bài mới" — không tự insert vào feed
// để không làm mất vị trí đọc của user
useFeedStore.setState({ newPostsAvailable: liveEvent.count })
break
}
}
ws.onclose = () => {
// Auto-reconnect sau 3 giây
setTimeout(() => {
// reconnect logic
}, 3000)
}
return () => ws.close()
}, []) // Chỉ setup 1 lần
// Cập nhật subscriptions khi bài visible thay đổi
useEffect(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({
type: 'UPDATE_SUBSCRIPTIONS',
postIds: visiblePostIds,
}))
}, [visiblePostIds])
}
| Metric | Đo gì | Target |
|---|---|---|
| LCP (Largest Contentful Paint) | Thời gian bài đầu tiên xuất hiện | < 2.5 giây |
| INP (Interaction to Next Paint) | Delay từ click/scroll đến browser vẽ lại | < 200ms |
| CLS (Cumulative Layout Shift) | Layout có bị nhảy không khi content load | < 0.1 |
Với news feed, INP quan trọng hơn LCP sau lần load đầu. Vì user scroll và tương tác hàng giờ, INP kém → cảm giác toàn bộ app giật.
Facebook chia JS loading thành 3 tầng:
TIER 1 — Phải load đầu tiên
├── App shell (header, sidebar skeleton)
├── Feed skeleton CSS
└── Critical path CSS
TIER 2 — Load ngay sau khi có pixels trên màn hình
├── Text post renderer
├── Image post renderer
├── Basic interaction handlers (like, comment buttons)
└── Feed scroll logic
TIER 3 — Load khi cần hoặc khi browser idle
├── Reaction picker
├── Hover cards (profile preview khi hover tên)
├── Emoji picker trong composer
├── GIF picker
├── Live update WebSocket client
└── Telemetry / logging
// Tier 3 example: lazy load ReactionPicker
const ReactionPicker = lazy(() =>
// Chỉ load khi user hover vào Like button
import('./ReactionPicker').then((module) => ({ default: module.ReactionPicker }))
)
// Prefetch khi browser idle (giữa Tier 2 và user interaction)
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
// Hint browser load trước nhưng không execute
import('./ReactionPicker')
})
}
// utils/webVitals.ts
import { onLCP, onINP, onCLS } from 'web-vitals'
function sendToAnalytics(metric: { name: string; value: number; rating: string }) {
// Gửi về analytics backend
navigator.sendBeacon('/analytics', JSON.stringify({
metric: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
deviceType: getDeviceType(), // 'mobile' | 'tablet' | 'desktop'
connectionType: (navigator as any).connection?.effectiveType, // '4g' | '3g'...
timestamp: Date.now(),
}))
}
onLCP(sendToAnalytics)
onINP(sendToAnalytics)
onCLS(sendToAnalytics)
// FeedPage.tsx
export function FeedPage() {
return (
<div>
{/* Skip link: cho phép keyboard user nhảy thẳng vào feed */}
<a
href="#main-feed"
style={{
// Ẩn đi nhưng hiện khi focus (cho keyboard users)
position: 'absolute',
left: '-9999px',
}}
onFocus={(e) => (e.currentTarget.style.left = '0')}
onBlur={(e) => (e.currentTarget.style.left = '-9999px')}
>
Bỏ qua navigation, đến nội dung chính
</a>
<Header />
{/* role="feed": nói với screen reader "đây là một luồng nội dung" */}
{/* aria-busy: thông báo screen reader khi đang load */}
<main
id="main-feed"
role="feed"
aria-label="Bảng tin của bạn"
aria-busy={isLoading}
>
{postIds.map((id) => (
<FeedPost key={id} postId={id} />
))}
</main>
</div>
)
}
// FeedPost.tsx (phần accessibility)
<article
role="article"
// aria-labelledby: tên bài = tên tác giả (screen reader sẽ đọc tên khi focus)
aria-labelledby={`author-name-${post.id}`}
// aria-describedby: mô tả thêm bằng phần body
aria-describedby={`post-body-${post.id}`}
>
<h3 id={`author-name-${post.id}`} className="visually-hidden">
Bài viết của {author.name}
</h3>
<p id={`post-body-${post.id}`}>
{/* nội dung bài */}
</p>
</article>
Khi bài đang được focused bị virtualize ra khỏi DOM → focus bị mất. Cần handle:
// Trước khi unmount item, check xem nó có chứa focused element không
function handleBeforeUnmount(postElement: HTMLElement) {
const focusedElement = document.activeElement
if (postElement.contains(focusedElement)) {
// Di chuyển focus về feed container để không mất
const feedContainer = document.getElementById('main-feed')
feedContainer?.focus()
}
}
// Không cần ship timezone/locale rules → browser xử lý hết
const date = new Date(post.createdAt * 1000)
// Tiếng Anh
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date)
// → "May 14, 2026"
// Tiếng Việt
new Intl.DateTimeFormat('vi-VN', { dateStyle: 'long' }).format(date)
// → "14 tháng 5, 2026"
// Tiếng Trung
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(date)
// → "2026年5月14日"
// Relative time
new Intl.RelativeTimeFormat('vi-VN', { numeric: 'auto' }).format(-5, 'minute')
// → "5 phút trước"
// Compact notation — tự adapt theo locale
const countFmt = (locale: string) =>
new Intl.NumberFormat(locale, { notation: 'compact' })
countFmt('en-US').format(103312) // → "103K"
countFmt('vi-VN').format(103312) // → "103 N"
countFmt('ja-JP').format(103312) // → "10万"
/* ❌ TRÁNH: vỡ layout khi dir="rtl" */
.post-avatar {
margin-right: 12px;
float: left;
}
/* ✅ DÙNG: tự flip khi có dir="rtl" trên <html> */
.post-avatar {
margin-inline-end: 12px; /* right trong LTR, left trong RTL */
float: inline-start; /* left trong LTR, right trong RTL */
}
.post-content {
padding-inline: 16px; /* padding left + right, tự flip */
text-align: start; /* left trong LTR, right trong RTL */
}
<!-- Bài chứa Arabic text trong một feed tiếng Anh -->
<!-- dir="auto": browser tự detect hướng chữ dựa vào ký tự đầu tiên -->
<p dir="auto">
This is English. وهذا عربي. And back to English.
</p>
Một "impression" được tính khi bài hiển thị ≥50% trong ≥1 giây.
// utils/impressionTracker.ts
import { useEffect, useRef } from 'react'
const trackedPosts = new Set<string>() // Tránh đếm impression nhiều lần
export function useImpressionTracking(postId: string) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el || trackedPosts.has(postId)) return
let dwellTimer: ReturnType<typeof setTimeout>
let enterTime: number
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
enterTime = Date.now()
// Delay 1 giây để tránh count scroll-through
dwellTimer = setTimeout(() => {
if (!trackedPosts.has(postId)) {
trackedPosts.add(postId)
trackImpression(postId, Date.now() - enterTime)
}
}, 1000)
} else {
clearTimeout(dwellTimer)
}
},
{ threshold: 0.5 } // ≥50% visible
)
observer.observe(el)
return () => {
observer.disconnect()
clearTimeout(dwellTimer)
}
}, [postId])
return ref
}
// Batch và gửi khi browser idle hoặc beforeunload
const impressionQueue: { postId: string; dwellMs: number }[] = []
function trackImpression(postId: string, dwellMs: number) {
impressionQueue.push({ postId, dwellMs })
// Flush queue khi có 10+ items hoặc khi tab đóng
if (impressionQueue.length >= 10) {
flushImpressions()
}
}
function flushImpressions() {
if (impressionQueue.length === 0) return
const batch = impressionQueue.splice(0)
// sendBeacon: không block page close
navigator.sendBeacon('/analytics/impressions', JSON.stringify(batch))
}
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushImpressions()
})
| Chủ đề | Quyết định | Lý do |
|---|---|---|
| Rendering | CSR | Feed cá nhân hóa, không cần SEO, long-lived session |
| Navigation | SPA | Giữ store/state giữa các route |
| Store shape | Normalized (ID references) | Tránh duplicate, update một chỗ phản ánh khắp nơi |
| Feed shape | List of post IDs + cursors | Feed thay đổi liên tục, cursor ổn định |
| Pagination | Cursor-based | Không bị skip/duplicate khi có bài mới |
| Infinite scroll | Intersection Observer | Không block main thread, tốt hơn scroll event |
| Danh sách dài | Virtualized list | Giảm DOM nodes, tăng scroll performance |
| Reaction UX | Optimistic update | Cảm giác tức thì, rollback nếu lỗi |
| Rich text | Entity ranges (không HTML) | An toàn khỏi XSS, đa platform |
| URL validation | Allowlist http/https/mailto | Chặn javascript: và data: URLs |
| Network thất bại | Outbox + idempotency key | Không mất action, không duplicate |
| Feed cũ | Stale banner | Giữ vị trí đọc, user tự quyết định refresh |
| Cross-tab sync | BroadcastChannel | Tab khác phản ánh thay đổi không cần reload |
| Metric chính | INP > LCP | Feed là long-lived interaction surface |
| Accessibility | role=feed, role=article, aria-live=polite | Screen reader và keyboard navigation |
Round 1 — Core (luôn cần đề cập)
├── 1. Clarify requirements (5 phút)
├── 2. CSR + SPA (rendering & navigation)
├── 3. 4 layers: View / Store / Data Access / Server
├── 4. Normalized store + entity design
├── 5. Cursor-based pagination
└── 6. Optimistic updates + idempotency key
Round 2 — Depth (khi interviewer hỏi thêm)
├── 7. Virtualized list (DOM performance)
├── 8. Intersection Observer (infinite scroll)
├── 9. Skeleton loading (perceived performance)
├── 10. Rich text rendering (entity ranges, XSS)
└── 11. Stale feed + cross-tab sync
Round 3 — Senior signals (nếu có thời gian)
├── 12. Offline outbox + Service Worker
├── 13. Live updates (WebSocket vs SSE vs polling)
├── 14. JavaScript loading tiers (Tier 1/2/3)
├── 15. Core Web Vitals (LCP/INP/CLS targets)
└── 16. Accessibility + i18n + telemetry
"Dùng rendering strategy nào?" → CSR cho home feed (signed-in, personalized, long session). SSR cho public post pages và logged-out surfaces.
"Dùng pagination nào?" → Cursor-based. Offset bị trật khi có bài mới insert vào. Cursor dùng post ID làm anchor — stable dù feed thay đổi liên tục.
"Store như thế nào?" → Normalized: postsById, usersById, mediaById riêng biệt. Feed chỉ lưu ordered list of post IDs + cursor metadata. Tránh nested objects.
"Làm sao reaction cảm giác nhanh?" → Optimistic update: cập nhật UI ngay, gửi request song song. Lưu snapshot để rollback nếu server trả lỗi. Server luôn là authoritative khi success.
"List dài performance?" → Virtualization: chỉ render bài trong viewport + overscan. Thay bài ngoài viewport bằng spacer div với đúng height. Facebook và Twitter đều dùng cách này.
"Mất mạng giữa chừng?" → Outbox pattern: lưu mutation vào IndexedDB trước, gửi network sau. Retry khi có mạng lại. Idempotency key để tránh duplicate khi retry.