Skip to content

📅 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:

  1. useRef có thể dùng để làm gì với DOM?

    • Access DOM nodes, call imperative methods (focus, scroll), measure dimensions
  2. useEffect chạy khi nào trong React lifecycle?

    • Sau khi React đã commit changes to DOM và browser đã paint
  3. 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:

jsx
// ❌ 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

jsx
// ✅ 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 version

Visual 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"

jsx
// ❌ 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"

jsx
// 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"

jsx
// ❌ 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 ⭐

jsx
/**
 * 🎯 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:

jsx
// ❌ 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 ⭐⭐

jsx
/**
 * 🎯 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:

jsx
// ❌ 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 ⭐⭐⭐

jsx
/**
 * 🎯 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:

jsx
// ❌ 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:

jsx
// 💡 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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íuseEffectuseLayoutEffectKhi nào dùng?
TimingAfter paintBefore paintuseLayoutEffect: 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 casesData fetching, subscriptionsDOM measurements, animations-
SSR✅ Safe⚠️ WarninguseEffect cho SSR
Browser paintHappens before effectBlocked until effect done-
Default choice✅ Yes❌ NoStart 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 flicker

Trade-offs Analysis

useLayoutEffect Pros:

jsx
✅ No visual flash
✅ Synchronous measurements
✅ Guaranteed before paint
✅ Perfect cho positioning
✅ Animation initialization

useLayoutEffect Cons:

jsx
❌ Blocks browser painting
❌ Slower initial render
❌ Can cause jank if slow
SSR warnings
❌ Overuse hurts performance

Pattern Combinations

Pattern 1: Measure then Update

jsx
// ✅ 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

jsx
// ✅ 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

jsx
// ✅ 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

jsx
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

jsx
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

jsx
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 ⭐

jsx
// ❌ 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:

  1. Tại sao đây là bad practice?
  2. Impact lên user experience?
  3. Cách fix?

💡 Giải thích:

jsx
// ❌ 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, subscriptions

Bug 2: Measuring Wrong Element ⭐⭐

jsx
// ❌ 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:

  1. Tại sao divRef.current là null?
  2. Effect chạy khi nào?
  3. Fix như thế nào?

💡 Giải thích:

jsx
// ❌ 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 elements

Bug 3: Infinite Loop với Measurements ⭐⭐⭐

jsx
// ❌ 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:

  1. Tại sao component re-render vô tận?
  2. Dependency array missing impact?
  3. Cách break loop?

💡 Giải thích:

jsx
// ❌ 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

jsx
/**
 * 🎯 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
jsx
/**
 * 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 &lt; 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

jsx
/**
 * 🎯 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
jsx
/**
 * 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

  1. React Docs - useLayoutEffect:https://react.dev/reference/react/useLayoutEffect

  2. React Docs - useEffect vs useLayoutEffect:https://react.dev/learn/synchronizing-with-effects#step-2-specify-the-effect-dependencies

Đọc thêm

  1. When to useLayoutEffect Instead of useEffect:https://kentcdodds.com/blog/useeffect-vs-uselayouteffect

  2. Understanding React useLayoutEffect:https://blog.logrocket.com/understanding-react-uselayouteffect-hook/

  3. 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

jsx
// ✅ 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

jsx
// ✅ 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

jsx
// ✅ 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:

jsx
// 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:

jsx
// 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í.

jsx
// ❌ 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:

jsx
// 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

  1. Bản chất — Khác gì useEffect?
  2. Timeline so sánh
  3. Khi nào PHẢI dùng — Khi nào KHÔNG nên
  4. Các patterns thực tế
  5. Performance Trade-offs
  6. SSR Compatibility
  7. Dạng bài tập & nhận dạng vấn đề
  8. Anti-patterns cần tránh
  9. Interview Questions — Theo level
  10. War Story
  11. Decision Framework nhanh

1. Bản chất

Điều duy nhất khác biệt: TIMING

useLayoutEffectuseEffect 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 đã paint
  • useLayoutEffect — 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ốc

2. 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 flash

Ví dụ cụ thể — Tooltip positioning

Với useEffect (có flash):

  1. Render tooltip tại (0, 0) — giá trị initial
  2. Browser paint → User thấy tooltip ở góc trên-trái ⚠️
  3. useEffect chạy → đo DOM → tính position đúng → setState
  4. Re-render → Browser paint lại → User thấy tooltip nhảy chỗ ⚠️

Với useLayoutEffect (không flash):

  1. Render tooltip tại (0, 0)
  2. useLayoutEffect chạy ngay → đo DOM → tính position đúng → setState đồng bộ
  3. Re-render với position đúng
  4. 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 sang useLayoutEffect khi 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ế:

  1. Render tooltip (ở đâu cũng được — user chưa thấy vì paint chưa chạy)
  2. useLayoutEffect: Đo target.getBoundingClientRect() + tooltip.getBoundingClientRect()
  3. Tính toán vị trí tối ưu (kiểm tra cạnh viewport — flip nếu bị cắt)
  4. setState với position mới
  5. 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:

  1. useLayoutEffect đo chiều cao header lần đầu → set padding
  2. ResizeObserver theo dõi khi header thay đổi kích thước → cập nhật padding
  3. 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ế:

  1. Render element (height: 0 hoặc hidden)
  2. useLayoutEffect: Đo element.scrollHeight
  3. Set CSS variable với giá trị đo được
  4. Trigger animation
  5. 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ốngKế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 state

6. 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ề useEffect

Pattern 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 useEffectuseLayoutEffect. 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àn

Checklist 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

Personal tech knowledge base