📅 NGÀY 23: useLayoutEffect - Synchronous DOM Measurements & Updates
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Hiểu sự khác biệt giữa useEffect và useLayoutEffect về timing
- [ ] Biết khi nào PHẢI dùng useLayoutEffect (và khi nào KHÔNG nên)
- [ ] Đo lường DOM properties (size, position) trước khi browser paint
- [ ] Ngăn chặn visual flickering với synchronous updates
- [ ] Position tooltips, popovers, dropdowns chính xác
- [ ] Setup animations mượt mà không bị flash
- [ ] Hiểu performance trade-offs của synchronous effects
🤔 Kiểm tra đầu vào (5 phút)
Trước khi bắt đầu, hãy trả lời 3 câu hỏi này:
useRef có thể dùng để làm gì với DOM?
- Access DOM nodes, call imperative methods (focus, scroll), measure dimensions
useEffect chạy khi nào trong React lifecycle?
- Sau khi React đã commit changes to DOM và browser đã paint
Tại sao đôi khi bạn thấy "flash" khi update UI trong useEffect?
- Vì useEffect chạy SAU khi paint → user nhìn thấy intermediate state → update → repaint
Hôm nay: Chúng ta sẽ học cách loại bỏ "flash" này với useLayoutEffect 🎯
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy xem tình huống này - tooltip cần hiển thị ở vị trí chính xác:
// ❌ VẤN ĐỀ: Tooltip "nhảy" vị trí
function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef(null);
useEffect(() => {
// Measure target element
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// Calculate position
setPosition({
top: targetRect.bottom + 5,
left: targetRect.left,
});
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
backgroundColor: 'black',
color: 'white',
padding: '5px 10px',
borderRadius: '4px',
}}
>
{children}
</div>
);
}Vấn đề:
- Lần đầu render: position = { top: 0, left: 0 } → tooltip xuất hiện ở góc trên-trái
- Browser paint tooltip ở vị trí (0, 0)
- useEffect chạy → calculate position mới
- setState → re-render
- Browser paint lại ở vị trí đúng
- User nhìn thấy tooltip "nhảy" từ (0,0) sang vị trí đúng! 😱
Timeline với useEffect:
1. [Render] → tooltip at (0, 0)
2. [Paint] → User SEES tooltip at (0, 0) ⚠️ FLASH!
3. [useEffect] → Calculate correct position
4. [setState] → Trigger re-render
5. [Render] → tooltip at correct position
6. [Paint] → User SEES tooltip move ⚠️ JUMP!1.2 Giải Pháp: useLayoutEffect
// ✅ GIẢI PHÁP: useLayoutEffect - no flash!
import { useLayoutEffect } from 'react';
function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef(null);
useLayoutEffect(() => {
// 🎯 Changed from useEffect
// Measure target element
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// Calculate position
setPosition({
top: targetRect.bottom + 5,
left: targetRect.left,
});
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
backgroundColor: 'black',
color: 'white',
padding: '5px 10px',
borderRadius: '4px',
}}
>
{children}
</div>
);
}Timeline với useLayoutEffect:
1. [Render] → tooltip at (0, 0)
2. [useLayoutEffect] → Calculate BEFORE paint
3. [setState] → Synchronous update
4. [Re-render] → tooltip at correct position
5. [Paint] → User SEES tooltip at correct position ✅ NO FLASH!Sự khác biệt chính:
useEffect:
[Render] → [Commit to DOM] → [Browser Paint] → [useEffect runs]
↑
User sees intermediate state!
useLayoutEffect:
[Render] → [Commit to DOM] → [useLayoutEffect runs] → [Browser Paint]
↑
Blocks painting until done!1.3 Mental Model
Hãy tưởng tượng React lifecycle như quay phim:
useEffect = POST-PRODUCTION
────────────────────────────────────────
1. Film scene (render)
2. Show to audience (paint)
3. Add effects later (useEffect)
→ Audience sees scene BEFORE effects
useLayoutEffect = LIVE EDITING
────────────────────────────────────────
1. Film scene (render)
2. Edit immediately (useLayoutEffect)
3. Show final version (paint)
→ Audience only sees edited versionVisual comparison:
┌─────────────────────────────────────────────┐
│ useEffect (Asynchronous) │
├─────────────────────────────────────────────┤
│ │
│ Render Paint Effect Re-paint │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ [A] ──> [A] ───> [calc] ──> [A→B] │
│ 👁️ 👁️ │
│ FLASH! JUMP! │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ useLayoutEffect (Synchronous) │
├─────────────────────────────────────────────┤
│ │
│ Render Effect Re-render Paint │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ [A] ──> [calc] ──> [A→B] ──────> [B] │
│ 👁️ │
│ SMOOTH! │
└─────────────────────────────────────────────┘1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Nên dùng useLayoutEffect cho mọi effect"
// ❌ SAI: Dùng useLayoutEffect khi không cần
function BadComponent() {
const [data, setData] = useState(null);
// ⚠️ Data fetching KHÔNG cần synchronous!
useLayoutEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []);
return <div>{data?.title}</div>;
}
// ✅ ĐÚNG: useEffect cho async operations
function GoodComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// ✅ Async operations use useEffect
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []);
return <div>{data?.title}</div>;
}Tại sao sai?
- useLayoutEffect blocks browser painting
- Data fetching mất thời gian → freeze UI
- User thấy blank screen lâu hơn
- Performance regression!
❌ Hiểu lầm 2: "useLayoutEffect và useEffect có API khác nhau"
// API hoàn toàn giống nhau!
useEffect(() => {
// effect code
return () => {
// cleanup
};
}, [dependencies]);
useLayoutEffect(() => {
// effect code (same syntax!)
return () => {
// cleanup (same!)
};
}, [dependencies]);Chỉ khác về TIMING, không khác về API!
❌ Hiểu lầm 3: "useLayoutEffect luôn tốt hơn vì không có flash"
// ❌ ANTI-PATTERN: useLayoutEffect cho heavy computation
function BadExpensiveComponent() {
const [result, setResult] = useState(null);
useLayoutEffect(() => {
// Heavy computation that takes 500ms
const computed = heavyCalculation(); // ⚠️ Blocks painting for 500ms!
setResult(computed);
}, []);
return <div>{result}</div>;
}
// ✅ ĐÚNG: useEffect cho heavy work
function GoodExpensiveComponent() {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Heavy computation doesn't block paint
const computed = heavyCalculation();
setResult(computed);
setLoading(false);
}, []);
if (loading) return <div>Loading...</div>; // User sees loading state
return <div>{result}</div>;
}Trade-off:
- useLayoutEffect: No flash, but blocks painting (slower initial render)
- useEffect: Possible flash, but faster initial render (better UX for slow operations)
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Pattern Cơ Bản - Measuring DOM Elements ⭐
/**
* 🎯 Mục tiêu: Đo kích thước element và adjust UI accordingly
* 💡 Pattern: useLayoutEffect cho measurements trước khi paint
*/
import { useState, useRef, useLayoutEffect } from 'react';
function ResizableText({ text }) {
const [fontSize, setFontSize] = useState(16);
const containerRef = useRef(null);
const textRef = useRef(null);
useLayoutEffect(() => {
// Measure container và text
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.scrollWidth;
// Nếu text quá dài → giảm font size
if (textWidth > containerWidth) {
const ratio = containerWidth / textWidth;
setFontSize((prev) => Math.max(12, prev * ratio * 0.95)); // Min 12px
} else if (textWidth < containerWidth * 0.8 && fontSize < 24) {
// Nếu text quá ngắn → tăng font size
setFontSize((prev) => Math.min(24, prev * 1.1)); // Max 24px
}
}, [text]); // Re-measure when text changes
return (
<div style={{ padding: '20px' }}>
<h2>Auto-sizing Text</h2>
<div
ref={containerRef}
style={{
width: '300px',
padding: '10px',
border: '2px solid #007bff',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<div
ref={textRef}
style={{
fontSize: `${fontSize}px`,
fontWeight: 'bold',
whiteSpace: 'nowrap',
transition: 'font-size 0.2s ease',
}}
>
{text}
</div>
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
Current font size: {fontSize.toFixed(1)}px
</div>
</div>
);
}
// Demo app
function MeasurementDemo() {
const [text, setText] = useState('Hello World');
return (
<div>
<ResizableText text={text} />
<div style={{ marginTop: '20px' }}>
<input
type='text'
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='Enter text...'
style={{
width: '300px',
padding: '10px',
fontSize: '16px',
}}
/>
</div>
<div style={{ marginTop: '10px' }}>
<button onClick={() => setText('Hi')}>Short</button>
<button
onClick={() => setText('This is a very long text that should shrink')}
>
Long
</button>
</div>
</div>
);
}Comparison: useEffect vs useLayoutEffect:
// ❌ Với useEffect: User thấy flash
function BadResizableText({ text }) {
const [fontSize, setFontSize] = useState(16);
const containerRef = useRef(null);
const textRef = useRef(null);
useEffect(() => {
// ⚠️ useEffect
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.scrollWidth;
if (textWidth > containerWidth) {
setFontSize((prev) =>
Math.max(12, prev * (containerWidth / textWidth) * 0.95),
);
}
}, [text]);
return (
<div
ref={containerRef}
style={{ width: '300px' }}
>
<div
ref={textRef}
style={{ fontSize: `${fontSize}px` }}
>
{text}
</div>
</div>
);
}
// Timeline:
// 1. Render với fontSize = 16
// 2. Paint → User sees overflowing text ⚠️ FLASH!
// 3. useEffect measures → setText(12)
// 4. Re-render và re-paint → Text now fits
// ✅ Với useLayoutEffect: Smooth!
function GoodResizableText({ text }) {
const [fontSize, setFontSize] = useState(16);
const containerRef = useRef(null);
const textRef = useRef(null);
useLayoutEffect(() => {
// ✅ useLayoutEffect
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.scrollWidth;
if (textWidth > containerWidth) {
setFontSize((prev) =>
Math.max(12, prev * (containerWidth / textWidth) * 0.95),
);
}
}, [text]);
return (
<div
ref={containerRef}
style={{ width: '300px' }}
>
<div
ref={textRef}
style={{ fontSize: `${fontSize}px` }}
>
{text}
</div>
</div>
);
}
// Timeline:
// 1. Render với fontSize = 16
// 2. useLayoutEffect measures → setText(12)
// 3. Re-render với fontSize = 12
// 4. Paint → User sees correctly sized text ✅ NO FLASH!Demo 2: Kịch Bản Thực Tế - Smart Tooltip Positioning ⭐⭐
/**
* 🎯 Use case: Tooltip tự động adjust vị trí để không bị overflow
* 💼 Real-world: Tooltips, popovers, dropdown menus
* ⚠️ Challenge: Tính toán viewport bounds và flip direction nếu cần
*/
import { useState, useRef, useLayoutEffect } from 'react';
function SmartTooltip({
children,
content,
preferredPosition = 'bottom', // 'top' | 'bottom' | 'left' | 'right'
}) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [actualPosition, setActualPosition] = useState(preferredPosition);
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
useLayoutEffect(() => {
if (!isVisible || !triggerRef.current || !tooltipRef.current) return;
// Get bounding boxes
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const gap = 8; // Gap between trigger and tooltip
let finalPosition = preferredPosition;
let top = 0;
let left = 0;
// Calculate position based on preferred direction
switch (preferredPosition) {
case 'bottom':
top = triggerRect.bottom + gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
// Check if overflows bottom → flip to top
if (top + tooltipRect.height > viewportHeight) {
finalPosition = 'top';
top = triggerRect.top - tooltipRect.height - gap;
}
break;
case 'top':
top = triggerRect.top - tooltipRect.height - gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
// Check if overflows top → flip to bottom
if (top < 0) {
finalPosition = 'bottom';
top = triggerRect.bottom + gap;
}
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + gap;
// Check if overflows right → flip to left
if (left + tooltipRect.width > viewportWidth) {
finalPosition = 'left';
left = triggerRect.left - tooltipRect.width - gap;
}
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - gap;
// Check if overflows left → flip to right
if (left < 0) {
finalPosition = 'right';
left = triggerRect.right + gap;
}
break;
}
// Clamp to viewport
left = Math.max(
gap,
Math.min(left, viewportWidth - tooltipRect.width - gap),
);
top = Math.max(
gap,
Math.min(top, viewportHeight - tooltipRect.height - gap),
);
setPosition({ top, left });
setActualPosition(finalPosition);
}, [isVisible, preferredPosition, content]);
return (
<>
{/* Trigger element */}
<span
ref={triggerRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
style={{
cursor: 'help',
borderBottom: '1px dashed #007bff',
color: '#007bff',
}}
>
{children}
</span>
{/* Tooltip */}
{isVisible && (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
backgroundColor: '#333',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '14px',
maxWidth: '200px',
zIndex: 9999,
pointerEvents: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
>
{content}
{/* Arrow indicator */}
<div
style={{
position: 'absolute',
width: 0,
height: 0,
borderStyle: 'solid',
...(actualPosition === 'bottom' && {
top: '-6px',
left: '50%',
transform: 'translateX(-50%)',
borderWidth: '0 6px 6px 6px',
borderColor: 'transparent transparent #333 transparent',
}),
...(actualPosition === 'top' && {
bottom: '-6px',
left: '50%',
transform: 'translateX(-50%)',
borderWidth: '6px 6px 0 6px',
borderColor: '#333 transparent transparent transparent',
}),
...(actualPosition === 'right' && {
left: '-6px',
top: '50%',
transform: 'translateY(-50%)',
borderWidth: '6px 6px 6px 0',
borderColor: 'transparent #333 transparent transparent',
}),
...(actualPosition === 'left' && {
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
borderWidth: '6px 0 6px 6px',
borderColor: 'transparent transparent transparent #333',
}),
}}
/>
</div>
)}
</>
);
}
// Demo app
function TooltipDemo() {
return (
<div style={{ padding: '100px 20px', minHeight: '150vh' }}>
<h2>Smart Tooltip Positioning</h2>
<p>
Hover over the blue text to see tooltips. Scroll to test edge detection.
</p>
<div style={{ marginTop: '50px' }}>
<p>
This is a{' '}
<SmartTooltip
content='This tooltip prefers bottom but will flip to top if needed'
preferredPosition='bottom'
>
bottom tooltip
</SmartTooltip>{' '}
example.
</p>
<p>
This is a{' '}
<SmartTooltip
content='This tooltip prefers top but will flip to bottom if needed'
preferredPosition='top'
>
top tooltip
</SmartTooltip>{' '}
example.
</p>
<p>
This is a{' '}
<SmartTooltip
content='This tooltip prefers right but will flip to left if needed'
preferredPosition='right'
>
right tooltip
</SmartTooltip>{' '}
example.
</p>
<p>
This is a{' '}
<SmartTooltip
content='This tooltip prefers left but will flip to right if needed'
preferredPosition='left'
>
left tooltip
</SmartTooltip>{' '}
example.
</p>
</div>
<div style={{ marginTop: '100vh' }}>
<p>Scroll to bottom to test edge detection:</p>
<p>
<SmartTooltip
content='I should flip to top when near bottom edge'
preferredPosition='bottom'
>
Bottom tooltip near edge
</SmartTooltip>
</p>
</div>
</div>
);
}Why useLayoutEffect is CRITICAL here:
// ❌ Với useEffect: Tooltip flashes at wrong position
// Timeline:
// 1. Render tooltip at (0, 0)
// 2. Paint → User SEES tooltip at (0, 0) ⚠️
// 3. useEffect calculates position
// 4. Update position state
// 5. Re-paint → Tooltip jumps to correct position ⚠️
// ✅ Với useLayoutEffect: Smooth positioning
// Timeline:
// 1. Render tooltip at (0, 0)
// 2. useLayoutEffect calculates position (BEFORE paint)
// 3. Update position state synchronously
// 4. Re-render with correct position
// 5. Paint → User sees tooltip at correct position ✅Demo 3: Edge Cases - Animation Initialization ⭐⭐⭐
/**
* 🎯 Use case: Initialize animation library (GSAP, Framer Motion)
* ⚠️ Edge cases:
* - Animation must start from measured position
* - Must run before first paint to avoid flash
* - Cleanup animations on unmount
* - Handle window resize
*/
import { useState, useRef, useLayoutEffect } from 'react';
function AnimatedCard({ title, content, index }) {
const cardRef = useRef(null);
const animationRef = useRef(null);
useLayoutEffect(() => {
if (!cardRef.current) return;
// Get initial position
const rect = cardRef.current.getBoundingClientRect();
// Setup initial state (before paint!)
cardRef.current.style.opacity = '0';
cardRef.current.style.transform = 'translateY(50px)';
// Start animation immediately after layout
// (In real app, use GSAP or Framer Motion)
const startAnimation = () => {
cardRef.current.style.transition = 'all 0.5s ease';
cardRef.current.style.opacity = '1';
cardRef.current.style.transform = 'translateY(0)';
};
// Stagger animations
const delay = index * 100;
animationRef.current = setTimeout(startAnimation, delay);
return () => {
if (animationRef.current) {
clearTimeout(animationRef.current);
}
};
}, []); // Only run once on mount
return (
<div
ref={cardRef}
style={{
padding: '20px',
margin: '10px 0',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<h3 style={{ margin: '0 0 10px 0' }}>{title}</h3>
<p style={{ margin: 0, color: '#666' }}>{content}</p>
</div>
);
}
// Staggered list animation
function AnimatedList() {
const [items, setItems] = useState([
{ id: 1, title: 'Card 1', content: 'This card animates in first' },
{ id: 2, title: 'Card 2', content: 'This card animates in second' },
{ id: 3, title: 'Card 3', content: 'This card animates in third' },
]);
const [showList, setShowList] = useState(true);
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>Animated List</h2>
<button
onClick={() => {
setShowList(false);
setTimeout(() => setShowList(true), 100);
}}
style={{
padding: '10px 20px',
marginBottom: '20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Replay Animation
</button>
{showList && (
<div>
{items.map((item, index) => (
<AnimatedCard
key={item.id}
title={item.title}
content={item.content}
index={index}
/>
))}
</div>
)}
</div>
);
}Comparison:
// ❌ Với useEffect: Flash of unstyled content
function BadAnimatedCard({ title, content }) {
const cardRef = useRef(null);
useEffect(() => {
// ⚠️ useEffect
// Set initial state
cardRef.current.style.opacity = '0';
cardRef.current.style.transform = 'translateY(50px)';
// Animate
setTimeout(() => {
cardRef.current.style.opacity = '1';
cardRef.current.style.transform = 'translateY(0)';
}, 100);
}, []);
return <div ref={cardRef}>{title}</div>;
}
// Timeline:
// 1. Render with default styles
// 2. Paint → User SEES card at full opacity ⚠️ FLASH!
// 3. useEffect sets opacity 0
// 4. Re-paint → Card disappears
// 5. setTimeout animates in
// Result: Awkward flash then disappear then animate
// ✅ Với useLayoutEffect: Smooth from start
function GoodAnimatedCard({ title, content }) {
const cardRef = useRef(null);
useLayoutEffect(() => {
// ✅ useLayoutEffect
// Set initial state BEFORE first paint
cardRef.current.style.opacity = '0';
cardRef.current.style.transform = 'translateY(50px)';
// Animate
setTimeout(() => {
cardRef.current.style.transition = 'all 0.5s ease';
cardRef.current.style.opacity = '1';
cardRef.current.style.transform = 'translateY(0)';
}, 100);
}, []);
return <div ref={cardRef}>{title}</div>;
}
// Timeline:
// 1. Render with default styles
// 2. useLayoutEffect sets opacity 0 (BEFORE paint)
// 3. Paint → User sees card at opacity 0
// 4. setTimeout animates in smoothly
// Result: Smooth animation from start ✅Real GSAP integration example:
// 💡 Real-world pattern với GSAP
import { useLayoutEffect, useRef } from 'react';
// import gsap from 'gsap';
function GSAPAnimatedCard({ children }) {
const cardRef = useRef(null);
const tlRef = useRef(null); // Timeline ref
useLayoutEffect(() => {
// Create GSAP timeline
// tlRef.current = gsap.timeline();
// Setup animation (runs before paint!)
// tlRef.current.fromTo(
// cardRef.current,
// {
// opacity: 0,
// y: 50
// },
// {
// opacity: 1,
// y: 0,
// duration: 0.5,
// ease: 'power2.out'
// }
// );
return () => {
// Cleanup timeline
// tlRef.current?.kill();
};
}, []);
return <div ref={cardRef}>{children}</div>;
}🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Highlight Active Section on Scroll (15 phút)
/**
* 🎯 Mục tiêu: Table of contents với active section highlighting
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useReducer, Context
*
* Requirements:
* 1. Scroll spy - highlight current section in TOC
* 2. Smooth scroll to section on click
* 3. Update active section dựa trên scroll position
* 4. No flash khi update active state
*
* 💡 Gợi ý:
* - useLayoutEffect để measure section positions
* - Scroll listener để detect active section
* - IntersectionObserver có thể dùng nhưng không bắt buộc
*/
import { useState, useRef, useLayoutEffect, useEffect } from 'react';
function TableOfContents() {
const [activeSection, setActiveSection] = useState('section1');
const [sectionPositions, setSectionPositions] = useState({});
const section1Ref = useRef(null);
const section2Ref = useRef(null);
const section3Ref = useRef(null);
// TODO: Measure section positions với useLayoutEffect
useLayoutEffect(() => {
// Measure tất cả sections
// const positions = {
// section1: section1Ref.current.offsetTop,
// section2: section2Ref.current.offsetTop,
// section3: section3Ref.current.offsetTop
// };
// setSectionPositions(positions);
}, []);
// TODO: Scroll listener với useEffect
useEffect(() => {
const handleScroll = () => {
const scrollPos = window.scrollY + 100; // Offset
// Determine active section based on scroll position
// if (scrollPos >= sectionPositions.section3) {
// setActiveSection('section3');
// } else if (scrollPos >= sectionPositions.section2) {
// setActiveSection('section2');
// } else {
// setActiveSection('section1');
// }
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [sectionPositions]);
const scrollToSection = (ref) => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div style={{ display: 'flex', minHeight: '200vh' }}>
{/* Table of Contents - Fixed */}
<nav
style={{
position: 'fixed',
width: '200px',
padding: '20px',
backgroundColor: '#f8f9fa',
borderRight: '2px solid #ddd',
height: '100vh',
}}
>
<h3>Table of Contents</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li
onClick={() => scrollToSection(section1Ref)}
style={{
padding: '10px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section1' ? '#007bff' : 'transparent',
color: activeSection === 'section1' ? 'white' : 'black',
borderRadius: '4px',
marginBottom: '5px',
transition: 'all 0.2s',
}}
>
Section 1
</li>
<li
onClick={() => scrollToSection(section2Ref)}
style={{
padding: '10px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section2' ? '#007bff' : 'transparent',
color: activeSection === 'section2' ? 'white' : 'black',
borderRadius: '4px',
marginBottom: '5px',
transition: 'all 0.2s',
}}
>
Section 2
</li>
<li
onClick={() => scrollToSection(section3Ref)}
style={{
padding: '10px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section3' ? '#007bff' : 'transparent',
color: activeSection === 'section3' ? 'white' : 'black',
borderRadius: '4px',
transition: 'all 0.2s',
}}
>
Section 3
</li>
</ul>
</nav>
{/* Content */}
<main style={{ marginLeft: '220px', padding: '20px', width: '100%' }}>
<section
ref={section1Ref}
style={{ minHeight: '100vh', paddingTop: '20px' }}
>
<h2>Section 1</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit...</p>
{Array.from({ length: 10 }).map((_, i) => (
<p key={i}>Content paragraph {i + 1}</p>
))}
</section>
<section
ref={section2Ref}
style={{ minHeight: '100vh', paddingTop: '20px' }}
>
<h2>Section 2</h2>
<p>
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua...
</p>
{Array.from({ length: 10 }).map((_, i) => (
<p key={i}>Content paragraph {i + 1}</p>
))}
</section>
<section
ref={section3Ref}
style={{ minHeight: '100vh', paddingTop: '20px' }}
>
<h2>Section 3</h2>
<p>
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris...
</p>
{Array.from({ length: 10 }).map((_, i) => (
<p key={i}>Content paragraph {i + 1}</p>
))}
</section>
</main>
</div>
);
}
// ✅ Expected behavior:
// - Active section highlights in TOC as you scroll
// - Click section → smooth scroll
// - No flash when updating active state💡 Solution
/**
* Table of Contents with scroll spy - highlights active section
* Uses useLayoutEffect to measure section positions before paint
* and useEffect for scroll listening
*/
function TableOfContents() {
const [activeSection, setActiveSection] = useState('section1');
const [sectionPositions, setSectionPositions] = useState({});
const section1Ref = useRef(null);
const section2Ref = useRef(null);
const section3Ref = useRef(null);
// Measure section offsets once on mount and when needed
useLayoutEffect(() => {
const positions = {
section1: section1Ref.current?.offsetTop || 0,
section2: section2Ref.current?.offsetTop || 0,
section3: section3Ref.current?.offsetTop || 0,
};
setSectionPositions(positions);
}, []); // Chỉ đo một lần khi mount
// Update active section based on scroll position
useEffect(() => {
const handleScroll = () => {
const scrollPos = window.scrollY + 120; // offset để active sớm hơn một chút
if (scrollPos >= sectionPositions.section3) {
setActiveSection('section3');
} else if (scrollPos >= sectionPositions.section2) {
setActiveSection('section2');
} else if (scrollPos >= sectionPositions.section1) {
setActiveSection('section1');
}
};
window.addEventListener('scroll', handleScroll);
// Initial check
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [sectionPositions]);
const scrollToSection = (ref) => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div style={{ display: 'flex', minHeight: '200vh' }}>
{/* Fixed Table of Contents */}
<nav
style={{
position: 'fixed',
width: '220px',
padding: '20px',
backgroundColor: '#f8f9fa',
borderRight: '2px solid #ddd',
height: '100vh',
overflowY: 'auto',
}}
>
<h3>Table of Contents</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li
onClick={() => scrollToSection(section1Ref)}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section1' ? '#007bff' : 'transparent',
color: activeSection === 'section1' ? 'white' : '#333',
borderRadius: '6px',
marginBottom: '8px',
transition: 'all 0.2s ease',
}}
>
Section 1
</li>
<li
onClick={() => scrollToSection(section2Ref)}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section2' ? '#007bff' : 'transparent',
color: activeSection === 'section2' ? 'white' : '#333',
borderRadius: '6px',
marginBottom: '8px',
transition: 'all 0.2s ease',
}}
>
Section 2
</li>
<li
onClick={() => scrollToSection(section3Ref)}
style={{
padding: '12px 16px',
cursor: 'pointer',
backgroundColor:
activeSection === 'section3' ? '#007bff' : 'transparent',
color: activeSection === 'section3' ? 'white' : '#333',
borderRadius: '6px',
transition: 'all 0.2s ease',
}}
>
Section 3
</li>
</ul>
</nav>
{/* Main content */}
<main
style={{ marginLeft: '240px', padding: '40px 20px', width: '100%' }}
>
<section
ref={section1Ref}
style={{ minHeight: '100vh' }}
>
<h2>Section 1 - Introduction</h2>
{Array.from({ length: 12 }).map((_, i) => (
<p key={i}>Paragraph {i + 1} of Section 1...</p>
))}
</section>
<section
ref={section2Ref}
style={{ minHeight: '100vh' }}
>
<h2>Section 2 - Main Content</h2>
{Array.from({ length: 15 }).map((_, i) => (
<p key={i}>Paragraph {i + 1} of Section 2...</p>
))}
</section>
<section
ref={section3Ref}
style={{ minHeight: '100vh' }}
>
<h2>Section 3 - Conclusion</h2>
{Array.from({ length: 10 }).map((_, i) => (
<p key={i}>Paragraph {i + 1} of Section 3...</p>
))}
</section>
</main>
</div>
);
}
/*
Kết quả mong đợi:
- Khi scroll xuống → mục tương ứng trong TOC được highlight (màu xanh, chữ trắng)
- Click vào mục trong TOC → scroll mượt đến section đó
- Active section được cập nhật realtime dựa trên vị trí scroll
- Không có hiện tượng nhấp nháy (flash) khi cập nhật active state
- Đo vị trí section diễn ra trước khi browser paint (nhờ useLayoutEffect)
*/⭐⭐ Exercise 2: Masonry Layout (25 phút)
/**
* 🎯 Mục tiêu: Pinterest-style masonry layout với height calculations
* ⏱️ Thời gian: 25 phút
*
* Scenario:
* Images với different heights cần được arrange trong masonry grid.
* Phải calculate heights và positions BEFORE paint để avoid layout shift.
*
* Requirements:
* 1. 3 columns masonry layout
* 2. Items arranged theo shortest column
* 3. Calculate positions với useLayoutEffect
* 4. Smooth loading without jumps
* 5. Handle window resize
*/
import { useState, useRef, useLayoutEffect } from 'react';
function MasonryLayout({ items }) {
const [itemPositions, setItemPositions] = useState({});
const containerRef = useRef(null);
const itemRefs = useRef({});
useLayoutEffect(() => {
if (!containerRef.current) return;
// TODO: Calculate masonry positions
// Algorithm:
// 1. Initialize column heights = [0, 0, 0]
// 2. For each item:
// a. Find shortest column
// b. Place item in that column
// c. Update column height
// d. Store position (left, top)
const columns = 3;
const gap = 16;
const containerWidth = containerRef.current.offsetWidth;
const columnWidth = (containerWidth - gap * (columns - 1)) / columns;
const columnHeights = Array(columns).fill(0);
const positions = {};
items.forEach((item, index) => {
// Get item height
const itemElement = itemRefs.current[item.id];
if (!itemElement) return;
const itemHeight = itemElement.offsetHeight;
// Find shortest column
const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights));
// Calculate position
const left = shortestColumn * (columnWidth + gap);
const top = columnHeights[shortestColumn];
positions[item.id] = { left, top };
// Update column height
columnHeights[shortestColumn] += itemHeight + gap;
});
setItemPositions(positions);
// Set container height
const maxHeight = Math.max(...columnHeights);
containerRef.current.style.height = `${maxHeight}px`;
}, [items]);
// TODO: Handle resize
useLayoutEffect(() => {
const handleResize = () => {
// Recalculate positions on resize
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [items]);
return (
<div style={{ padding: '20px' }}>
<h2>Masonry Layout</h2>
<div
ref={containerRef}
style={{
position: 'relative',
width: '100%',
maxWidth: '900px',
margin: '0 auto',
}}
>
{items.map((item) => {
const pos = itemPositions[item.id] || { left: 0, top: 0 };
return (
<div
key={item.id}
ref={(el) => (itemRefs.current[item.id] = el)}
style={{
position: 'absolute',
left: `${pos.left}px`,
top: `${pos.top}px`,
width: 'calc(33.333% - 11px)',
transition: 'all 0.3s ease',
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<img
src={item.image}
alt={item.title}
style={{
width: '100%',
display: 'block',
}}
/>
<div style={{ padding: '10px' }}>
<h3 style={{ margin: '0 0 5px 0', fontSize: '16px' }}>
{item.title}
</h3>
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
{item.description}
</p>
</div>
</div>
);
})}
</div>
</div>
);
}
// Demo
function MasonryDemo() {
const [items] = useState([
{
id: 1,
image: 'https://picsum.photos/300/200',
title: 'Item 1',
description: 'Short description',
},
{
id: 2,
image: 'https://picsum.photos/300/400',
title: 'Item 2',
description: 'This is a longer description that takes more space',
},
{
id: 3,
image: 'https://picsum.photos/300/250',
title: 'Item 3',
description: 'Medium length',
},
{
id: 4,
image: 'https://picsum.photos/300/350',
title: 'Item 4',
description: 'Another item',
},
{
id: 5,
image: 'https://picsum.photos/300/180',
title: 'Item 5',
description: 'Small one',
},
{
id: 6,
image: 'https://picsum.photos/300/300',
title: 'Item 6',
description: 'Square-ish',
},
]);
return <MasonryLayout items={items} />;
}
// 🎯 Expected behavior:
// - Items arranged in 3 columns
// - Shortest column gets next item
// - No layout shift/jump when rendering
// - Smooth transitions when resizing💡 Solution
/**
* Masonry Layout component - arranges items in columns like Pinterest
* Uses useLayoutEffect to calculate positions before browser paint
* Supports dynamic heights and window resize
*/
function MasonryLayout({ items }) {
const [positions, setPositions] = useState({});
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef(null);
const itemRefs = useRef({});
// Measure container width and item heights, calculate positions
useLayoutEffect(() => {
if (!containerRef.current) return;
const updateLayout = () => {
const width = containerRef.current.offsetWidth;
setContainerWidth(width);
if (width === 0) return;
const columns = 3;
const gap = 16;
const columnWidth = (width - gap * (columns - 1)) / columns;
const columnHeights = new Array(columns).fill(0);
const newPositions = {};
items.forEach((item) => {
const el = itemRefs.current[item.id];
if (!el) return;
// Get actual rendered height
const height = el.getBoundingClientRect().height;
// Find the shortest column
let shortest = 0;
for (let i = 1; i < columns; i++) {
if (columnHeights[i] < columnHeights[shortest]) {
shortest = i;
}
}
const left = shortest * (columnWidth + gap);
const top = columnHeights[shortest];
newPositions[item.id] = { left, top, height };
// Update column height
columnHeights[shortest] += height + gap;
});
// Set container height to tallest column
const maxHeight = Math.max(...columnHeights);
containerRef.current.style.height = `${maxHeight}px`;
setPositions(newPositions);
};
updateLayout();
// Handle resize
const handleResize = () => {
updateLayout();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [items]);
return (
<div style={{ padding: '20px' }}>
<h2>Masonry Layout (3 columns)</h2>
<div
ref={containerRef}
style={{
position: 'relative',
width: '100%',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{items.map((item) => {
const pos = positions[item.id] || { left: 0, top: 0, height: 0 };
return (
<div
key={item.id}
ref={(el) => (itemRefs.current[item.id] = el)}
style={{
position: 'absolute',
left: `${pos.left}px`,
top: `${pos.top}px`,
width: `calc(${100 / 3}% - ${32}px)`, // 3 columns with gaps
transition: 'all 0.4s ease',
backgroundColor: 'white',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={item.image}
alt={item.title}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
// Force layout reflow after image load if needed
onLoad={() => {
// Trigger re-measure after image loads (for dynamic heights)
setPositions((prev) => ({ ...prev }));
}}
/>
<div style={{ padding: '12px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>
{item.title}
</h3>
<p style={{ margin: 0, fontSize: '14px', color: '#555' }}>
{item.description}
</p>
</div>
</div>
);
})}
</div>
<div
style={{
marginTop: '30px',
padding: '12px',
backgroundColor: '#f0f8ff',
borderRadius: '6px',
fontSize: '14px',
}}
>
<strong>Tip:</strong> Resize browser window to see layout adapt
automatically.
</div>
</div>
);
}
// Demo usage
function MasonryDemo() {
const items = [
{
id: 1,
image: 'https://picsum.photos/300/200',
title: 'Mountain View',
description: 'Beautiful landscape',
},
{
id: 2,
image: 'https://picsum.photos/300/450',
title: 'City at Night',
description: 'Long night exposure',
},
{
id: 3,
image: 'https://picsum.photos/300/280',
title: 'Forest Path',
description: 'Green tunnel',
},
{
id: 4,
image: 'https://picsum.photos/300/380',
title: 'Ocean Waves',
description: 'Crashing waves',
},
{
id: 5,
image: 'https://picsum.photos/300/160',
title: 'Desert Dunes',
description: 'Golden sand',
},
{
id: 6,
image: 'https://picsum.photos/300/320',
title: 'Autumn Leaves',
description: 'Fall colors',
},
];
return <MasonryLayout items={items} />;
}
/*
Kết quả mong đợi:
- 6 items được sắp xếp thành 3 cột, cột ngắn nhất nhận item tiếp theo
- Không có layout shift khi hình ảnh load hoặc khi resize window
- Vị trí và chiều cao container được tính trước khi paint (useLayoutEffect)
- Layout tự động điều chỉnh khi thay đổi kích thước cửa sổ
- Hiệu ứng chuyển động mượt mà khi items di chuyển vị trí
*/⭐⭐⭐ Exercise 3: Modal với Focus Trap (40 phút)
/**
* 🎯 Mục tiêu: Accessible modal với focus management
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là keyboard user, tôi muốn focus trap trong modal để navigate dễ dàng"
*
* ✅ Acceptance Criteria:
* - [ ] Focus first element khi modal mở
* - [ ] Tab cycles through focusable elements trong modal
* - [ ] Shift+Tab cycles backward
* - [ ] Escape closes modal
* - [ ] Restore focus khi modal đóng
* - [ ] Prevent body scroll khi modal open
*
* 🎨 Technical Constraints:
* - useLayoutEffect cho focus management
* - useEffect cho body scroll lock
* - Proper cleanup
*
* 🚨 Edge Cases cần handle:
* - Modal content changes (refocus first element)
* - No focusable elements in modal
* - Nested modals (bonus)
* - Unmount during animation
*/
import { useState, useRef, useLayoutEffect, useEffect } from 'react';
function AccessibleModal({ isOpen, onClose, children, title }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
const firstFocusableRef = useRef(null);
const lastFocusableRef = useRef(null);
// TODO: Focus management với useLayoutEffect
useLayoutEffect(() => {
if (!isOpen || !modalRef.current) return;
// Save currently focused element
previousFocusRef.current = document.activeElement;
// Get all focusable elements
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusableElements.length === 0) {
// No focusable elements → focus modal itself
modalRef.current.focus();
return;
}
firstFocusableRef.current = focusableElements[0];
lastFocusableRef.current = focusableElements[focusableElements.length - 1];
// Focus first element
firstFocusableRef.current.focus();
// Cleanup: restore focus
return () => {
previousFocusRef.current?.focus();
};
}, [isOpen]);
// TODO: Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
// Escape to close
if (e.key === 'Escape') {
onClose();
return;
}
// Tab key for focus trap
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstFocusableRef.current) {
e.preventDefault();
lastFocusableRef.current?.focus();
}
} else {
// Tab
if (document.activeElement === lastFocusableRef.current) {
e.preventDefault();
firstFocusableRef.current?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// TODO: Prevent body scroll
useEffect(() => {
if (!isOpen) return;
// Save current scroll position
const scrollY = window.scrollY;
// Lock body scroll
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
return () => {
// Restore scroll
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, scrollY);
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<div
ref={modalRef}
role='dialog'
aria-modal='true'
aria-labelledby='modal-title'
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '24px',
maxWidth: '500px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<h2
id='modal-title'
style={{ margin: 0 }}
>
{title}
</h2>
<button
onClick={onClose}
aria-label='Close modal'
style={{
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
}}
>
×
</button>
</div>
{children}
</div>
</div>
);
}
// Demo
function ModalDemo() {
const [isOpen, setIsOpen] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '' });
return (
<div style={{ padding: '20px' }}>
<h2>Accessible Modal Demo</h2>
<button
onClick={() => setIsOpen(true)}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Open Modal
</button>
<div style={{ marginTop: '20px', padding: '100vh 0' }}>
<p>Scroll down to test body scroll lock</p>
<p>Bottom of page</p>
</div>
<AccessibleModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title='Contact Form'
>
<form
onSubmit={(e) => {
e.preventDefault();
console.log('Submitted:', formData);
setIsOpen(false);
}}
>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
Name:
</label>
<input
type='text'
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
style={{
width: '100%',
padding: '8px',
fontSize: '14px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
Email:
</label>
<input
type='email'
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
style={{
width: '100%',
padding: '8px',
fontSize: '14px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
</div>
<div
style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}
>
<button
type='button'
onClick={() => setIsOpen(false)}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
type='submit'
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Submit
</button>
</div>
</form>
</AccessibleModal>
</div>
);
}
// 🎯 Expected behavior:
// - Focus jumps to first input when modal opens
// - Tab cycles through: name input → email input → Cancel → Submit → name input
// - Shift+Tab cycles backward
// - Escape closes modal
// - Body scroll locked while modal open
// - Focus returns to trigger button when closed💡 Solution
/**
* Accessible Modal with focus trap, keyboard navigation and body scroll lock
* Uses useLayoutEffect for focus management (runs before paint)
* and useEffect for scroll lock + keyboard listeners
*/
function AccessibleModal({ isOpen, onClose, children, title }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
const firstFocusableRef = useRef(null);
const lastFocusableRef = useRef(null);
// Focus management - runs synchronously before browser paint
useLayoutEffect(() => {
if (!isOpen || !modalRef.current) return;
// 1. Save currently focused element (to restore later)
previousFocusRef.current = document.activeElement;
// 2. Find all focusable elements inside modal
const focusable = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) {
// No focusable elements → focus the modal container itself
modalRef.current.focus();
return;
}
firstFocusableRef.current = focusable[0];
lastFocusableRef.current = focusable[focusable.length - 1];
// 3. Move focus to first focusable element
firstFocusableRef.current.focus();
// Cleanup: restore focus when modal closes
return () => {
previousFocusRef.current?.focus();
};
}, [isOpen]);
// Keyboard handling (Escape + Tab trap)
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
// Escape → close modal
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
// Tab trap
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift + Tab (backward)
if (document.activeElement === firstFocusableRef.current) {
e.preventDefault();
lastFocusableRef.current?.focus();
}
} else {
// Tab (forward)
if (document.activeElement === lastFocusableRef.current) {
e.preventDefault();
firstFocusableRef.current?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Prevent body scroll while modal is open
useEffect(() => {
if (!isOpen) return;
// Save current scroll position
const scrollY = window.scrollY;
// Lock body
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
document.body.style.overflowY = 'hidden';
return () => {
// Restore scroll
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
document.body.style.overflowY = '';
window.scrollTo(0, scrollY);
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
onClick={onClose}
>
<div
ref={modalRef}
role='dialog'
aria-modal='true'
aria-labelledby='modal-title'
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '24px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 10px 25px rgba(0,0,0,0.2)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<h2
id='modal-title'
style={{ margin: 0 }}
>
{title}
</h2>
<button
onClick={onClose}
aria-label='Close modal'
style={{
background: 'none',
border: 'none',
fontSize: '28px',
cursor: 'pointer',
padding: '0 8px',
lineHeight: 1,
}}
>
×
</button>
</div>
{children}
</div>
</div>
);
}
// Demo usage
function ModalDemo() {
const [isOpen, setIsOpen] = useState(false);
return (
<div style={{ padding: '40px' }}>
<h2>Accessible Modal with Focus Trap</h2>
<button
onClick={() => setIsOpen(true)}
style={{
padding: '12px 24px',
fontSize: '16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Open Contact Form
</button>
<AccessibleModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title='Contact Information'
>
<form
onSubmit={(e) => {
e.preventDefault();
alert('Form submitted!');
setIsOpen(false);
}}
>
<div style={{ marginBottom: '16px' }}>
<label
htmlFor='name'
style={{ display: 'block', marginBottom: '6px' }}
>
Full Name
</label>
<input
id='name'
type='text'
required
style={{
width: '100%',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '6px',
fontSize: '16px',
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label
htmlFor='email'
style={{ display: 'block', marginBottom: '6px' }}
>
Email Address
</label>
<input
id='email'
type='email'
required
style={{
width: '100%',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '6px',
fontSize: '16px',
}}
/>
</div>
<div
style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}
>
<button
type='button'
onClick={() => setIsOpen(false)}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Cancel
</button>
<button
type='submit'
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Submit
</button>
</div>
</form>
</AccessibleModal>
</div>
);
}
/*
Kết quả mong đợi:
- Khi modal mở → focus tự động chuyển vào input đầu tiên (Name)
- Nhấn Tab → focus di chuyển tuần tự: Name → Email → Cancel → Submit → quay lại Name
- Nhấn Shift+Tab → di chuyển ngược lại
- Nhấn Escape → đóng modal và focus trở về nút mở modal
- Khi modal mở → không thể scroll trang bên dưới (body scroll bị khóa)
- Khi đóng modal → scroll position được khôi phục chính xác
- Không có hiện tượng nhảy focus hoặc flash giao diện
*/⭐⭐⭐⭐ Exercise 4: Dropdown Menu với Smart Positioning (60 phút)
/**
* 🎯 Mục tiêu: Production-ready dropdown với collision detection
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Nhiệm vụ:
* 1. So sánh positioning strategies:
* A. Fixed position với viewport calculations
* B. Absolute position relative to trigger
* C. Portal với global positioning
* 2. Document pros/cons
* 3. Chọn approach
* 4. Write ADR
*
* Requirements:
* - Dropdown flips nếu không đủ space
* - Clamps to viewport boundaries
* - Updates position on scroll/resize
* - Smooth animations
* - Keyboard navigation
* - Click outside to close
*
* 💻 PHASE 2: Implementation (30 phút)
* 🧪 PHASE 3: Testing (10 phút)
*/
import { useState, useRef, useLayoutEffect, useEffect } from 'react';
function SmartDropdown({ trigger, items, align = 'left' }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [flipped, setFlipped] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// TODO: Calculate position với useLayoutEffect
useLayoutEffect(() => {
if (!isOpen || !triggerRef.current || !dropdownRef.current) return;
const calculatePosition = () => {
const triggerRect = triggerRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
let top = triggerRect.bottom + 4;
let left =
align === 'left'
? triggerRect.left
: triggerRect.right - dropdownRect.width;
let shouldFlip = false;
// Check bottom overflow
if (top + dropdownRect.height > viewportHeight) {
// Try flipping to top
const topPosition = triggerRect.top - dropdownRect.height - 4;
if (topPosition > 0) {
top = topPosition;
shouldFlip = true;
} else {
// Clamp to viewport
top = viewportHeight - dropdownRect.height - 8;
}
}
// Check horizontal overflow
if (left + dropdownRect.width > viewportWidth) {
left = viewportWidth - dropdownRect.width - 8;
}
if (left < 0) {
left = 8;
}
setPosition({ top, left });
setFlipped(shouldFlip);
};
calculatePosition();
// Recalculate on scroll/resize
window.addEventListener('scroll', calculatePosition, true);
window.addEventListener('resize', calculatePosition);
return () => {
window.removeEventListener('scroll', calculatePosition, true);
window.removeEventListener('resize', calculatePosition);
};
}, [isOpen, align]);
// TODO: Click outside
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (
triggerRef.current &&
!triggerRef.current.contains(e.target) &&
dropdownRef.current &&
!dropdownRef.current.contains(e.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// TODO: Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setIsOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
>
{trigger}
</div>
{isOpen && (
<div
ref={dropdownRef}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
backgroundColor: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
minWidth: '200px',
zIndex: 1000,
opacity: position.top === 0 ? 0 : 1,
transition: 'opacity 0.15s ease',
}}
>
{items.map((item, index) => (
<div
key={index}
onClick={() => {
item.onClick?.();
setIsOpen(false);
}}
style={{
padding: '10px 16px',
cursor: 'pointer',
transition: 'background-color 0.15s',
borderBottom:
index < items.length - 1 ? '1px solid #f0f0f0' : 'none',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = '#f5f5f5')
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = 'white')
}
>
{item.label}
</div>
))}
</div>
)}
</div>
);
}
// Demo
function DropdownDemo() {
return (
<div>
<div style={{ padding: '20px', marginBottom: '100vh' }}>
<h2>Smart Dropdown Demo</h2>
<p>Try dropdowns at different positions:</p>
<div style={{ marginTop: '20px' }}>
<SmartDropdown
trigger={
<button style={{ padding: '10px 20px', cursor: 'pointer' }}>
Top of Page
</button>
}
items={[
{ label: 'Option 1', onClick: () => console.log('Option 1') },
{ label: 'Option 2', onClick: () => console.log('Option 2') },
{ label: 'Option 3', onClick: () => console.log('Option 3') },
]}
/>
</div>
</div>
<div style={{ padding: '20px' }}>
<SmartDropdown
trigger={
<button style={{ padding: '10px 20px', cursor: 'pointer' }}>
Bottom of Page (Should Flip)
</button>
}
items={[
{ label: 'Option 1', onClick: () => console.log('Option 1') },
{ label: 'Option 2', onClick: () => console.log('Option 2') },
{ label: 'Option 3', onClick: () => console.log('Option 3') },
{ label: 'Option 4', onClick: () => console.log('Option 4') },
{ label: 'Option 5', onClick: () => console.log('Option 5') },
]}
/>
</div>
</div>
);
}
// 🧪 PHASE 3: Testing Checklist
// - [ ] Dropdown appears at correct position
// - [ ] Flips to top when near bottom edge
// - [ ] Clamps to viewport boundaries
// - [ ] Updates position on scroll
// - [ ] Closes on click outside
// - [ ] Closes on Escape
// - [ ] No flash/jump on open💡 Solution
/**
* Smart Dropdown with collision detection, auto-flip and viewport clamping
* Uses useLayoutEffect to calculate position before paint (no flash/jump)
* Includes click-outside close, Escape key support and dynamic reposition on scroll/resize
*/
function SmartDropdown({ trigger, items, align = 'left' }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [placement, setPlacement] = useState('bottom'); // bottom | top
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// Calculate and update dropdown position
useLayoutEffect(() => {
if (!isOpen || !triggerRef.current || !dropdownRef.current) return;
const updatePosition = () => {
const triggerRect = triggerRef.current.getBoundingClientRect();
const dropdownRect = dropdownRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const spacing = 8;
let top = triggerRect.bottom + spacing;
let left =
align === 'left'
? triggerRect.left
: triggerRect.right - dropdownRect.width;
let newPlacement = 'bottom';
// Check if there's enough space below → flip to top if needed
if (top + dropdownRect.height > viewportHeight - spacing) {
const topPosition = triggerRect.top - dropdownRect.height - spacing;
if (topPosition >= spacing) {
top = topPosition;
newPlacement = 'top';
} else {
// Not enough space either side → clamp to bottom of viewport
top = viewportHeight - dropdownRect.height - spacing;
}
}
// Horizontal clamping
left = Math.max(
spacing,
Math.min(left, viewportWidth - dropdownRect.width - spacing),
);
setPosition({ top, left });
setPlacement(newPlacement);
};
updatePosition();
// Re-calculate on scroll (capture phase to catch early) and resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [isOpen, align]);
// Click outside to close
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e) => {
if (
triggerRef.current &&
!triggerRef.current.contains(e.target) &&
dropdownRef.current &&
!dropdownRef.current.contains(e.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
// Escape key to close
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
setIsOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div
ref={triggerRef}
onClick={() => setIsOpen((prev) => !prev)}
style={{ cursor: 'pointer' }}
>
{trigger}
</div>
{isOpen && (
<div
ref={dropdownRef}
style={{
position: 'fixed',
top: `${position.top}px`,
left: `${position.left}px`,
minWidth: '180px',
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '6px',
boxShadow: '0 8px 16px rgba(0,0,0,0.15)',
zIndex: 1000,
overflow: 'hidden',
opacity: position.top === 0 ? 0 : 1,
transform:
placement === 'top' ? 'translateY(-8px)' : 'translateY(8px)',
transition: 'opacity 0.15s ease, transform 0.15s ease',
}}
>
{items.map((item, index) => (
<div
key={index}
onClick={() => {
item.onClick?.();
setIsOpen(false);
}}
style={{
padding: '10px 16px',
cursor: 'pointer',
fontSize: '14px',
color: '#333',
borderBottom:
index < items.length - 1 ? '1px solid #f0f0f0' : 'none',
transition: 'background-color 0.12s',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = '#f5f5f5')
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = 'white')
}
>
{item.label}
</div>
))}
</div>
)}
</div>
);
}
// Demo usage
function DropdownDemo() {
return (
<div style={{ padding: '40px', minHeight: '120vh' }}>
<h2>Smart Dropdown with Auto-flip</h2>
<div style={{ margin: '60px 0' }}>
<SmartDropdown
trigger={
<button
style={{
padding: '12px 24px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '16px',
}}
>
Dropdown (Middle of Page)
</button>
}
items={[
{ label: 'Profile', onClick: () => console.log('Profile clicked') },
{
label: 'Settings',
onClick: () => console.log('Settings clicked'),
},
{
label: 'Notifications',
onClick: () => console.log('Notifications clicked'),
},
{ label: 'Logout', onClick: () => console.log('Logout clicked') },
]}
/>
</div>
<div style={{ marginTop: '80vh' }}>
<SmartDropdown
trigger={
<button
style={{
padding: '12px 24px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '16px',
}}
>
Dropdown Near Bottom (Should Flip ↑)
</button>
}
items={[
{ label: 'Option A', onClick: () => {} },
{ label: 'Option B', onClick: () => {} },
{ label: 'Option C', onClick: () => {} },
{ label: 'Option D', onClick: () => {} },
{ label: 'Option E', onClick: () => {} },
]}
align='right'
/>
</div>
</div>
);
}
/*
Kết quả mong đợi:
- Dropdown mở ngay dưới trigger (hoặc bên phải nếu align="right")
- Khi trigger gần đáy màn hình → tự động flip lên trên
- Vị trí luôn nằm trong viewport (không bị tràn ra ngoài)
- Cập nhật vị trí realtime khi scroll hoặc resize cửa sổ
- Click bên ngoài hoặc nhấn Escape → đóng dropdown
- Không có hiện tượng nhảy vị trí (flash) khi mở nhờ useLayoutEffect
- Hiệu ứng mở nhẹ nhàng (opacity + transform)
*/⭐⭐⭐⭐⭐ Exercise 5: Virtualized List với Smooth Scrolling (90 phút)
/**
* 🎯 Mục tiêu: Virtual scroll list cho 10,000+ items
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* Build virtualized list để render large datasets efficiently:
* 1. Only render visible items
* 2. Smooth scrolling experience
* 3. Dynamic item heights
* 4. Scroll position preservation
* 5. Jump to index
* 6. Measurement caching
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture:
* - useLayoutEffect để measure item heights
* - Cache measurements trong ref
* - Calculate visible range
* - Render only visible items
*
* 2. Performance Strategy:
* - Overscan để prevent white space
* - Debounced scroll handler
* - Memoized calculations
* - Height estimation for unmeasured items
*
* 3. Challenges:
* - Dynamic heights unknown upfront
* - Scroll position jumps
* - Performance with rapid scrolling
*
* ✅ Production Checklist:
* - [ ] Measure all rendered items
* - [ ] Cache measurements
* - [ ] Calculate visible range correctly
* - [ ] Smooth scrolling
* - [ ] No white space/jumps
* - [ ] Jump to index works
* - [ ] Memory efficient
*/
import { useState, useRef, useLayoutEffect, useEffect, useMemo } from 'react';
function VirtualizedList({
items,
estimatedItemHeight = 50,
overscan = 3,
containerHeight = 600,
}) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const heightCache = useRef({});
const itemRefs = useRef({});
// TODO: Measure item heights với useLayoutEffect
useLayoutEffect(() => {
// Measure all rendered items
Object.keys(itemRefs.current).forEach((key) => {
const element = itemRefs.current[key];
if (element) {
const height = element.getBoundingClientRect().height;
heightCache.current[key] = height;
}
});
});
// Calculate visible range
const { virtualItems, totalHeight } = useMemo(() => {
const heights = [];
let totalHeight = 0;
// Calculate cumulative heights
items.forEach((item, index) => {
const height = heightCache.current[index] || estimatedItemHeight;
heights.push({ index, height, offset: totalHeight });
totalHeight += height;
});
// Find visible range
const startIndex = heights.findIndex(
(h) => h.offset + h.height > scrollTop,
);
const endIndex = heights.findIndex(
(h) => h.offset > scrollTop + containerHeight,
);
const start = Math.max(0, startIndex - overscan);
const end = Math.min(
heights.length,
(endIndex === -1 ? heights.length : endIndex) + overscan,
);
const virtualItems = heights.slice(start, end).map((h) => ({
index: h.index,
offset: h.offset,
height: h.height,
}));
return { virtualItems, totalHeight };
}, [items, scrollTop, estimatedItemHeight, overscan, containerHeight]);
// Scroll handler
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
// Jump to index
const scrollToIndex = (index) => {
if (!containerRef.current) return;
let offset = 0;
for (let i = 0; i < index; i++) {
offset += heightCache.current[i] || estimatedItemHeight;
}
containerRef.current.scrollTop = offset;
};
return (
<div style={{ padding: '20px' }}>
<h2>Virtualized List ({items.length.toLocaleString()} items)</h2>
<div style={{ marginBottom: '10px' }}>
<input
type='number'
placeholder='Jump to index...'
onKeyPress={(e) => {
if (e.key === 'Enter') {
scrollToIndex(parseInt(e.target.value));
}
}}
style={{ padding: '5px', marginRight: '10px' }}
/>
<button onClick={() => scrollToIndex(0)}>Top</button>
<button onClick={() => scrollToIndex(Math.floor(items.length / 2))}>
Middle
</button>
<button onClick={() => scrollToIndex(items.length - 1)}>Bottom</button>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: `${containerHeight}px`,
overflow: 'auto',
border: '1px solid #ccc',
borderRadius: '4px',
position: 'relative',
}}
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
{virtualItems.map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.index}
ref={(el) => (itemRefs.current[virtualItem.index] = el)}
style={{
position: 'absolute',
top: `${virtualItem.offset}px`,
left: 0,
right: 0,
padding: '10px',
borderBottom: '1px solid #eee',
backgroundColor:
virtualItem.index % 2 === 0 ? 'white' : '#f9f9f9',
}}
>
<div style={{ fontWeight: 'bold' }}>
Item #{virtualItem.index}
</div>
<div style={{ color: '#666', fontSize: '14px' }}>
{item.content}
</div>
</div>
);
})}
</div>
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
Scroll position: {scrollTop.toFixed(0)}px | Rendering{' '}
{virtualItems.length} / {items.length} items
</div>
</div>
);
}
// Demo
function VirtualListDemo() {
const items = useMemo(() => {
return Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `This is item ${i}. ${i % 3 === 0 ? 'This item has extra content to make it taller and test dynamic heights.' : ''}`,
}));
}, []);
return <VirtualizedList items={items} />;
}
// 📝 Implementation Notes:
//
// Algorithm breakdown:
// 1. Initialize with estimated heights
// 2. Render visible items based on scrollTop
// 3. Measure actual heights after render
// 4. Cache measurements
// 5. Recalculate on scroll with cached heights
// 6. Repeat 2-5
//
// Performance tips:
// - Use refs for caching (no re-renders)
// - Memoize calculations
// - Overscan prevents white space
// - Don't measure on every scroll (use actual heights from cache)💡 Solution
/**
* Virtualized List component for rendering very large lists efficiently
* Only renders visible + overscan items
* Uses useLayoutEffect to measure actual heights after render
* Caches measurements to prevent jumps on re-render/scroll
*/
function VirtualizedList({
items,
estimatedItemHeight = 60,
overscan = 5,
containerHeight = 600,
}) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
// Cache of measured heights (index → height)
const heightCache = useRef({});
// Refs to DOM elements for measurement
const itemRefs = useRef({});
// Measure rendered items' actual heights (runs after layout)
useLayoutEffect(() => {
let needsUpdate = false;
Object.keys(itemRefs.current).forEach((key) => {
const index = Number(key);
const el = itemRefs.current[key];
if (el) {
const measured = el.getBoundingClientRect().height;
if (heightCache.current[index] !== measured) {
heightCache.current[index] = measured;
needsUpdate = true;
}
}
});
// Force re-calculation of visible range if any height changed
if (needsUpdate) {
setScrollTop((prev) => prev); // trigger memo recalc
}
});
// Calculate visible range + total height
const { visibleItems, totalHeight } = useMemo(() => {
const positions = [];
let accumulated = 0;
items.forEach((_, index) => {
const height = heightCache.current[index] || estimatedItemHeight;
positions.push({ index, top: accumulated, height });
accumulated += height;
});
// Find start/end of visible range
let start = 0;
while (
start < items.length &&
positions[start].top + positions[start].height < scrollTop
) {
start++;
}
let end = start;
while (
end < items.length &&
positions[end].top < scrollTop + containerHeight
) {
end++;
}
// Apply overscan
start = Math.max(0, start - overscan);
end = Math.min(items.length, end + overscan);
const visible = positions.slice(start, end).map((p) => ({
...p,
item: items[p.index],
}));
return {
visibleItems: visible,
totalHeight: accumulated,
};
}, [items, scrollTop, estimatedItemHeight, overscan, containerHeight]);
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
const scrollToIndex = (index) => {
if (!containerRef.current) return;
let offset = 0;
for (let i = 0; i < index; i++) {
offset += heightCache.current[i] || estimatedItemHeight;
}
containerRef.current.scrollTop = offset;
};
return (
<div style={{ padding: '20px' }}>
<h2>Virtualized List ({items.length.toLocaleString()} items)</h2>
<div
style={{
marginBottom: '16px',
display: 'flex',
gap: '12px',
alignItems: 'center',
}}
>
<input
type='number'
placeholder='Jump to index...'
onKeyDown={(e) => {
if (e.key === 'Enter') {
const idx = parseInt(e.target.value, 10);
if (!isNaN(idx)) scrollToIndex(idx);
}
}}
style={{ padding: '8px', width: '140px' }}
/>
<button onClick={() => scrollToIndex(0)}>Top</button>
<button onClick={() => scrollToIndex(Math.floor(items.length / 2))}>
Middle
</button>
<button onClick={() => scrollToIndex(items.length - 1)}>Bottom</button>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: `${containerHeight}px`,
overflow: 'auto',
border: '1px solid #ccc',
borderRadius: '8px',
backgroundColor: '#fafafa',
position: 'relative',
}}
>
<div style={{ height: `${totalHeight}px`, position: 'relative' }}>
{visibleItems.map(({ index, top, height, item }) => (
<div
key={index}
ref={(el) => (itemRefs.current[index] = el)}
style={{
position: 'absolute',
top: `${top}px`,
left: 0,
right: 0,
minHeight: `${height}px`,
padding: '12px 16px',
borderBottom: '1px solid #eee',
backgroundColor: index % 2 === 0 ? '#ffffff' : '#f8f9fa',
boxSizing: 'border-box',
}}
>
<div style={{ fontWeight: 'bold', marginBottom: '6px' }}>
Item #{index + 1}
</div>
<div style={{ color: '#555', lineHeight: 1.5 }}>
{item.content}
{index % 4 === 0 && (
<p
style={{
marginTop: '8px',
color: '#777',
fontSize: '14px',
}}
>
This item has extra content to demonstrate variable height
measurement.
</p>
)}
</div>
</div>
))}
</div>
</div>
<div
style={{
marginTop: '12px',
fontSize: '13px',
color: '#666',
textAlign: 'center',
}}
>
Showing {visibleItems.length} items | Scroll position:{' '}
{scrollTop.toLocaleString()}px
</div>
</div>
);
}
// Demo with 10,000 items
function VirtualListDemo() {
const items = useMemo(() => {
return Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `Item ${i + 1} content. ${
i % 5 === 0
? 'This item is taller because it has additional description text to demonstrate dynamic height handling.'
: 'Short item description.'
}`,
}));
}, []);
return (
<VirtualizedList
items={items}
estimatedItemHeight={60}
/>
);
}
/*
Kết quả mong đợi:
- Chỉ render ~15-25 items cùng lúc (tùy viewport + overscan)
- Scroll cực kỳ mượt dù có 10,000+ items
- Không có hiện tượng nhảy layout khi scroll nhờ cache chiều cao
- Chiều cao item được đo chính xác sau khi render (useLayoutEffect)
- Jump to index hoạt động chính xác (dùng cache hoặc estimated height)
- Hỗ trợ item có chiều cao động (không cần biết trước)
- Hiệu suất tốt ngay cả khi scroll nhanh
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: useEffect vs useLayoutEffect
| Tiêu chí | useEffect | useLayoutEffect | Khi nào dùng? |
|---|---|---|---|
| Timing | After paint | Before paint | useLayoutEffect: Cần update trước paint |
| Synchronous | ❌ Async | ✅ Sync (blocks paint) | useLayoutEffect: DOM measurements |
| Performance | ✅ Tốt hơn (non-blocking) | ⚠️ Có thể slow (blocks) | useEffect: Mặc định useLayoutEffect: Khi cần thiết |
| Visual flash | ⚠️ Có thể có | ✅ Không có | useLayoutEffect: Tooltip positioning |
| Use cases | Data fetching, subscriptions | DOM measurements, animations | - |
| SSR | ✅ Safe | ⚠️ Warning | useEffect cho SSR |
| Browser paint | Happens before effect | Blocked until effect done | - |
| Default choice | ✅ Yes | ❌ No | Start with useEffect |
Decision Tree
Cần DOM operation?
│
┌──────────┴──────────┐
│ │
Có Không
│ │
User nhìn thấy useEffect
intermediate state
có vấn đề không?
│
┌───┴───┐
│ │
Có Không
│ │
useLayout useEffect
Effect
│
Examples:
- Tooltip position
- Animation init
- Measure→Update
- Prevent flickerTrade-offs Analysis
useLayoutEffect Pros:
✅ No visual flash
✅ Synchronous measurements
✅ Guaranteed before paint
✅ Perfect cho positioning
✅ Animation initializationuseLayoutEffect Cons:
❌ Blocks browser painting
❌ Slower initial render
❌ Can cause jank if slow
❌ SSR warnings
❌ Overuse hurts performancePattern Combinations
Pattern 1: Measure then Update
// ✅ GOOD: Measure → Update pattern
function ResizableComponent({ content }) {
const [size, setSize] = useState({ width: 0, height: 0 });
const ref = useRef(null);
useLayoutEffect(() => {
// 1. Measure
const rect = ref.current.getBoundingClientRect();
// 2. Update (synchronously before paint)
setSize({ width: rect.width, height: rect.height });
}, [content]); // Re-measure when content changes
return (
<div ref={ref}>
{content}
<div>
Size: {size.width} x {size.height}
</div>
</div>
);
}Pattern 2: Hybrid Approach
// ✅ PATTERN: useLayoutEffect cho measurement, useEffect cho side effects
function HybridComponent() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [data, setData] = useState(null);
const ref = useRef(null);
// Measurement: useLayoutEffect
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setDimensions({ width: rect.width, height: rect.height });
}, []);
// Data fetching: useEffect
useEffect(() => {
fetchData(dimensions).then(setData);
}, [dimensions]);
return <div ref={ref}>{/* ... */}</div>;
}Pattern 3: Conditional Sync
// ✅ PATTERN: useLayoutEffect chỉ khi cần, fallback to useEffect
function SmartComponent({ needsSyncUpdate }) {
const [value, setValue] = useState(0);
const effectHook = needsSyncUpdate ? useLayoutEffect : useEffect;
effectHook(() => {
// Logic here
}, []);
return <div>{value}</div>;
}Common Patterns
Pattern: Tooltip/Popover Positioning
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Measure trigger
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// Calculate position
let top = triggerRect.bottom + 5;
let left = triggerRect.left;
// Adjust for viewport
if (top + tooltipRect.height > window.innerHeight) {
top = triggerRect.top - tooltipRect.height - 5;
}
setPosition({ top, left });
}, []);Pattern: Animation Setup
useLayoutEffect(() => {
// Set initial state BEFORE paint
element.style.opacity = '0';
element.style.transform = 'scale(0.8)';
// Trigger animation
requestAnimationFrame(() => {
element.style.transition = 'all 0.3s ease';
element.style.opacity = '1';
element.style.transform = 'scale(1)';
});
}, []);Pattern: Scroll Position Restoration
useLayoutEffect(() => {
// Restore scroll BEFORE paint
const savedPosition = sessionStorage.getItem('scrollPos');
if (savedPosition) {
window.scrollTo(0, parseInt(savedPosition));
}
}, []);
useEffect(() => {
// Save scroll position
const handleScroll = () => {
sessionStorage.setItem('scrollPos', window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Using useLayoutEffect for Async Operations ⭐
// ❌ BUG: Async operation trong useLayoutEffect
function BuggyDataFetcher() {
const [data, setData] = useState(null);
useLayoutEffect(() => {
// ⚠️ Async operation blocks painting!
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []);
return <div>{data?.title}</div>;
}🔍 Debug Questions:
- Tại sao đây là bad practice?
- Impact lên user experience?
- Cách fix?
💡 Giải thích:
// ❌ VẤN ĐỀ:
// - useLayoutEffect blocks painting
// - Fetch takes time (100ms - 1s+)
// - User sees BLANK SCREEN during fetch
// - Terrible UX!
// Timeline:
// 1. Render
// 2. useLayoutEffect runs
// 3. fetch() starts... (blocks paint!)
// 4. ...waiting... (user sees nothing!)
// 5. ...still waiting...
// 6. Response arrives
// 7. setData → re-render
// 8. FINALLY paint
// ✅ SOLUTION: useEffect cho async operations
function FixedDataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ✅ useEffect
fetch('/api/data')
.then((res) => res.json())
.then((data) => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>; // User sees loading state
return <div>{data?.title}</div>;
}
// Timeline:
// 1. Render with loading state
// 2. Paint → User SEES "Loading..." ✅
// 3. useEffect runs (async, doesn't block)
// 4. fetch() in background
// 5. Response arrives
// 6. Update state → re-render → paint new data
// 📊 RULE:
// useLayoutEffect: Only for SYNCHRONOUS DOM operations
// useEffect: For async operations, side effects, subscriptionsBug 2: Measuring Wrong Element ⭐⭐
// ❌ BUG: Measuring element that hasn't rendered yet
function BuggyConditional() {
const [show, setShow] = useState(false);
const [height, setHeight] = useState(0);
const divRef = useRef(null);
useLayoutEffect(() => {
// ⚠️ divRef.current might be null!
const h = divRef.current.offsetHeight; // TypeError!
setHeight(h);
}, []); // ⚠️ Missing dependency: show
return (
<div>
<button onClick={() => setShow(!show)}>Toggle</button>
{show && <div ref={divRef}>Content</div>}
<p>Height: {height}</p>
</div>
);
}🔍 Debug Questions:
- Tại sao divRef.current là null?
- Effect chạy khi nào?
- Fix như thế nào?
💡 Giải thích:
// ❌ VẤN ĐỀ:
// - useLayoutEffect with [] runs only on mount
// - At mount: show = false → div doesn't exist
// - divRef.current = null → crash!
// ✅ SOLUTION 1: Add show to dependencies
function Fixed1() {
const [show, setShow] = useState(false);
const [height, setHeight] = useState(0);
const divRef = useRef(null);
useLayoutEffect(() => {
if (divRef.current) {
// ✅ Null check
const h = divRef.current.offsetHeight;
setHeight(h);
}
}, [show]); // ✅ Re-run when show changes
return (
<div>
<button onClick={() => setShow(!show)}>Toggle</button>
{show && <div ref={divRef}>Content</div>}
<p>Height: {height}</p>
</div>
);
}
// ✅ SOLUTION 2: Callback ref
function Fixed2() {
const [show, setShow] = useState(false);
const [height, setHeight] = useState(0);
const measureRef = (element) => {
if (element) {
// Called when element mounts
setHeight(element.offsetHeight);
}
};
return (
<div>
<button onClick={() => setShow(!show)}>Toggle</button>
{show && <div ref={measureRef}>Content</div>}
<p>Height: {height}</p>
</div>
);
}
// 📊 LESSON:
// - Always null-check refs
// - Include relevant dependencies
// - Callback refs useful cho conditional elementsBug 3: Infinite Loop với Measurements ⭐⭐⭐
// ❌ BUG: useLayoutEffect causes infinite render loop
function BuggyInfiniteLoop() {
const [size, setSize] = useState({ width: 0, height: 0 });
const divRef = useRef(null);
useLayoutEffect(() => {
const rect = divRef.current.getBoundingClientRect();
// ⚠️ Always sets new object → always triggers re-render!
setSize({ width: rect.width, height: rect.height });
// ⚠️ No dependencies → runs after EVERY render!
}); // Missing dependency array!
return (
<div
ref={divRef}
style={{ width: `${size.width}px` }}
>
Content
</div>
);
}🔍 Debug Questions:
- Tại sao component re-render vô tận?
- Dependency array missing impact?
- Cách break loop?
💡 Giải thích:
// ❌ VẤN ĐỀ:
// Loop:
// 1. Render
// 2. useLayoutEffect runs (no deps → always runs)
// 3. Measure DOM
// 4. setSize({ width: 100, height: 50 })
// 5. Re-render (state changed)
// 6. useLayoutEffect runs again
// 7. setSize({ width: 100, height: 50 }) // Same values but new object!
// 8. Re-render (React sees different object reference)
// 9. Loop continues forever! 💥
// ✅ SOLUTION 1: Add empty dependency array
function Fixed1() {
const [size, setSize] = useState({ width: 0, height: 0 });
const divRef = useRef(null);
useLayoutEffect(() => {
const rect = divRef.current.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
}, []); // ✅ Only run once on mount
return (
<div
ref={divRef}
style={{ width: `${size.width}px` }}
>
Content
</div>
);
}
// ✅ SOLUTION 2: Check if size actually changed
function Fixed2() {
const [size, setSize] = useState({ width: 0, height: 0 });
const divRef = useRef(null);
useLayoutEffect(() => {
const rect = divRef.current.getBoundingClientRect();
// ✅ Only update if actually changed
setSize((prev) => {
if (prev.width === rect.width && prev.height === rect.height) {
return prev; // Same object reference → no re-render
}
return { width: rect.width, height: rect.height };
});
}); // Can run every render now - safe!
return <div ref={divRef}>Content</div>;
}
// ✅ SOLUTION 3: Use ResizeObserver
function Fixed3() {
const [size, setSize] = useState({ width: 0, height: 0 });
const divRef = useRef(null);
useLayoutEffect(() => {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observer.observe(divRef.current);
return () => observer.disconnect();
}, []); // Only setup once
return <div ref={divRef}>Content</div>;
}
// 📊 LESSONS:
// 1. ALWAYS use dependency array
// 2. Check if values actually changed before setState
// 3. Prefer ResizeObserver for size changes
// 4. Be careful with object references✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu các câu bạn có thể trả lời tự tin:
- [ ] Sự khác biệt chính giữa useEffect và useLayoutEffect?
- [ ] useLayoutEffect chạy khi nào trong React lifecycle?
- [ ] Khi nào PHẢI dùng useLayoutEffect?
- [ ] Khi nào KHÔNG nên dùng useLayoutEffect?
- [ ] Tại sao useLayoutEffect có thể gây performance issues?
- [ ] "Flash of unstyled content" là gì và làm sao prevent?
- [ ] useLayoutEffect có API khác useEffect không?
- [ ] SSR với useLayoutEffect có vấn đề gì?
- [ ] Làm sao measure DOM element trước khi paint?
- [ ] Trade-offs giữa useEffect vs useLayoutEffect?
Code Review Checklist
Khi review code có useLayoutEffect, check:
✅ Correct Usage:
- [ ] useLayoutEffect chỉ cho synchronous DOM operations
- [ ] No async operations (fetch, setTimeout) trong useLayoutEffect
- [ ] Measurements cần thiết trước paint
- [ ] Preventing visual flash là justified
✅ Performance:
- [ ] Effect code chạy nhanh (<16ms ideal)
- [ ] Không block painting quá lâu
- [ ] Consider useEffect nếu flash không noticeable
- [ ] Memoize expensive calculations
✅ Dependencies:
- [ ] Dependency array correct
- [ ] No infinite loops
- [ ] Re-runs khi cần thiết
✅ Cleanup:
- [ ] Event listeners removed
- [ ] Observers disconnected
- [ ] Timers cleared
❌ Red Flags:
- [ ] Data fetching trong useLayoutEffect
- [ ] Heavy computations blocking paint
- [ ] No dependency array (runs every render)
- [ ] Overuse (should be rare!)
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Exercise: Custom useWindowSize Hook
/**
* 🎯 Mục tiêu: Hook track window size với useLayoutEffect
*
* Requirements:
* 1. Return current window size
* 2. Update on resize
* 3. Use useLayoutEffect để prevent flash
* 4. Debounce resize events
*
* API:
* const { width, height } = useWindowSize();
*/
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
// TODO: Implement với useLayoutEffect
// Hints:
// - Initial measurement với useLayoutEffect
// - Resize listener với useEffect (debounced)
// - Cleanup listener
return size;
}
// Usage:
function ResponsiveComponent() {
const { width, height } = useWindowSize();
return (
<div>
<p>
Window: {width} x {height}
</p>
{width < 768 ? <MobileView /> : <DesktopView />}
</div>
);
}💡 Solution
/**
* Custom hook that tracks current window dimensions
* Uses useLayoutEffect for initial measurement (before paint)
* and useEffect for resize event listening
*/
function useWindowSize() {
const [size, setSize] = useState({
width: 0,
height: 0,
});
useLayoutEffect(() => {
// Initial measurement - synchronous, before first paint
const updateSize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateSize();
}, []); // Chỉ chạy 1 lần khi mount
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// Thêm debounce để tránh gọi quá nhiều lần khi resize liên tục
let timeoutId;
const debouncedResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(handleResize, 100);
};
window.addEventListener('resize', debouncedResize);
// Cleanup
return () => {
window.removeEventListener('resize', debouncedResize);
clearTimeout(timeoutId);
};
}, []);
return size;
}
/**
* Demo component showing usage of useWindowSize hook
*/
function ResponsiveComponent() {
const { width, height } = useWindowSize();
const breakpoint = 768;
return (
<div
style={{
padding: '40px',
fontFamily: 'system-ui, sans-serif',
maxWidth: '900px',
margin: '0 auto',
}}
>
<h2>Window Size Tracker</h2>
<div
style={{
padding: '24px',
backgroundColor: '#f8f9fa',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
marginBottom: '24px',
}}
>
<div
style={{ fontSize: '28px', fontWeight: 'bold', marginBottom: '12px' }}
>
{width} × {height}
</div>
<div
style={{
fontSize: '18px',
color: width < breakpoint ? '#d9534f' : '#5cb85c',
fontWeight: '500',
}}
>
{width < breakpoint ? 'Mobile view' : 'Desktop view'}
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: width < breakpoint ? '1fr' : '1fr 1fr',
gap: '24px',
}}
>
<div
style={{
padding: '20px',
backgroundColor: '#e8f4ff',
borderRadius: '8px',
border: '1px solid #b3d4fc',
}}
>
<h3>Mobile Layout (width < 768px)</h3>
<p>Single column layout, stacked content</p>
</div>
<div
style={{
padding: '20px',
backgroundColor: '#e6ffed',
borderRadius: '8px',
border: '1px solid #b3e6cc',
}}
>
<h3>Desktop Layout (width ≥ 768px)</h3>
<p>Two-column or multi-column layout</p>
</div>
</div>
<p
style={{
marginTop: '32px',
color: '#666',
fontSize: '14px',
}}
>
Try resizing your browser window to see the values update in real-time
</p>
</div>
);
}
/*
Kết quả mong đợi:
- Giá trị width & height chính xác ngay từ lần render đầu (nhờ useLayoutEffect)
- Khi thay đổi kích thước cửa sổ → giá trị cập nhật mượt mà (có debounce)
- Không gây ra hiện tượng nhấp nháy giao diện khi resize
- Phản ứng responsive hoạt động ngay lập tức (mobile/desktop layout thay đổi)
- Cleanup event listener đúng cách khi component unmount
*/Nâng cao (60 phút)
Exercise: Sticky Header với Height Detection
/**
* 🎯 Mục tiêu: Sticky header adjust content padding dynamically
*
* Scenario:
* Header height thay đổi (responsive, content changes).
* Content padding phải match header height để không bị overlap.
*
* Requirements:
* 1. Measure header height
* 2. Apply padding to content
* 3. No flash/jump
* 4. Update on resize
* 5. Update when header content changes
*/
function StickyHeaderLayout({ headerContent, children }) {
const [headerHeight, setHeaderHeight] = useState(0);
const headerRef = useRef(null);
// TODO: Implement measurement logic
// Use useLayoutEffect để measure
// ResizeObserver để track changes
return (
<div>
<header
ref={headerRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderBottom: '1px solid #ccc',
zIndex: 100,
}}
>
{headerContent}
</header>
<main style={{ paddingTop: `${headerHeight}px` }}>{children}</main>
</div>
);
}💡 Solution
/**
* Sticky Header Layout with automatic content padding adjustment
* Measures header height using useLayoutEffect + ResizeObserver
* Prevents content jump/flash by setting padding synchronously before paint
*/
function StickyHeaderLayout({ headerContent, children }) {
const [headerHeight, setHeaderHeight] = useState(0);
const headerRef = useRef(null);
const observerRef = useRef(null);
useLayoutEffect(() => {
if (!headerRef.current) return;
// Initial measurement before first paint
const updateHeight = () => {
if (headerRef.current) {
const height = headerRef.current.offsetHeight;
if (height !== headerHeight) {
setHeaderHeight(height);
}
}
};
updateHeight();
// Setup ResizeObserver to track dynamic height changes
observerRef.current = new ResizeObserver(() => {
updateHeight();
});
observerRef.current.observe(headerRef.current);
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []); // Chỉ setup một lần khi mount
return (
<div style={{ minHeight: '100vh' }}>
{/* Sticky Header */}
<header
ref={headerRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderBottom: '1px solid #e0e0e0',
zIndex: 1000,
boxShadow: '0 2px 10px rgba(0,0,0,0.08)',
transition: 'all 0.2s ease',
}}
>
{headerContent}
</header>
{/* Main content with dynamic top padding */}
<main
style={{
paddingTop: `${headerHeight}px`,
transition: 'padding-top 0.2s ease',
}}
>
{children}
</main>
{/* Debug info (optional) */}
<div
style={{
position: 'fixed',
bottom: '16px',
right: '16px',
backgroundColor: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '13px',
fontFamily: 'monospace',
}}
>
Header height: {headerHeight}px
</div>
</div>
);
}
// Demo usage
function StickyHeaderDemo() {
const [extraContent, setExtraContent] = useState(false);
return (
<StickyHeaderLayout
headerContent={
<div
style={{
padding: '16px 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: '1200px',
margin: '0 auto',
}}
>
<h1 style={{ margin: 0, fontSize: '24px' }}>My App</h1>
<nav style={{ display: 'flex', gap: '24px' }}>
<a
href='#'
style={{ textDecoration: 'none', color: '#333' }}
>
Home
</a>
<a
href='#'
style={{ textDecoration: 'none', color: '#333' }}
>
About
</a>
<a
href='#'
style={{ textDecoration: 'none', color: '#333' }}
>
Services
</a>
<a
href='#'
style={{ textDecoration: 'none', color: '#333' }}
>
Contact
</a>
</nav>
</div>
}
>
<div style={{ padding: '32px', maxWidth: '1000px', margin: '0 auto' }}>
<h2>Welcome to the page</h2>
<p style={{ lineHeight: 1.7 }}>
This content starts right below the sticky header. The padding-top is
automatically calculated based on the actual header height.
</p>
<button
onClick={() => setExtraContent(!extraContent)}
style={{
margin: '24px 0',
padding: '12px 24px',
fontSize: '16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
{extraContent ? 'Remove' : 'Add'} extra header content
</button>
{extraContent && (
<div
style={{
backgroundColor: '#fff3cd',
padding: '16px',
borderRadius: '8px',
marginBottom: '24px',
border: '1px solid #ffeeba',
}}
>
<strong>Extra content:</strong> This makes the header taller. The
main content padding will automatically adjust without any layout
jump.
</div>
)}
{/* Long content to enable scrolling */}
{Array.from({ length: 30 }).map((_, i) => (
<p
key={i}
style={{ margin: '16px 0', lineHeight: 1.6 }}
>
Scroll content paragraph {i + 1}. The header stays fixed at the top,
and content never gets hidden underneath thanks to dynamic padding.
</p>
))}
</div>
</StickyHeaderLayout>
);
}
/*
Kết quả mong đợi:
- Header luôn dính ở đầu trang khi scroll
- Nội dung chính bắt đầu ngay dưới header, không bị đè hoặc khoảng trống thừa
- Khi header thay đổi chiều cao (thêm/xóa nội dung) → padding-top tự động điều chỉnh
- Không có hiện tượng nhảy layout (layout shift) khi header resize
- Đo chiều cao diễn ra trước paint (useLayoutEffect) + theo dõi liên tục (ResizeObserver)
- Cleanup observer đúng cách khi component unmount
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - useLayoutEffect:https://react.dev/reference/react/useLayoutEffect
React Docs - useEffect vs useLayoutEffect:https://react.dev/learn/synchronizing-with-effects#step-2-specify-the-effect-dependencies
Đọc thêm
When to useLayoutEffect Instead of useEffect:https://kentcdodds.com/blog/useeffect-vs-uselayouteffect
Understanding React useLayoutEffect:https://blog.logrocket.com/understanding-react-uselayouteffect-hook/
Browser Paint Timing:https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (cần biết từ trước)
- Ngày 16-20: useEffect fundamentals, cleanup, dependencies
- Ngày 21-22: useRef cho DOM access và measurements
- Ngày 11-14: useState và state updates
Hướng tới (sẽ dùng ở)
- Ngày 24: Custom hooks - combine useLayoutEffect patterns
- Ngày 25: Project - tooltips, modals, animations
- Ngày 29-34: Advanced patterns với measurements
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Performance Budget
// ✅ GOOD: Monitor useLayoutEffect performance
function MonitoredComponent() {
useLayoutEffect(() => {
const start = performance.now();
// Your DOM operations
measureAndUpdate();
const duration = performance.now() - start;
// Log slow effects
if (duration > 16) {
// 60fps = 16ms budget
console.warn('Slow useLayoutEffect:', duration, 'ms');
}
}, []);
}2. SSR Compatibility
// ✅ GOOD: SSR-safe useLayoutEffect
import { useEffect, useLayoutEffect } from 'react';
// Use useLayoutEffect on client, useEffect on server
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
function SSRSafeComponent() {
useIsomorphicLayoutEffect(() => {
// Safe for both client and server
}, []);
}3. Progressive Enhancement
// ✅ GOOD: Fallback if measurement fails
function TooltipWithFallback({ targetRef, content }) {
const [position, setPosition] = useState(null);
const tooltipRef = useRef(null);
useLayoutEffect(() => {
try {
const targetRect = targetRef.current?.getBoundingClientRect();
const tooltipRect = tooltipRef.current?.getBoundingClientRect();
if (!targetRect || !tooltipRect) {
// Fallback position
setPosition({ top: 0, left: 0 });
return;
}
// Calculate position...
} catch (error) {
console.error('Tooltip positioning failed:', error);
// Graceful degradation
setPosition({ top: 0, left: 0 });
}
}, [targetRef]);
// Render with fallback
}Câu Hỏi Phỏng Vấn
Junior Level:
Q1: "useLayoutEffect khác useEffect như thế nào?"
Expected answer:
- useLayoutEffect chạy TRƯỚC browser paint
- useEffect chạy SAU browser paint
- useLayoutEffect synchronous, blocks painting
- useEffect asynchronous, doesn't block
- useLayoutEffect cho DOM measurements
- Mặc định dùng useEffect
Q2: "Khi nào dùng useLayoutEffect?"
Expected answer:
- DOM measurements cần thiết trước paint
- Tooltip/popover positioning
- Animation initialization
- Preventing visual flash
- Scroll position restoration
Mid Level:
Q3: "Giải thích 'flash of unstyled content' và cách fix."
Expected answer:
// Flash occurs:
// 1. Render with default state
// 2. Paint → user sees default
// 3. useEffect measures
// 4. Update state
// 5. Re-paint → user sees change (flash!)
// Fix with useLayoutEffect:
// 1. Render with default state
// 2. useLayoutEffect measures (blocks paint)
// 3. Update state
// 4. Re-render
// 5. Paint → user sees final state (no flash!)Q4: "Performance implications của useLayoutEffect?"
Expected answer:
- Blocks browser painting
- Slower initial render (user waits longer)
- Can cause jank if slow operations
- Good: Prevents flash (<16ms operations)
- Bad: Heavy computation (>16ms)
- Trade-off: Smooth UX vs render speed
Senior Level:
Q5: "Design tooltip system với smart positioning cho large app."
Expected answer:
// Considerations:
// 1. Collision detection with viewport
// 2. Flip strategy (top/bottom/left/right)
// 3. Performance với many tooltips
// 4. Reusability
// 5. Accessibility
function useTooltipPosition(targetRef, options) {
const [position, setPosition] = useState(null);
useLayoutEffect(
() => {
// Measure
// Calculate với collision detection
// Cache results
// Handle edge cases
},
[
/* smart dependencies */
],
);
return position;
}Q6: "Virtual scroll implementation challenges và solutions."
Expected answer:
- Challenge: Dynamic heights unknown
- Solution: Estimate + measure + cache
- Challenge: Scroll jumps
- Solution: useLayoutEffect cho measurement
- Challenge: Performance
- Solution: Overscan, debounce, memoization
- Challenge: Memory
- Solution: Cache limit, cleanup
War Stories
Story: The Dropdown That Wouldn't Position
Production bug: Dropdown xuất hiện ở (0,0) rồi nhảy về đúng vị trí.
// ❌ Original code:
useEffect(() => {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom, left: rect.left });
}, []);
// User saw: Dropdown flash at top-left corner then jump
// ✅ Fix:
useLayoutEffect(() => {
const rect = triggerRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom, left: rect.left });
}, []);
// Now smooth!Lesson: Always useLayoutEffect cho positioning!
🎯 PREVIEW NGÀY MAI
Ngày 24: Custom Hooks - Basics 🎣
Ngày mai chúng ta sẽ học cách tạo custom hooks để reuse logic!
Bạn sẽ học:
- Custom hook rules và conventions
- Extracting stateful logic
- Composing hooks
- useCallback và useMemo (introduction)
- Testing custom hooks
- Common patterns
Preview:
// Custom hook example:
function useTooltip(targetRef) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// Position calculation
}, [isVisible]);
return { isVisible, setIsVisible, position };
}
// Usage:
const tooltip = useTooltip(buttonRef);See you tomorrow! 🚀
✅ CHECKLIST HOÀN THÀNH
Trước khi kết thúc ngày học, check:
- [ ] Hiểu sâu useLayoutEffect vs useEffect
- [ ] Làm đủ 5 exercises
- [ ] Đọc React docs về useLayoutEffect
- [ ] Làm bài tập về nhà
- [ ] Review debug lab
- [ ] Thực hành positioning patterns
- [ ] Chuẩn bị cho custom hooks
🎉 Congratulations! Bạn đã hoàn thành Ngày 23!
Bạn đã học được: ✅ useLayoutEffect fundamentals ✅ Preventing visual flash ✅ DOM measurements before paint ✅ Tooltip/popover positioning ✅ Animation initialization ✅ Performance considerations ✅ SSR compatibility
Tomorrow: Custom Hooks để reuse tất cả logic này! 💪
useLayoutEffect — Tham chiếu tư duy cho Senior
Ngày 23 | Synchronous DOM Measurements & Updates
Hook chuyên biệt — dùng đúng chỗ, không lạm dụng.
MỤC LỤC
- Bản chất — Khác gì useEffect?
- Timeline so sánh
- Khi nào PHẢI dùng — Khi nào KHÔNG nên
- Các patterns thực tế
- Performance Trade-offs
- SSR Compatibility
- Dạng bài tập & nhận dạng vấn đề
- Anti-patterns cần tránh
- Interview Questions — Theo level
- War Story
- Decision Framework nhanh
1. Bản chất
Điều duy nhất khác biệt: TIMING
useLayoutEffect và useEffect giống hệt nhau về API — cùng cú pháp, cùng dependencies, cùng cleanup. Chỉ khác một điều duy nhất: khi nào chạy trong lifecycle.
useEffect— chạy sau khi browser đã paintuseLayoutEffect— chạy trước khi browser paint (nhưng sau khi React đã commit DOM)
Analogy: Quay phim
useEffect = Post-production
Quay phim → Chiếu cho khán giả → Thêm hiệu ứng sau
→ Khán giả thấy cảnh gốc TRƯỚC, rồi mới thấy cảnh đã edit
useLayoutEffect = Live editing
Quay phim → Edit ngay → Chiếu phiên bản cuối
→ Khán giả chỉ thấy cảnh đã edit, không thấy bản gốc2. Timeline so sánh
useEffect (mặc định)
[Render] → [Commit to DOM] → [Browser Paint] → [useEffect chạy]
↑
User thấy intermediate state!
(Nếu effect thay đổi UI → user thấy FLASH)useLayoutEffect
[Render] → [Commit to DOM] → [useLayoutEffect chạy] → [Browser Paint]
↑
Block painting cho đến khi xong
User chỉ thấy kết quả cuối — KHÔNG flashVí dụ cụ thể — Tooltip positioning
Với useEffect (có flash):
- Render tooltip tại
(0, 0)— giá trị initial - Browser paint → User thấy tooltip ở góc trên-trái ⚠️
- useEffect chạy → đo DOM → tính position đúng → setState
- Re-render → Browser paint lại → User thấy tooltip nhảy chỗ ⚠️
Với useLayoutEffect (không flash):
- Render tooltip tại
(0, 0) - useLayoutEffect chạy ngay → đo DOM → tính position đúng → setState đồng bộ
- Re-render với position đúng
- Browser paint lần duy nhất → User chỉ thấy tooltip đúng chỗ ✅
3. Khi nào PHẢI dùng — Khi nào KHÔNG nên
✅ PHẢI dùng useLayoutEffect
Chỉ khi đo DOM rồi update UI ngay và không muốn user thấy intermediate state:
- Tooltip/Popover/Dropdown positioning — đo element, tính vị trí hiển thị
- Scroll position restoration — khôi phục scroll trước khi user thấy
- Animation initialization — đo dimensions trước khi animate
- Prevent layout shift — bất kỳ trường hợp nào mà useEffect gây visual flash rõ ràng
- Sticky header padding — đo chiều cao header để set padding-top cho content
❌ KHÔNG nên dùng useLayoutEffect
- Data fetching — fetch blocks paint → UI freeze → trải nghiệm tệ hơn
- Heavy computation — tính toán nặng blocks paint → jank, lag
- Analytics/logging — không cần synchronous, lãng phí
- State updates không liên quan đến DOM measurements — không có lợi gì
- Bất kỳ thứ gì mà useEffect đủ dùng — mặc định dùng useEffect
Quy tắc vàng
Mặc định dùng
useEffect. Chỉ chuyển sanguseLayoutEffectkhi bạn nhìn thấy visual flash và cần fix nó.
4. Patterns thực tế
Pattern 1: Tooltip Smart Positioning
Vấn đề cần giải quyết: Tooltip cần biết kích thước của chính nó và của target để tính vị trí không bị cắt bởi viewport.
Cơ chế:
- Render tooltip (ở đâu cũng được — user chưa thấy vì paint chưa chạy)
useLayoutEffect: Đotarget.getBoundingClientRect()+tooltip.getBoundingClientRect()- Tính toán vị trí tối ưu (kiểm tra cạnh viewport — flip nếu bị cắt)
setStatevới position mới- Re-render → Paint lần duy nhất với vị trí đúng
Collision detection logic tư duy:
- Nếu
tooltipBottom > viewportHeight→ hiển thị phía trên target thay vì dưới - Nếu
tooltipRight > viewportWidth→ dịch trái - Ưu tiên theo thứ tự: bottom → top → right → left
Pattern 2: Scroll Position Restoration
Use case: User scroll xuống, navigate đi nơi khác, quay lại → khôi phục vị trí scroll.
Vấn đề với useEffect: User thấy trang ở đầu (position 0) trong tích tắc trước khi scroll được khôi phục → flash.
Với useLayoutEffect: Khôi phục scroll position trước khi paint → user không thấy trang ở đầu bao giờ.
Pattern 3: Sticky Header Padding
Use case: Header cố định (sticky) có chiều cao dynamic → content bên dưới cần padding-top bằng đúng chiều cao header.
Với useLayoutEffect + `ResizeObserver:
useLayoutEffectđo chiều cao header lần đầu → set paddingResizeObservertheo dõi khi header thay đổi kích thước → cập nhật padding- Cleanup:
observer.disconnect()
Pattern 4: Animation Initialization
Use case: Slide-in animation cần biết chiều cao element trước khi animate từ 0.
Cơ chế:
- Render element (height: 0 hoặc hidden)
useLayoutEffect: Đoelement.scrollHeight- Set CSS variable với giá trị đo được
- Trigger animation
- Paint — animation chạy mượt từ giá trị đo được
5. Performance Trade-offs
useLayoutEffect BLOCKS browser paint
Đây là con dao hai lưỡi:
| Tình huống | Kết quả |
|---|---|
| Fast operation (<16ms) | ✅ Smooth — không flash, không jank |
| Slow operation (>16ms) | ❌ Jank — user thấy UI bị đơ/delay |
| Async operation (fetch) | ❌ UI freeze cho đến khi fetch xong |
16ms Budget — Frame Budget
Browser target 60fps = mỗi frame 16.67ms. Nếu useLayoutEffect chiếm quá 16ms → frame bị drop → user thấy jank.
Monitoring trong development:
Đo thời gian useLayoutEffect bằng performance.now() trước và sau
Nếu > 16ms → warning → cân nhắc chuyển sang useEffect + loading state6. SSR Compatibility
Vấn đề
useLayoutEffect không chạy được trên server (Next.js, SSR) vì không có DOM. React hiển thị warning khi dùng useLayoutEffect trong SSR environment.
Giải pháp: Isomorphic Layout Effect
const useIsomorphicLayoutEffect =
typeof window !== 'undefined'
? useLayoutEffect // Client: dùng useLayoutEffect
: useEffect // Server: fallback về useEffectPattern này phổ biến trong các UI libraries (Radix UI, shadcn/ui...) — khi cần useLayoutEffect nhưng phải tương thích SSR.
7. Dạng bài tập
DẠNG 1 — Tooltip/Dropdown nhảy vị trí khi mở
Nhận dạng: Element xuất hiện ở (0, 0) hoặc sai vị trí trong tích tắc rồi nhảy về đúng
Nguyên nhân: Dùng useEffect để tính position → paint trước khi có position đúng
Hướng giải: Chuyển sang useLayoutEffect — đo và tính position trước khi browser paint
DẠNG 2 — Content bị đè bởi sticky header
Nhận dạng: Nội dung bị ẩn dưới fixed header, hoặc khoảng trống quá lớn/nhỏ
Nguyên nhân: padding-top hardcode hoặc tính bằng useEffect sau paint
Hướng giải: useLayoutEffect đo header height → set padding → ResizeObserver cho dynamic header
DẠNG 3 — Flash khi load component có conditional style
Nhận dạng: Component hiển thị unstyled/wrong style trong split-second rồi đổi
Nguyên nhân: Style phụ thuộc vào DOM measurements, được set trong useEffect
Hướng giải: Chuyển sang useLayoutEffect
DẠNG 4 — Scroll position không được khôi phục
Nhận dạng: Quay lại trang, trang luôn ở đầu dù đã scroll
Hướng giải: Lưu scroll position vào sessionStorage khi rời, khôi phục trong useLayoutEffect khi quay lại
DẠNG 5 — Animation giật/jump khi bắt đầu
Nhận dạng: Animation không mượt từ đầu, element "nhảy" trước khi animate
Nguyên nhân: Dimensions chưa được đo khi animation bắt đầu
Hướng giải: useLayoutEffect đo dimensions → thiết lập CSS variables → trigger animation
DẠNG 6 — Nhầm lẫn dùng useLayoutEffect cho fetch
Nhận dạng: Data fetching trong useLayoutEffect → blank screen lâu
Nguyên nhân: Nghĩ "synchronous tốt hơn"
Hướng giải: Fetch là async, không liên quan đến DOM measurements → dùng useEffect + loading state
8. Anti-patterns cần tránh
❌ useLayoutEffect cho data fetching
Triệu chứng: UI freeze, blank screen kéo dài, trải nghiệm tệ hơn useEffect
Fix: useEffect + loading/error states
❌ useLayoutEffect cho heavy computation
Triệu chứng: Frame drop, jank, >16ms block
Fix: useEffect + loading state, hoặc Web Worker cho heavy work
❌ Dùng useLayoutEffect như "default" thay useEffect
Triệu chứng: Unnecessary performance overhead, code harder to reason about
Fix: Default useEffect, chỉ chuyển khi thực sự cần prevent flash
❌ useLayoutEffect trong SSR mà không fallback
Triệu chứng: React warning, hydration mismatch
Fix: useIsomorphicLayoutEffect pattern
❌ Heavy DOM operations trong useLayoutEffect
Triệu chứng: Jank, user thấy UI bị đơ
Fix: Chỉ dùng cho operations nhanh (<16ms), heavy ops → useEffect
9. Interview Questions
Junior Level
Q: useLayoutEffect khác useEffect như thế nào?
A: Cùng API, chỉ khác timing. useLayoutEffect chạy đồng bộ sau khi React commit DOM nhưng TRƯỚC khi browser paint. useEffect chạy SAU khi paint. useLayoutEffect block painting; useEffect không block.
Q: Khi nào dùng useLayoutEffect?
A: Khi cần đo DOM rồi update UI ngay và không muốn user thấy intermediate state — tooltip/popover positioning, scroll restoration, animation init, sticky header. Mặc định dùng useEffect.
Mid Level
Q: Giải thích "flash of unstyled content" và cách fix bằng useLayoutEffect.
A: Flash xảy ra khi: render với default state → browser paint (user thấy) → useEffect đo và update → re-paint (user thấy nhảy). Fix: useLayoutEffect chạy trước paint → đo và update → một lần paint duy nhất với state đúng. Trade-off: chỉ dùng khi flash thực sự ảnh hưởng UX vì useLayoutEffect block paint.
Q: Performance implications của useLayoutEffect?
A: Block browser painting cho đến khi xong. Nếu operation < 16ms → mượt, không jank. Nếu > 16ms → frame drop, jank, UX tệ. Heavy work (fetch, heavy computation) trong useLayoutEffect → UI freeze. Luôn keep useLayoutEffect nhanh — chỉ cho DOM measurements và nhỏ updates.
Senior Level
Q: Design tooltip system với smart positioning cho large app.
A: Các yếu tố cần xem xét: (1) Collision detection với viewport — flip strategy (bottom → top → right → left). (2) Performance — chỉ calculate khi tooltip visible, cache nếu target không thay đổi. (3) Reusability — extract thành custom hook useTooltipPosition(targetRef, options). (4) Accessibility — đảm bảo ARIA attributes, keyboard nav. (5) Cleanup — disconnect observers. Dùng useIsomorphicLayoutEffect cho SSR compatibility.
Q: Tại sao useLayoutEffect không chạy được trên server và cách xử lý?
A: SSR không có browser, không có DOM, không có painting cycle → useLayoutEffect vô nghĩa trên server. React cảnh báo nếu dùng useLayoutEffect trong SSR. Fix: useIsomorphicLayoutEffect — điều kiện runtime, client dùng useLayoutEffect, server fallback về useEffect. Được dùng rộng rãi trong UI libraries cần cả client-side positioning và SSR support.
10. War Story
Story: Dropdown Nhảy Vị Trí Trên Production
Dropdown xuất hiện ở góc trên-trái màn hình trong ~100ms mỗi khi mở. Nguyên nhân: useEffect tính position → paint trước → effect chạy sau. Fix 1 dòng: đổi useEffect → useLayoutEffect. Smooth ngay lập tức. Lesson: Visual positioning luôn cần useLayoutEffect. Nếu bạn thấy element "nhảy chỗ" khi mount → đó là dấu hiệu cần useLayoutEffect.
11. Decision Framework nhanh
Nên dùng useLayoutEffect hay useEffect?
Effect của bạn có làm gì không?
│
├── Fetch data / async operations → useEffect
├── Logging / analytics → useEffect
├── State updates không liên quan DOM → useEffect
│
└── Đo DOM rồi update UI?
├── User có thấy flash/jump với useEffect không?
│ ├── Không → useEffect (đủ dùng)
│ └── Có → useLayoutEffect ✅
│
└── Operation có mất > 16ms không?
├── Có → useEffect + loading state (tránh jank)
└── Không → useLayoutEffect an toànChecklist trước khi dùng useLayoutEffect
- [ ] Đây có phải DOM measurement → UI update không?
- [ ] useEffect có gây visual flash rõ ràng không?
- [ ] Operation có hoàn thành trong < 16ms không?
- [ ] App có SSR không? → cần
useIsomorphicLayoutEffect - [ ] Đã thêm try/catch và fallback position chưa?
Tổng hợp từ Ngày 23: useLayoutEffect — Synchronous DOM Measurements & Updates