Skip to content

📅 NGÀY 22: useRef - DOM Manipulation & Focus Management

🎯 Mục tiêu học tập (5 phút)

Sau bài học này, bạn sẽ:

  • [ ] Hiểu cách useRef truy cập và manipulate DOM nodes trực tiếp
  • [ ] Nắm vững ref forwarding pattern và khi nào cần dùng
  • [ ] Quản lý focus, scroll, và animations với refs
  • [ ] Tích hợp third-party libraries cần DOM access
  • [ ] Phân biệt khi nào dùng refs vs state để control DOM
  • [ ] Xử lý edge cases như conditional rendering và ref timing

🤔 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 trả về object có structure như thế nào?

    • { current: value } - object với property .current
  2. Tại sao update ref.current không trigger re-render?

    • Vì React không track changes trong ref.current, chỉ track state changes
  3. Ngày hôm qua chúng ta dùng useRef cho gì?

    • Mutable values: timer IDs, previous values, flags, render counts

Hôm nay: Chúng ta sẽ học use case thứ 2 - DOM References 🎯


📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)

1.1 Vấn Đề Thực Tế

Trong React, bạn thường declarative - nói React "cái gì" cần render, không phải "làm sao" render:

jsx
// ✅ Declarative React way
function SearchBox() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder='Search...'
    />
  );
}

Nhưng đôi khi bạn cần imperative - trực tiếp control DOM:

jsx
// ❌ VẤN ĐỀ: Làm sao auto-focus input khi component mount?

function SearchBox() {
  const [query, setQuery] = useState('');

  // 🤔 Làm sao gọi input.focus()?
  // Không có cách nào access DOM element!

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder='Search...'
    />
  );
}

Các tình huống cần DOM access:

  1. Focus management - Auto-focus input, focus next field
  2. Scroll control - Scroll to element, smooth scroll
  3. Measurements - Get element width/height/position
  4. Animations - Trigger CSS animations, integrate animation libraries
  5. Third-party libs - Chart libraries, text editors, video players
  6. Media control - Play/pause video, seek audio

1.2 Giải Pháp: DOM Refs

jsx
// ✅ GIẢI PHÁP: useRef để access DOM
import { useRef, useEffect } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const inputRef = useRef(null); // 🎯 Create ref

  useEffect(() => {
    // 🎯 Access DOM node
    inputRef.current.focus();
  }, []); // Run once on mount

  return (
    <input
      ref={inputRef} // 🎯 Attach ref to element
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder='Search...'
    />
  );
}

Cách hoạt động:

1. Create ref:     const inputRef = useRef(null)

                   { current: null }

2. Attach to JSX:  <input ref={inputRef} />

                   React fills: { current: <input> DOM node }

3. Use in effect:  inputRef.current.focus()

                   Direct DOM manipulation!

1.3 Mental Model

Hãy tưởng tượng refs như "tay cầm" để nắm DOM elements:

React Component (Declarative World)

│  const inputRef = useRef(null);
│  ↓
│  ref={inputRef}

└──────────> "Tay cầm"

             │ inputRef.current

         ┌────────────────┐
         │  DOM Element   │  ← Real Browser DOM (Imperative World)
         │  <input>       │
         └────────────────┘

Timeline:

Mount phase:
──────────────────────────────────────────
1. React creates virtual DOM:  <input ref={inputRef} />
2. inputRef.current = null      (initial)
3. React creates real DOM:      <input> element
4. React assigns ref:            inputRef.current = <input>
5. useEffect runs:               inputRef.current.focus() ✅

Update phase:
──────────────────────────────────────────
1. Component re-renders
2. inputRef.current stays same  (ref object persists)
3. Still points to same DOM node

Unmount phase:
──────────────────────────────────────────
1. React removes DOM element
2. inputRef.current = null      (cleanup)

1.4 Hiểu Lầm Phổ Biến

❌ Hiểu lầm 1: "Có thể access ref.current trong render"

jsx
// ❌ SAI: Access ref trong render phase
function BadExample() {
  const divRef = useRef(null);

  // ⚠️ Lần render đầu tiên: divRef.current = null!
  console.log(divRef.current); // null → error!
  const width = divRef.current.offsetWidth; // ❌ Cannot read property of null

  return <div ref={divRef}>Content</div>;
}

// ✅ ĐÚNG: Access ref trong useEffect
function GoodExample() {
  const divRef = useRef(null);

  useEffect(() => {
    // ✅ Bây giờ divRef.current đã có giá trị
    console.log(divRef.current); // <div> element
    const width = divRef.current.offsetWidth; // ✅ Works!
  }, []);

  return <div ref={divRef}>Content</div>;
}

Tại sao?

Render phase:
  1. Function component chạy
  2. JSX được tạo (virtual DOM)
  3. ref={divRef} được note lại
  → Lúc này divRef.current vẫn là null!

Commit phase:
  1. React tạo/update real DOM
  2. React gán ref: divRef.current = DOM node
  3. useEffect chạy
  → Bây giờ mới có thể dùng!

❌ Hiểu lầm 2: "Có thể dùng ref cho conditional elements"

jsx
// ❌ VẤN ĐỀ: Element có thể không tồn tại
function BadConditional() {
  const [show, setShow] = useState(false);
  const divRef = useRef(null);

  useEffect(() => {
    divRef.current.focus(); // ⚠️ Nếu show=false → crash!
  }, []);

  return (
    <div>
      {show && (
        <div
          ref={divRef}
          tabIndex={-1}
        >
          Content
        </div>
      )}
    </div>
  );
}

// ✅ ĐÚNG: Luôn check ref.current trước khi dùng
function GoodConditional() {
  const [show, setShow] = useState(false);
  const divRef = useRef(null);

  useEffect(() => {
    if (divRef.current) {
      // ✅ Null check!
      divRef.current.focus();
    }
  }, [show]); // ✅ Re-run when show changes

  return (
    <div>
      {show && (
        <div
          ref={divRef}
          tabIndex={-1}
        >
          Content
        </div>
      )}
    </div>
  );
}

❌ Hiểu lầm 3: "Refs thay thế state cho mọi DOM manipulation"

jsx
// ❌ ANTI-PATTERN: Dùng ref thay vì state
function BadToggle() {
  const divRef = useRef(null);

  const toggle = () => {
    // ⚠️ Direct DOM manipulation - bad React pattern!
    if (divRef.current.style.display === 'none') {
      divRef.current.style.display = 'block';
    } else {
      divRef.current.style.display = 'none';
    }
  };

  return (
    <div>
      <button onClick={toggle}>Toggle</button>
      <div ref={divRef}>Content</div>
    </div>
  );
}

// ✅ ĐÚNG: Dùng state, để React quản lý
function GoodToggle() {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>Toggle</button>
      {show && <div>Content</div>}
    </div>
  );
}

Rule of thumb:

Dùng STATE khi:
✅ Control whether element renders
✅ Control element's React props
✅ Manage data that affects UI
✅ Declarative approach works

Dùng REF khi:
✅ Need imperative DOM API (focus, scroll, play)
✅ Measure DOM properties
✅ Integrate with non-React code
✅ Must escape React's declarative model

💻 PHẦN 2: LIVE CODING (45 phút)

Demo 1: Pattern Cơ Bản - Auto Focus Input ⭐

jsx
/**
 * 🎯 Mục tiêu: Focus input khi component mount
 * 💡 Pattern: useRef + useEffect for imperative DOM operations
 */

import { useState, useRef, useEffect } from 'react';

function AutoFocusInput() {
  const [value, setValue] = useState('');
  const inputRef = useRef(null);

  // ✅ Effect để focus khi mount
  useEffect(() => {
    // Focus input
    inputRef.current.focus();

    // Optional: Select all text
    inputRef.current.select();
  }, []); // Empty deps = chỉ chạy khi mount

  return (
    <div style={{ padding: '20px' }}>
      <h2>Auto Focus Demo</h2>

      <input
        ref={inputRef} // 🎯 Attach ref
        type='text'
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="I'm auto-focused!"
        style={{
          padding: '10px',
          fontSize: '16px',
          width: '300px',
          border: '2px solid #007bff',
        }}
      />

      <p>Value: {value}</p>
    </div>
  );
}

Variations:

jsx
// ✅ Pattern: Focus khi condition changes
function ConditionalFocus() {
  const [show, setShow] = useState(false);
  const inputRef = useRef(null);

  useEffect(() => {
    if (show && inputRef.current) {
      // Delay để đảm bảo element đã render
      setTimeout(() => {
        inputRef.current?.focus();
      }, 0);
    }
  }, [show]); // Re-run when show changes

  return (
    <div style={{ padding: '20px' }}>
      <button onClick={() => setShow(!show)}>
        {show ? 'Hide' : 'Show'} Input
      </button>

      {show && (
        <input
          ref={inputRef}
          type='text'
          placeholder='I appear and get focused!'
          style={{ marginTop: '10px', padding: '10px' }}
        />
      )}
    </div>
  );
}

// ✅ Pattern: Focus next input after max length
function OTPInput() {
  const [otp, setOtp] = useState(['', '', '', '']);
  const inputRefs = useRef([]); // Array of refs!

  const handleChange = (index, value) => {
    // Chỉ cho nhập 1 ký tự
    const newValue = value.slice(0, 1);

    // Update OTP
    const newOtp = [...otp];
    newOtp[index] = newValue;
    setOtp(newOtp);

    // Auto-focus next input
    if (newValue && index < 3) {
      inputRefs.current[index + 1]?.focus();
    }
  };

  const handleKeyDown = (index, e) => {
    // Backspace: focus previous
    if (e.key === 'Backspace' && !otp[index] && index > 0) {
      inputRefs.current[index - 1]?.focus();
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>OTP Input</h2>
      <div style={{ display: 'flex', gap: '10px' }}>
        {otp.map((digit, index) => (
          <input
            key={index}
            ref={(el) => (inputRefs.current[index] = el)} // 🎯 Store ref in array
            type='text'
            inputMode='numeric'
            value={digit}
            onChange={(e) => handleChange(index, e.target.value)}
            onKeyDown={(e) => handleKeyDown(index, e)}
            style={{
              width: '50px',
              height: '50px',
              fontSize: '24px',
              textAlign: 'center',
              border: '2px solid #ccc',
              borderRadius: '4px',
            }}
          />
        ))}
      </div>
      <p>OTP: {otp.join('')}</p>
    </div>
  );
}

Demo 2: Kịch Bản Thực Tế - Scroll to Element ⭐⭐

jsx
/**
 * 🎯 Use case: Scroll to section, smooth scroll, scroll to bottom
 * 💼 Real-world: Chat apps, long forms, documentation pages
 */

import { useState, useRef, useEffect } from 'react';

function ScrollToDemo() {
  const [messages, setMessages] = useState([
    { id: 1, text: 'Hello!' },
    { id: 2, text: 'How are you?' },
    { id: 3, text: 'I am good, thanks!' },
  ]);

  const messagesEndRef = useRef(null);
  const containerRef = useRef(null);

  // ✅ Pattern 1: Scroll to bottom on new message
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth',
    });
  };

  useEffect(() => {
    scrollToBottom();
  }, [messages]); // Scroll when messages change

  // ✅ Pattern 2: Scroll to specific message
  const scrollToMessage = (id) => {
    const element = document.getElementById(`message-${id}`);
    element?.scrollIntoView({
      behavior: 'smooth',
      block: 'center', // Scroll to center of viewport
    });
  };

  // ✅ Pattern 3: Check if scrolled to bottom
  const [isAtBottom, setIsAtBottom] = useState(true);

  const handleScroll = () => {
    if (!containerRef.current) return;

    const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
    const atBottom = scrollHeight - scrollTop - clientHeight < 10; // 10px threshold
    setIsAtBottom(atBottom);
  };

  const addMessage = () => {
    const newMessage = {
      id: messages.length + 1,
      text: `Message ${messages.length + 1}`,
    };
    setMessages([...messages, newMessage]);
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>Scroll to Demo</h2>

      {/* Controls */}
      <div style={{ marginBottom: '10px', display: 'flex', gap: '10px' }}>
        <button onClick={addMessage}>Add Message</button>
        <button onClick={scrollToBottom}>Scroll to Bottom</button>
        <button onClick={() => scrollToMessage(1)}>Go to Message 1</button>
      </div>

      {/* Scroll indicator */}
      {!isAtBottom && (
        <div
          style={{
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            backgroundColor: '#007bff',
            color: 'white',
            padding: '10px 15px',
            borderRadius: '20px',
            cursor: 'pointer',
            boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
          }}
          onClick={scrollToBottom}
        >
          ↓ New messages
        </div>
      )}

      {/* Messages container */}
      <div
        ref={containerRef}
        onScroll={handleScroll}
        style={{
          height: '300px',
          overflowY: 'auto',
          border: '2px solid #ccc',
          borderRadius: '8px',
          padding: '10px',
          backgroundColor: '#f9f9f9',
        }}
      >
        {messages.map((message) => (
          <div
            key={message.id}
            id={`message-${message.id}`}
            style={{
              padding: '10px',
              marginBottom: '10px',
              backgroundColor: 'white',
              borderRadius: '4px',
              border: '1px solid #ddd',
            }}
          >
            <strong>Message #{message.id}:</strong> {message.text}
          </div>
        ))}

        {/* Invisible element at bottom */}
        <div ref={messagesEndRef} />
      </div>
    </div>
  );
}

Key Patterns:

jsx
// Pattern 1: Scroll to bottom (chat apps)
const endRef = useRef(null);
useEffect(() => {
  endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

// Pattern 2: Scroll to specific element
const scrollToId = (id) => {
  document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
};

// Pattern 3: Detect scroll position
const handleScroll = (e) => {
  const { scrollTop, scrollHeight, clientHeight } = e.target;
  const isAtBottom = scrollHeight - scrollTop === clientHeight;
  // Do something based on position
};

// Pattern 4: Programmatic scroll
const containerRef = useRef(null);
containerRef.current?.scrollTo({
  top: 0,
  behavior: 'smooth',
});

Demo 3: Edge Cases - Video Player Control ⭐⭐⭐

jsx
/**
 * 🎯 Use case: Control video playback imperatively
 * ⚠️ Edge cases:
 *    - Video not loaded yet
 *    - Multiple play/pause calls
 *    - Cleanup on unmount
 *    - Browser autoplay policies
 */

import { useState, useRef, useEffect } from 'react';

function VideoPlayer() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolume] = useState(1);

  const videoRef = useRef(null);
  const progressIntervalRef = useRef(null);

  // ✅ Pattern: Play/Pause with error handling
  const togglePlay = async () => {
    if (!videoRef.current) return;

    try {
      if (isPlaying) {
        videoRef.current.pause();
        setIsPlaying(false);
      } else {
        // ⚠️ play() returns a promise - can fail!
        await videoRef.current.play();
        setIsPlaying(true);
      }
    } catch (error) {
      console.error('Playback failed:', error);
      // ⚠️ Autoplay might be blocked by browser
      if (error.name === 'NotAllowedError') {
        alert('Autoplay blocked. Click play button to start.');
      }
    }
  };

  // ✅ Pattern: Seek to position
  const handleSeek = (e) => {
    if (!videoRef.current) return;

    const rect = e.currentTarget.getBoundingClientRect();
    const clickX = e.clientX - rect.left;
    const percentage = clickX / rect.width;

    const newTime = percentage * duration;
    videoRef.current.currentTime = Math.min(
      Math.max(0, newTime),
      videoRef.current.duration,
    );
    setCurrentTime(videoRef.current.currentTime);
  };

  // ✅ Pattern: Volume control
  const handleVolumeChange = (e) => {
    const newVolume = parseFloat(e.target.value);
    setVolume(newVolume);

    if (videoRef.current) {
      videoRef.current.volume = newVolume;
    }
  };

  // ✅ Pattern: Track progress
  useEffect(() => {
    if (isPlaying) {
      progressIntervalRef.current = setInterval(() => {
        if (videoRef.current) {
          setCurrentTime(videoRef.current.currentTime);
        }
      }, 100); // Update every 100ms
    } else {
      if (progressIntervalRef.current) {
        clearInterval(progressIntervalRef.current);
      }
    }

    return () => {
      if (progressIntervalRef.current) {
        clearInterval(progressIntervalRef.current);
      }
    };
  }, [isPlaying]);

  // ✅ Pattern: Handle video events
  const handleLoadedMetadata = () => {
    if (videoRef.current) {
      setDuration(videoRef.current.duration);
    }
  };

  const handleEnded = () => {
    setIsPlaying(false);
    setCurrentTime(0);
    if (videoRef.current) {
      videoRef.current.currentTime = 0;
    }
  };

  // ✅ Pattern: Cleanup on unmount
  useEffect(() => {
    return () => {
      // Pause video when component unmounts
      if (videoRef.current) {
        videoRef.current.pause();
      }
    };
  }, []);

  // Format time helper
  const formatTime = (seconds) => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h2>Custom Video Player</h2>

      {/* Video element */}
      <video
        ref={videoRef}
        onLoadedMetadata={handleLoadedMetadata}
        onEnded={handleEnded}
        style={{
          width: '100%',
          borderRadius: '8px',
          backgroundColor: '#000',
        }}
      >
        <source
          src='https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
          type='video/mp4'
        />
        Your browser does not support video.
      </video>

      {/* Custom controls */}
      <div style={{ marginTop: '15px' }}>
        {/* Progress bar */}
        <div
          onClick={handleSeek}
          style={{
            width: '100%',
            height: '8px',
            backgroundColor: '#ddd',
            borderRadius: '4px',
            cursor: 'pointer',
            position: 'relative',
            marginBottom: '15px',
          }}
        >
          <div
            style={{
              width: `${(currentTime / duration) * 100}%`,
              height: '100%',
              backgroundColor: '#007bff',
              borderRadius: '4px',
              transition: 'width 0.1s',
            }}
          />
        </div>

        {/* Time display */}
        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            marginBottom: '15px',
            fontSize: '14px',
            color: '#666',
          }}
        >
          <span>{formatTime(currentTime)}</span>
          <span>{formatTime(duration)}</span>
        </div>

        {/* Control buttons */}
        <div
          style={{
            display: 'flex',
            gap: '10px',
            alignItems: 'center',
          }}
        >
          <button
            onClick={togglePlay}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              backgroundColor: '#007bff',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            {isPlaying ? '⏸ Pause' : '▶ Play'}
          </button>

          <button
            onClick={() => {
              if (videoRef.current) {
                videoRef.current.currentTime = 0;
                setCurrentTime(0);
              }
            }}
            style={{
              padding: '10px 20px',
              fontSize: '16px',
              backgroundColor: '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            ⏮ Reset
          </button>

          {/* Volume control */}
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: '10px',
              marginLeft: 'auto',
            }}
          >
            <span>🔊</span>
            <input
              type='range'
              min='0'
              max='1'
              step='0.1'
              value={volume}
              onChange={handleVolumeChange}
              style={{ width: '100px' }}
            />
            <span>{Math.round(volume * 100)}%</span>
          </div>
        </div>
      </div>

      {/* Debug info */}
      <details style={{ marginTop: '20px', fontSize: '12px' }}>
        <summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
          Debug Info
        </summary>
        <div
          style={{
            marginTop: '10px',
            padding: '10px',
            backgroundColor: '#f8f9fa',
            borderRadius: '4px',
            fontFamily: 'monospace',
          }}
        >
          <p>Is Playing: {isPlaying ? 'Yes' : 'No'}</p>
          <p>Current Time: {currentTime.toFixed(2)}s</p>
          <p>Duration: {duration.toFixed(2)}s</p>
          <p>Volume: {volume}</p>
          <p>Video Ready: {videoRef.current ? 'Yes' : 'No'}</p>
        </div>
      </details>
    </div>
  );
}

Edge Cases Handled:

jsx
// ⚠️ Edge Case 1: Video not loaded
const play = async () => {
  if (!videoRef.current) {
    console.warn('Video ref not ready');
    return; // ✅ Early return
  }
  await videoRef.current.play();
};

// ⚠️ Edge Case 2: Autoplay blocked
try {
  await videoRef.current.play();
} catch (error) {
  if (error.name === 'NotAllowedError') {
    // ✅ Handle browser autoplay policy
    alert('Click to play');
  }
}

// ⚠️ Edge Case 3: Seeking beyond duration
const seek = (time) => {
  if (videoRef.current) {
    // ✅ Clamp to valid range
    videoRef.current.currentTime = Math.min(
      Math.max(0, time),
      videoRef.current.duration,
    );
  }
};

// ⚠️ Edge Case 4: Component unmount during playback
useEffect(() => {
  return () => {
    if (videoRef.current) {
      videoRef.current.pause(); // ✅ Cleanup
    }
  };
}, []);

🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)

⭐ Exercise 1: Click Outside Detector (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Detect clicks outside element (dropdown, modal)
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: useReducer, Context, custom hooks
 *
 * Requirements:
 * 1. Dropdown mở khi click button
 * 2. Dropdown đóng khi click outside
 * 3. Không đóng khi click inside dropdown
 * 4. Cleanup event listener properly
 *
 * 💡 Gợi ý:
 * - useRef để reference dropdown element
 * - useEffect để add/remove document click listener
 * - event.target để check click location
 */

// ❌ Cách SAI: Không dùng ref, logic phức tạp
function BadDropdown() {
  const [isOpen, setIsOpen] = useState(false);

  // ⚠️ Không reliable, có thể click vào child elements
  const handleDocumentClick = (e) => {
    if (e.target.className !== 'dropdown') {
      setIsOpen(false);
    }
  };

  useEffect(() => {
    document.addEventListener('click', handleDocumentClick);
    return () => document.removeEventListener('click', handleDocumentClick);
  }, []);

  return (
    <div className='dropdown'>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && <div>Dropdown Content</div>}
    </div>
  );
}

// ✅ Cách ĐÚNG: Dùng ref với contains()
function GoodDropdown() {
  // TODO: Implement using useRef
  // Step 1: Create ref for dropdown container
  // Step 2: useEffect với document click listener
  // Step 3: Check if click is outside using ref.current.contains()
  // Step 4: Close dropdown if outside
  // Step 5: Cleanup listener
}

// 🎯 NHIỆM VỤ CỦA BẠN:
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  // TODO: const dropdownRef = useRef(null);

  // TODO: Implement click outside detection
  useEffect(() => {
    // const handleClickOutside = (event) => {
    //   if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
    //     setIsOpen(false);
    //   }
    // };
    // if (isOpen) {
    //   document.addEventListener('mousedown', handleClickOutside);
    // }
    // return () => {
    //   document.removeEventListener('mousedown', handleClickOutside);
    // };
  }, [isOpen]);

  return (
    <div style={{ padding: '20px' }}>
      <h2>Click Outside Detector</h2>

      {/* TODO: Add ref to container */}
      <div style={{ position: 'relative', display: 'inline-block' }}>
        <button
          onClick={() => setIsOpen(!isOpen)}
          style={{
            padding: '10px 20px',
            fontSize: '16px',
            backgroundColor: '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          {isOpen ? 'Close' : 'Open'} Dropdown
        </button>

        {isOpen && (
          <div
            style={{
              position: 'absolute',
              top: '100%',
              left: 0,
              marginTop: '5px',
              padding: '15px',
              backgroundColor: 'white',
              border: '2px solid #007bff',
              borderRadius: '4px',
              boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
              minWidth: '200px',
              zIndex: 1000,
            }}
          >
            <p>This is dropdown content</p>
            <button style={{ padding: '5px 10px' }}>Action 1</button>
            <button style={{ padding: '5px 10px', marginLeft: '5px' }}>
              Action 2
            </button>
          </div>
        )}
      </div>

      <div
        style={{
          marginTop: '20px',
          padding: '20px',
          backgroundColor: '#f0f0f0',
        }}
      >
        <p>Click anywhere in this gray area to test</p>
        <p>Dropdown should close when you click here</p>
      </div>
    </div>
  );
}

// ✅ Expected behavior:
// 1. Click button → dropdown opens
// 2. Click inside dropdown → stays open
// 3. Click outside dropdown → closes
// 4. Click button again → toggles
💡 Solution
jsx
/**
 * Dropdown component with click-outside-to-close functionality
 * Uses useRef + useEffect to detect clicks outside the dropdown container
 */
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useEffect(() => {
    const handleClickOutside = (event) => {
      // Chỉ xử lý khi dropdown đang mở
      if (
        isOpen &&
        dropdownRef.current &&
        !dropdownRef.current.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };

    // Sử dụng 'mousedown' thay vì 'click' để bắt sự kiện sớm hơn
    document.addEventListener('mousedown', handleClickOutside);

    // Cleanup
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]); // Dependency: isOpen → chỉ re-attach khi trạng thái mở thay đổi

  return (
    <div style={{ padding: '20px' }}>
      <h2>Click Outside Detector</h2>

      <div
        style={{ position: 'relative', display: 'inline-block' }}
        ref={dropdownRef}
      >
        <button
          onClick={() => setIsOpen(!isOpen)}
          style={{
            padding: '10px 20px',
            fontSize: '16px',
            backgroundColor: '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          {isOpen ? 'Close' : 'Open'} Dropdown
        </button>

        {isOpen && (
          <div
            style={{
              position: 'absolute',
              top: '100%',
              left: 0,
              marginTop: '5px',
              padding: '15px',
              backgroundColor: 'white',
              border: '2px solid #007bff',
              borderRadius: '4px',
              boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
              minWidth: '200px',
              zIndex: 1000,
            }}
          >
            <p>This is dropdown content</p>
            <button style={{ padding: '5px 10px' }}>Action 1</button>
            <button style={{ padding: '5px 10px', marginLeft: '5px' }}>
              Action 2
            </button>
          </div>
        )}
      </div>

      <div
        style={{
          marginTop: '20px',
          padding: '20px',
          backgroundColor: '#f0f0f0',
        }}
      >
        <p>Click anywhere in this gray area to test</p>
        <p>Dropdown should close when you click here</p>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi:
- Click nút → dropdown mở
- Click bên trong dropdown (kể cả nút Action 1/2) → vẫn mở
- Click bất kỳ đâu bên ngoài vùng dropdown → đóng
- Click lại nút khi đang mở → đóng (toggle behavior)
*/

⭐⭐ Exercise 2: Image Zoom Viewer (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Measure và manipulate DOM để create image zoom
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario:
 * E-commerce product image viewer - hover to zoom.
 * Cần calculate mouse position và adjust image transform.
 *
 * 🤔 PHÂN TÍCH:
 *
 * Approach A: Pure CSS (transform-origin)
 * Pros: Simple, performant
 * Cons: Limited control, fixed zoom ratio
 *
 * Approach B: JS với refs (calculate và set style)
 * Pros: Full control, custom zoom logic
 * Cons: More complex, need refs
 *
 * 💭 Chọn Approach B để thực hành refs!
 *
 * Requirements:
 * 1. Image zoom on hover
 * 2. Zoom follows mouse position
 * 3. Smooth zoom in/out
 * 4. Reset on mouse leave
 */

import { useState, useRef } from 'react';

function ImageZoomViewer() {
  const [isZoomed, setIsZoomed] = useState(false);
  const imageRef = useRef(null);
  const containerRef = useRef(null);

  // TODO: Implement zoom logic
  const handleMouseMove = (e) => {
    if (!imageRef.current || !containerRef.current) return;

    // Get container bounds
    // const rect = containerRef.current.getBoundingClientRect();

    // Calculate mouse position relative to container (0-1)
    // const x = (e.clientX - rect.left) / rect.width;
    // const y = (e.clientY - rect.top) / rect.height;

    // Set transform origin based on mouse position
    // imageRef.current.style.transformOrigin = `${x * 100}% ${y * 100}%`;
  };

  const handleMouseEnter = () => {
    setIsZoomed(true);
  };

  const handleMouseLeave = () => {
    setIsZoomed(false);
    // TODO: Reset transform origin
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>Image Zoom Viewer</h2>

      <div
        ref={containerRef}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onMouseMove={handleMouseMove}
        style={{
          width: '400px',
          height: '400px',
          border: '2px solid #ccc',
          borderRadius: '8px',
          overflow: 'hidden',
          cursor: isZoomed ? 'zoom-in' : 'default',
          position: 'relative',
        }}
      >
        <img
          ref={imageRef}
          src='https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=800'
          alt='Product'
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            transform: isZoomed ? 'scale(2)' : 'scale(1)',
            transition: 'transform 0.3s ease',
            transformOrigin: 'center center',
          }}
        />
      </div>

      <p style={{ marginTop: '10px', color: '#666' }}>
        Hover over image to zoom. Move mouse to pan.
      </p>
    </div>
  );
}

// 🎯 Expected behavior:
// - Hover → image zooms 2x
// - Move mouse → zoom follows cursor
// - Leave → zoom resets smoothly
💡 Solution
jsx
/**
 * Image Zoom Viewer - Hover to zoom with mouse-following pan effect
 * Uses useRef to access container and image DOM nodes for dynamic transform-origin
 */
function ImageZoomViewer() {
  const [isZoomed, setIsZoomed] = useState(false);
  const imageRef = useRef(null);
  const containerRef = useRef(null);

  const handleMouseMove = (e) => {
    if (!isZoomed || !containerRef.current || !imageRef.current) return;

    const rect = containerRef.current.getBoundingClientRect();

    // Tính vị trí chuột tương đối trong container (0 → 1)
    const x = (e.clientX - rect.left) / rect.width;
    const y = (e.clientY - rect.top) / rect.height;

    // Giới hạn trong khoảng 0-100%
    const originX = Math.max(0, Math.min(100, x * 100));
    const originY = Math.max(0, Math.min(100, y * 100));

    // Cập nhật transform-origin để zoom tập trung vào vị trí chuột
    imageRef.current.style.transformOrigin = `${originX}% ${originY}%`;
  };

  const handleMouseEnter = () => {
    setIsZoomed(true);
  };

  const handleMouseLeave = () => {
    setIsZoomed(false);
    if (imageRef.current) {
      // Reset về giữa khi rời chuột
      imageRef.current.style.transformOrigin = 'center center';
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>Image Zoom Viewer</h2>

      <div
        ref={containerRef}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onMouseMove={handleMouseMove}
        style={{
          width: '400px',
          height: '400px',
          border: '2px solid #ccc',
          borderRadius: '8px',
          overflow: 'hidden',
          cursor: isZoomed ? 'zoom-in' : 'default',
          position: 'relative',
          backgroundColor: '#f8f9fa',
        }}
      >
        <img
          ref={imageRef}
          src='https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=800'
          alt='Product'
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            transform: isZoomed ? 'scale(2)' : 'scale(1)',
            transition: 'transform 0.3s ease',
            transformOrigin: 'center center',
            willChange: 'transform', // Cải thiện hiệu suất animation
          }}
        />
      </div>

      <p style={{ marginTop: '10px', color: '#666' }}>
        Hover over image to zoom ×2. Move mouse to look around.
      </p>
    </div>
  );
}

/*
Kết quả mong đợi:
- Ban đầu: hình ảnh hiển thị bình thường (scale 1)
- Di chuột vào vùng ảnh → zoom lên gấp 2 lần (scale 2)
- Di chuyển chuột → điểm zoom (transform-origin) theo vị trí chuột
- Rời chuột khỏi vùng → ảnh trở về scale 1, origin về giữa
- Hiệu ứng mượt mà nhờ transition và willChange
*/

⭐⭐⭐ Exercise 3: Infinite Scroll List (40 phút)

jsx
/**
 * 🎯 Mục tiêu: Implement infinite scroll với Intersection Observer
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là user, tôi muốn scroll và load more items tự động"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Load 20 items initially
 * - [ ] Detect when scrolled near bottom
 * - [ ] Load 20 more items automatically
 * - [ ] Show loading indicator
 * - [ ] Handle "no more items" state
 * - [ ] Smooth scroll experience
 *
 * 🎨 Technical Constraints:
 * - Dùng Intersection Observer API
 * - useRef cho observer và sentinel element
 * - Mock API với setTimeout
 *
 * 🚨 Edge Cases cần handle:
 * - Loading already in progress (prevent duplicate requests)
 * - Scroll too fast (debounce/throttle)
 * - Component unmount during loading
 * - No more items to load
 *
 * 📝 Implementation Checklist:
 * - [ ] Create sentinel element at list bottom
 * - [ ] Setup Intersection Observer
 * - [ ] Load more when sentinel visible
 * - [ ] Cleanup observer on unmount
 * - [ ] Loading states
 * - [ ] Empty/end states
 */

import { useState, useRef, useEffect, useCallback } from 'react';

function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);

  // Refs
  const observerRef = useRef(null);
  const sentinelRef = useRef(null);
  const loadingRef = useRef(false); // Prevent duplicate loads

  // Mock API
  const fetchItems = async (pageNum) => {
    // Simulate API delay
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // Generate mock items
    const newItems = Array.from({ length: 20 }, (_, i) => ({
      id: (pageNum - 1) * 20 + i + 1,
      title: `Item ${(pageNum - 1) * 20 + i + 1}`,
      description: `Description for item ${(pageNum - 1) * 20 + i + 1}`,
    }));

    // Simulate "no more items" after page 5
    return {
      items: newItems,
      hasMore: pageNum < 5,
    };
  };

  // Load more items
  const loadMore = async () => {
    // TODO: Implement
    // 1. Check if already loading (loadingRef)
    // 2. Check if has more items
    // 3. Set loading states
    // 4. Fetch items
    // 5. Update items array
    // 6. Update page number
    // 7. Update hasMore
    // 8. Reset loading state
  };

  // Setup Intersection Observer
  useEffect(() => {
    // TODO: Implement
    // 1. Create IntersectionObserver
    // 2. Observe sentinel element
    // 3. When intersecting → loadMore()
    // 4. Cleanup observer on unmount
    // const options = {
    //   root: null, // viewport
    //   rootMargin: '100px', // Load before reaching bottom
    //   threshold: 0.1
    // };
    // observerRef.current = new IntersectionObserver((entries) => {
    //   const [entry] = entries;
    //   if (entry.isIntersecting && !loadingRef.current && hasMore) {
    //     loadMore();
    //   }
    // }, options);
    // if (sentinelRef.current) {
    //   observerRef.current.observe(sentinelRef.current);
    // }
    // return () => {
    //   if (observerRef.current) {
    //     observerRef.current.disconnect();
    //   }
    // };
  }, [loadMore, hasMore]);

  // Initial load
  useEffect(() => {
    loadMore();
  }, []); // Load once on mount

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h2>Infinite Scroll List</h2>

      {/* Items list */}
      <div style={{ marginTop: '20px' }}>
        {items.map((item) => (
          <div
            key={item.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              backgroundColor: 'white',
              border: '1px solid #ddd',
              borderRadius: '4px',
            }}
          >
            <h3 style={{ margin: '0 0 5px 0' }}>{item.title}</h3>
            <p style={{ margin: 0, color: '#666', fontSize: '14px' }}>
              {item.description}
            </p>
          </div>
        ))}
      </div>

      {/* Loading indicator */}
      {loading && (
        <div
          style={{
            padding: '20px',
            textAlign: 'center',
            color: '#007bff',
          }}
        >
          Loading more items...
        </div>
      )}

      {/* Sentinel element - invisible trigger */}
      {hasMore && (
        <div
          ref={sentinelRef}
          style={{
            height: '20px',
            margin: '10px 0',
          }}
        />
      )}

      {/* End message */}
      {!hasMore && (
        <div
          style={{
            padding: '20px',
            textAlign: 'center',
            color: '#999',
            borderTop: '2px solid #eee',
          }}
        >
          🎉 You've reached the end!
        </div>
      )}

      {/* Debug info */}
      <div
        style={{
          position: 'fixed',
          bottom: '20px',
          right: '20px',
          padding: '10px',
          backgroundColor: 'rgba(0,0,0,0.8)',
          color: 'white',
          borderRadius: '4px',
          fontSize: '12px',
          fontFamily: 'monospace',
        }}
      >
        <div>Items: {items.length}</div>
        <div>Page: {page}</div>
        <div>Loading: {loading ? 'Yes' : 'No'}</div>
        <div>Has More: {hasMore ? 'Yes' : 'No'}</div>
      </div>
    </div>
  );
}

// 🎯 Expected behavior:
// 1. Loads 20 items initially
// 2. Scroll down → auto-loads more when near bottom
// 3. Shows loading indicator during fetch
// 4. Stops at page 5 with "end" message
// 5. No duplicate requests
💡 Solution
jsx
/**
 * Infinite Scroll List using Intersection Observer + useRef
 * Loads more items automatically when nearing the bottom
 */
function InfiniteScrollList() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);

  // Refs
  const observerRef = useRef(null);
  const sentinelRef = useRef(null);
  const loadingRef = useRef(false); // Prevent duplicate loads

  // Mock API simulation
  const fetchItems = async (pageNum) => {
    await new Promise((resolve) => setTimeout(resolve, 1200)); // Simulate network delay

    const newItems = Array.from({ length: 20 }, (_, i) => ({
      id: (pageNum - 1) * 20 + i + 1,
      title: `Item ${(pageNum - 1) * 20 + i + 1}`,
      description: `This is description for item number ${(pageNum - 1) * 20 + i + 1}`,
    }));

    // Simulate end of content after page 5
    const hasMoreItems = pageNum < 5;

    return { items: newItems, hasMore: hasMoreItems };
  };

  // Load more function - memoized with useCallback
  const loadMore = async () => {
    // Prevent concurrent loads
    if (loadingRef.current || !hasMore) return;

    loadingRef.current = true;
    setLoading(true);

    try {
      const { items: newItems, hasMore: moreAvailable } =
        await fetchItems(page);

      setItems((prev) => [...prev, ...newItems]);
      setPage((prev) => prev + 1);
      setHasMore(moreAvailable);
    } catch (err) {
      console.error('Failed to load items:', err);
      setHasMore(false);
    } finally {
      setLoading(false);
      loadingRef.current = false;
    }
  };

  // Setup Intersection Observer
  useEffect(() => {
    if (!hasMore || loading) return;

    const options = {
      root: null, // viewport
      rootMargin: '200px', // trigger 200px before reaching sentinel
      threshold: 0.1,
    };

    observerRef.current = new IntersectionObserver((entries) => {
      const [entry] = entries;
      if (entry.isIntersecting && !loadingRef.current) {
        loadMore();
      }
    }, options);

    if (sentinelRef.current) {
      observerRef.current.observe(sentinelRef.current);
    }

    // Cleanup
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, [loadMore, hasMore, loading]);

  // Initial load
  useEffect(() => {
    loadMore();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h2>Infinite Scroll List</h2>

      {/* Items list */}
      <div style={{ marginTop: '20px' }}>
        {items.map((item) => (
          <div
            key={item.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              backgroundColor: 'white',
              border: '1px solid #ddd',
              borderRadius: '4px',
              boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
            }}
          >
            <h3 style={{ margin: '0 0 8px 0' }}>{item.title}</h3>
            <p style={{ margin: 0, color: '#555', fontSize: '14px' }}>
              {item.description}
            </p>
          </div>
        ))}
      </div>

      {/* Loading indicator */}
      {loading && (
        <div
          style={{
            padding: '30px',
            textAlign: 'center',
            color: '#007bff',
            fontSize: '16px',
          }}
        >
          Loading more items...
        </div>
      )}

      {/* Sentinel element - triggers load more when visible */}
      {hasMore && !loading && (
        <div
          ref={sentinelRef}
          style={{
            height: '40px',
            margin: '20px 0',
          }}
        />
      )}

      {/* End of content message */}
      {!hasMore && items.length > 0 && (
        <div
          style={{
            padding: '30px',
            textAlign: 'center',
            color: '#6c757d',
            fontSize: '18px',
            borderTop: '2px solid #eee',
            marginTop: '20px',
          }}
        >
          🎉 You've reached the end of the list!
        </div>
      )}

      {/* Debug info (optional) */}
      <div
        style={{
          position: 'fixed',
          bottom: '20px',
          right: '20px',
          padding: '10px 14px',
          backgroundColor: 'rgba(0,0,0,0.7)',
          color: 'white',
          borderRadius: '6px',
          fontSize: '12px',
          fontFamily: 'monospace',
        }}
      >
        Items: {items.length} | Page: {page} | Loading: {loading ? 'Yes' : 'No'}
      </div>
    </div>
  );
}

/*
Kết quả mong đợi:
- Trang tải 20 items đầu tiên ngay khi mount
- Khi scroll gần đáy (cách ~200px), tự động load thêm 20 items
- Hiển thị "Loading more items..." trong lúc chờ
- Sau khi load trang 5 → hiển thị thông báo "You've reached the end"
- Không load trùng lặp (nhờ loadingRef)
- Observer tự động cleanup khi component unmount
*/

⭐⭐⭐⭐ Exercise 4: Resizable Split Pane (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Build resizable panels với drag handle
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Nhiệm vụ:
 * 1. So sánh approaches:
 *    A. CSS Flexbox với dynamic flex values
 *    B. Absolute positioning với width calculations
 *    C. CSS Grid với template columns
 * 2. Document pros/cons
 * 3. Chọn approach
 * 4. Viết ADR
 *
 * Requirements:
 * - Two panels side by side
 * - Draggable divider in middle
 * - Min/max width constraints
 * - Smooth resize
 * - Save sizes to localStorage
 *
 * 💻 PHASE 2: Implementation (30 phút)
 * 🧪 PHASE 3: Testing (10 phút)
 */

import { useState, useRef, useEffect } from 'react';

function ResizableSplitPane({
  leftContent,
  rightContent,
  initialLeftWidth = 50, // percentage
  minWidth = 20,
  maxWidth = 80,
}) {
  const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
  const [isDragging, setIsDragging] = useState(false);

  const containerRef = useRef(null);
  const startXRef = useRef(0);
  const startWidthRef = useRef(0);

  // TODO: Implement drag logic
  const handleMouseDown = (e) => {
    // Start drag
    // Save initial mouse X and current width
    // Set dragging state
  };

  useEffect(() => {
    if (!isDragging) return;

    const handleMouseMove = (e) => {
      // TODO: Calculate new width
      // Get container width
      // Calculate delta X
      // Calculate new width percentage
      // Clamp between min/max
      // Update state
    };

    const handleMouseUp = () => {
      // TODO: End drag
      // Save to localStorage
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);

    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [isDragging, minWidth, maxWidth]);

  // Load from localStorage
  useEffect(() => {
    const saved = localStorage.getItem('splitPaneWidth');
    if (saved) {
      setLeftWidth(Number(saved));
    }
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h2>Resizable Split Pane</h2>

      <div
        ref={containerRef}
        style={{
          display: 'flex',
          height: '400px',
          border: '2px solid #ccc',
          borderRadius: '8px',
          overflow: 'hidden',
          userSelect: isDragging ? 'none' : 'auto',
          cursor: isDragging ? 'col-resize' : 'default',
        }}
      >
        {/* Left panel */}
        <div
          style={{
            width: `${leftWidth}%`,
            backgroundColor: '#f0f8ff',
            padding: '20px',
            overflowY: 'auto',
          }}
        >
          {leftContent || (
            <div>
              <h3>Left Panel</h3>
              <p>Width: {leftWidth.toFixed(1)}%</p>
              <p>Drag the divider to resize</p>
            </div>
          )}
        </div>

        {/* Divider */}
        <div
          onMouseDown={handleMouseDown}
          style={{
            width: '4px',
            backgroundColor: isDragging ? '#007bff' : '#ccc',
            cursor: 'col-resize',
            transition: isDragging ? 'none' : 'background-color 0.2s',
            position: 'relative',
          }}
        >
          {/* Drag handle visual */}
          <div
            style={{
              position: 'absolute',
              top: '50%',
              left: '50%',
              transform: 'translate(-50%, -50%)',
              width: '20px',
              height: '40px',
              backgroundColor: isDragging ? '#007bff' : '#999',
              borderRadius: '4px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: 'white',
              fontSize: '12px',
            }}
          >

          </div>
        </div>

        {/* Right panel */}
        <div
          style={{
            flex: 1,
            backgroundColor: '#fff5f5',
            padding: '20px',
            overflowY: 'auto',
          }}
        >
          {rightContent || (
            <div>
              <h3>Right Panel</h3>
              <p>Width: {(100 - leftWidth).toFixed(1)}%</p>
              <p>This panel takes remaining space</p>
            </div>
          )}
        </div>
      </div>

      {/* Controls */}
      <div style={{ marginTop: '15px', display: 'flex', gap: '10px' }}>
        <button onClick={() => setLeftWidth(50)}>Reset (50/50)</button>
        <button onClick={() => setLeftWidth(minWidth)}>Min Left</button>
        <button onClick={() => setLeftWidth(maxWidth)}>Max Left</button>
      </div>
    </div>
  );
}

// 🧪 PHASE 3: Testing Checklist
// - [ ] Drag works smoothly
// - [ ] Width clamped to min/max
// - [ ] Cursor changes on hover/drag
// - [ ] Saves to localStorage
// - [ ] Loads from localStorage on mount
// - [ ] No text selection during drag
// - [ ] Works with different content
💡 Solution
jsx
/**
 * Resizable Split Pane with drag-to-resize functionality
 * Uses useRef for container reference and mouse event tracking
 * Persists split position in localStorage
 */
function ResizableSplitPane({
  leftContent,
  rightContent,
  initialLeftWidth = 50, // percentage
  minWidth = 20,
  maxWidth = 80,
}) {
  const [leftWidth, setLeftWidth] = useState(() => {
    // Load from localStorage if available
    const saved = localStorage.getItem('splitPaneWidth');
    return saved ? Number(saved) : initialLeftWidth;
  });

  const [isDragging, setIsDragging] = useState(false);
  const containerRef = useRef(null);
  const startXRef = useRef(0);
  const startWidthRef = useRef(0);

  const handleMouseDown = (e) => {
    e.preventDefault();
    setIsDragging(true);

    startXRef.current = e.clientX;
    startWidthRef.current = leftWidth;

    // Prevent text selection during drag
    document.body.style.userSelect = 'none';
  };

  useEffect(() => {
    if (!isDragging) return;

    const handleMouseMove = (e) => {
      if (!containerRef.current) return;

      const containerRect = containerRef.current.getBoundingClientRect();
      const deltaX = e.clientX - startXRef.current;
      const deltaPercent = (deltaX / containerRect.width) * 100;

      const newWidth = startWidthRef.current + deltaPercent;

      // Clamp between min and max
      const clampedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));

      setLeftWidth(clampedWidth);
    };

    const handleMouseUp = () => {
      setIsDragging(false);

      // Save to localStorage
      localStorage.setItem('splitPaneWidth', leftWidth.toFixed(1));

      // Restore text selection
      document.body.style.userSelect = '';
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);

    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
      document.body.style.userSelect = '';
    };
  }, [isDragging, leftWidth, minWidth, maxWidth]);

  return (
    <div style={{ padding: '20px' }}>
      <h2>Resizable Split Pane</h2>

      <div
        ref={containerRef}
        style={{
          display: 'flex',
          height: '400px',
          border: '2px solid #ccc',
          borderRadius: '8px',
          overflow: 'hidden',
          userSelect: isDragging ? 'none' : 'auto',
          cursor: isDragging ? 'col-resize' : 'default',
        }}
      >
        {/* Left panel */}
        <div
          style={{
            width: `${leftWidth}%`,
            backgroundColor: '#f0f8ff',
            padding: '20px',
            overflowY: 'auto',
            transition: isDragging ? 'none' : 'width 0.1s ease',
          }}
        >
          {leftContent || (
            <div>
              <h3>Left Panel</h3>
              <p>Width: {leftWidth.toFixed(1)}%</p>
              <p>Drag the middle bar to resize</p>
              <p>
                Current width is preserved across page reloads via localStorage
              </p>
            </div>
          )}
        </div>

        {/* Draggable divider */}
        <div
          onMouseDown={handleMouseDown}
          style={{
            width: '8px',
            backgroundColor: isDragging ? '#007bff' : '#ccc',
            cursor: 'col-resize',
            transition: isDragging ? 'none' : 'background-color 0.2s',
            position: 'relative',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >
          <div
            style={{
              width: '24px',
              height: '48px',
              backgroundColor: isDragging ? '#0056b3' : '#999',
              borderRadius: '4px',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              color: 'white',
              fontSize: '14px',
              fontWeight: 'bold',
            }}
          >

          </div>
        </div>

        {/* Right panel - takes remaining space */}
        <div
          style={{
            flex: 1,
            backgroundColor: '#fff5f5',
            padding: '20px',
            overflowY: 'auto',
          }}
        >
          {rightContent || (
            <div>
              <h3>Right Panel</h3>
              <p>Width: {(100 - leftWidth).toFixed(1)}%</p>
              <p>This panel automatically fills the remaining space</p>
            </div>
          )}
        </div>
      </div>

      {/* Quick controls */}
      <div style={{ marginTop: '15px', display: 'flex', gap: '10px' }}>
        <button
          onClick={() => {
            setLeftWidth(50);
            localStorage.setItem('splitPaneWidth', '50');
          }}
          style={{ padding: '8px 16px' }}
        >
          Reset 50/50
        </button>
        <button
          onClick={() => {
            setLeftWidth(minWidth);
            localStorage.setItem('splitPaneWidth', minWidth.toString());
          }}
          style={{ padding: '8px 16px' }}
        >
          Min Left ({minWidth}%)
        </button>
        <button
          onClick={() => {
            setLeftWidth(maxWidth);
            localStorage.setItem('splitPaneWidth', maxWidth.toString());
          }}
          style={{ padding: '8px 16px' }}
        >
          Max Left ({maxWidth}%)
        </button>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi:
- Ban đầu chia 50/50 (hoặc giá trị đã lưu trước đó)
- Kéo thanh giữa → thay đổi kích thước hai panel mượt mà
- Giới hạn minWidth (20%) và maxWidth (80%)
- Khi thả chuột → lưu vị trí vào localStorage
- Tải lại trang → khôi phục đúng tỷ lệ đã lưu
- Các nút Reset/Min/Max hoạt động và cũng lưu lại
- Không chọn text khi đang kéo (user-select: none)
*/

⭐⭐⭐⭐⭐ Exercise 5: Rich Text Editor Integration (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Integrate third-party editor (Quill) với React
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 *
 * Build rich text editor component với:
 * 1. Quill editor integration
 * 2. Controlled value (sync with React state)
 * 3. Custom toolbar
 * 4. Character counter
 * 5. Auto-save draft
 * 6. Read-only mode
 * 7. HTML export
 * 8. Image upload handling
 *
 * 🏗️ Technical Design Doc:
 *
 * 1. Component Architecture:
 *    - useRef for editor container
 *    - useRef for Quill instance
 *    - useState for content
 *    - useEffect for initialization
 *
 * 2. Integration Strategy:
 *    - Initialize Quill in useEffect
 *    - Sync changes back to React state
 *    - Handle cleanup on unmount
 *    - Prevent double initialization
 *
 * 3. Challenges:
 *    - Quill mutates DOM directly (not React way)
 *    - Need to sync external state
 *    - Event listeners management
 *    - Preventing memory leaks
 *
 * ✅ Production Checklist:
 * - [ ] Proper Quill initialization
 * - [ ] Controlled component pattern
 * - [ ] Event listener cleanup
 * - [ ] No double initialization
 * - [ ] Loading state handling
 * - [ ] Error boundaries
 * - [ ] TypeScript types (JSDoc)
 * - [ ] Accessibility (ARIA labels)
 * - [ ] Mobile responsive toolbar
 */

import { useState, useRef, useEffect } from 'react';

/**
 * Rich Text Editor using Quill
 *
 * Note: In real app, install Quill:
 * npm install quill
 * import Quill from 'quill';
 * import 'quill/dist/quill.snow.css';
 */

function RichTextEditor({
  initialValue = '',
  onChange,
  placeholder = 'Start typing...',
  readOnly = false,
  maxLength = 5000,
}) {
  const [content, setContent] = useState(initialValue);
  const [charCount, setCharCount] = useState(0);

  // Refs
  const editorRef = useRef(null);
  const quillRef = useRef(null);
  const isInitializedRef = useRef(false);

  // Initialize Quill
  useEffect(() => {
    if (isInitializedRef.current) return;
    if (!editorRef.current) return;

    // TODO: In real implementation
    // const quill = new Quill(editorRef.current, {
    //   theme: 'snow',
    //   placeholder,
    //   readOnly,
    //   modules: {
    //     toolbar: [
    //       ['bold', 'italic', 'underline'],
    //       ['link', 'image'],
    //       [{ list: 'ordered' }, { list: 'bullet' }],
    //       ['clean']
    //     ]
    //   }
    // });

    // Set initial content
    // if (initialValue) {
    //   quill.clipboard.dangerouslyPasteHTML(initialValue);
    // }

    // Listen to changes
    // quill.on('text-change', () => {
    //   const html = quill.root.innerHTML;
    //   const text = quill.getText();
    //
    //   setContent(html);
    //   setCharCount(text.trim().length);
    //   onChange?.(html);
    //
    //   // Enforce max length
    //   if (text.length > maxLength) {
    //     quill.deleteText(maxLength, text.length);
    //   }
    // });

    // quillRef.current = quill;
    isInitializedRef.current = true;

    // Cleanup
    return () => {
      // quillRef.current = null;
    };
  }, []);

  // Update readOnly mode
  useEffect(() => {
    if (quillRef.current) {
      // quillRef.current.enable(!readOnly);
    }
  }, [readOnly]);

  // Export functions
  const getHTML = () => {
    return quillRef.current?.root.innerHTML || '';
  };

  const getText = () => {
    return quillRef.current?.getText() || '';
  };

  const clear = () => {
    quillRef.current?.setText('');
    setContent('');
    setCharCount(0);
  };

  const insertText = (text) => {
    const range = quillRef.current?.getSelection();
    if (range) {
      quillRef.current?.insertText(range.index, text);
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h2>Rich Text Editor</h2>

      {/* Toolbar info */}
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: '10px',
          padding: '10px',
          backgroundColor: '#f8f9fa',
          borderRadius: '4px',
        }}
      >
        <div style={{ fontSize: '14px', color: '#666' }}>
          Characters: {charCount} / {maxLength}
        </div>
        <div style={{ display: 'flex', gap: '5px' }}>
          <button
            onClick={clear}
            style={{
              padding: '5px 10px',
              fontSize: '12px',
              backgroundColor: '#dc3545',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Clear
          </button>
          <button
            onClick={() => insertText('Hello World')}
            style={{
              padding: '5px 10px',
              fontSize: '12px',
              backgroundColor: '#28a745',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Insert Text
          </button>
        </div>
      </div>

      {/* Editor container */}
      <div
        ref={editorRef}
        style={{
          minHeight: '300px',
          backgroundColor: 'white',
          border: '1px solid #ccc',
          borderRadius: '4px',
        }}
      />

      {/* Preview */}
      <details style={{ marginTop: '20px' }}>
        <summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
          HTML Preview
        </summary>
        <pre
          style={{
            marginTop: '10px',
            padding: '15px',
            backgroundColor: '#f8f9fa',
            borderRadius: '4px',
            overflow: 'auto',
            fontSize: '12px',
          }}
        >
          {content || '<p><br></p>'}
        </pre>
      </details>

      {/* Instructions */}
      <div
        style={{
          marginTop: '20px',
          padding: '15px',
          backgroundColor: '#e7f3ff',
          border: '1px solid #007bff',
          borderRadius: '4px',
          fontSize: '14px',
        }}
      >
        <p>
          <strong>📝 Instructions:</strong>
        </p>
        <p>This is a placeholder for Quill integration.</p>
        <p>In a real implementation:</p>
        <ul>
          <li>
            Install: <code>npm install quill</code>
          </li>
          <li>Import Quill and CSS</li>
          <li>Uncomment initialization code in useEffect</li>
          <li>Handle image uploads with custom handler</li>
          <li>Implement auto-save with debouncing</li>
        </ul>
      </div>
    </div>
  );
}

// 📝 Implementation Notes:
//
// Real Quill integration pattern:
//
// 1. Installation:
//    npm install quill
//    npm install @types/quill --save-dev
//
// 2. Import:
//    import Quill from 'quill';
//    import 'quill/dist/quill.snow.css';
//
// 3. Custom image handler:
//    const imageHandler = () => {
//      const input = document.createElement('input');
//      input.setAttribute('type', 'file');
//      input.setAttribute('accept', 'image/*');
//      input.click();
//
//      input.onchange = async () => {
//        const file = input.files[0];
//        const url = await uploadImage(file);
//        const range = quill.getSelection();
//        quill.insertEmbed(range.index, 'image', url);
//      };
//    };
//
// 4. Toolbar config:
//    modules: {
//      toolbar: {
//        container: [
//          [{ header: [1, 2, 3, false] }],
//          ['bold', 'italic', 'underline', 'strike'],
//          [{ color: [] }, { background: [] }],
//          [{ list: 'ordered' }, { list: 'bullet' }],
//          ['link', 'image', 'video'],
//          ['clean']
//        ],
//        handlers: {
//          image: imageHandler
//        }
//      }
//    }
//
// 5. Auto-save pattern:
//    const autoSaveRef = useRef(null);
//
//    quill.on('text-change', () => {
//      clearTimeout(autoSaveRef.current);
//      autoSaveRef.current = setTimeout(() => {
//        saveContent(quill.root.innerHTML);
//      }, 2000);
//    });
💡 Solution
jsx
/**
 * Rich Text Editor using Quill (placeholder implementation)
 * Demonstrates proper useRef pattern for third-party library integration
 * Features: controlled value, character counter, clear/insert controls
 *
 * Note: This is a complete placeholder version showing the React + Quill integration pattern.
 * In a real project, you would:
 * 1. npm install quill
 * 2. import Quill from 'quill';
 * 3. import 'quill/dist/quill.snow.css';
 * and uncomment the Quill initialization code.
 */
function RichTextEditor({
  initialValue = '',
  onChange,
  placeholder = 'Start typing here...',
  readOnly = false,
  maxLength = 5000,
}) {
  const [content, setContent] = useState(initialValue);
  const [charCount, setCharCount] = useState(initialValue.length);

  const editorRef = useRef(null);
  const quillRef = useRef(null);
  const isInitializedRef = useRef(false);

  // Initialize Quill (placeholder - real implementation would use Quill library)
  useEffect(() => {
    if (isInitializedRef.current || !editorRef.current) return;

    // ────────────────────────────────────────────────
    // Real Quill initialization (commented out - enable when Quill is installed)
    /*
    const quill = new Quill(editorRef.current, {
      theme: 'snow',
      placeholder,
      readOnly,
      modules: {
        toolbar: [
          ['bold', 'italic', 'underline', 'strike'],
          ['link', 'image'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          ['clean']
        ]
      }
    });

    // Set initial content safely
    if (initialValue) {
      quill.clipboard.dangerouslyPasteHTML(initialValue);
    }

    // Listen to text changes
    quill.on('text-change', () => {
      const html = quill.root.innerHTML;
      const text = quill.getText().trim();
      
      setContent(html);
      setCharCount(text.length);
      onChange?.(html);

      // Enforce max length
      if (text.length > maxLength) {
        quill.deleteText(maxLength, text.length - maxLength);
      }
    });

    quillRef.current = quill;
    */

    isInitializedRef.current = true;

    // Cleanup (would destroy Quill instance)
    return () => {
      // if (quillRef.current) {
      //   quillRef.current = null;
      // }
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Sync readOnly prop changes
  useEffect(() => {
    // if (quillRef.current) {
    //   quillRef.current.enable(!readOnly);
    // }
  }, [readOnly]);

  // Public API methods
  const getHTML = () => {
    // return quillRef.current?.root.innerHTML || '';
    return content;
  };

  const getText = () => {
    // return quillRef.current?.getText().trim() || '';
    return content.replace(/<[^>]+>/g, '').trim();
  };

  const clear = () => {
    // quillRef.current?.setText('');
    setContent('');
    setCharCount(0);
    onChange?.('');
  };

  const insertText = (text) => {
    // const range = quillRef.current?.getSelection();
    // if (range) {
    //   quillRef.current?.insertText(range.index, text);
    // }
    setContent((prev) => prev + text);
    setCharCount((prev) => prev + text.length);
    onChange?.(content + text);
  };

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h2>Rich Text Editor (Quill Placeholder)</h2>

      {/* Toolbar / Controls */}
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          marginBottom: '12px',
          padding: '10px',
          backgroundColor: '#f8f9fa',
          borderRadius: '6px',
          fontSize: '14px',
        }}
      >
        <div style={{ color: charCount > maxLength ? '#dc3545' : '#666' }}>
          Characters: {charCount} / {maxLength}
        </div>

        <div style={{ display: 'flex', gap: '8px' }}>
          <button
            onClick={clear}
            style={{
              padding: '6px 14px',
              backgroundColor: '#dc3545',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Clear
          </button>
          <button
            onClick={() => insertText('Hello World! ')}
            style={{
              padding: '6px 14px',
              backgroundColor: '#28a745',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            Insert "Hello World!"
          </button>
        </div>
      </div>

      {/* Editor container */}
      <div
        ref={editorRef}
        style={{
          minHeight: '320px',
          backgroundColor: 'white',
          border: '1px solid #ced4da',
          borderRadius: '6px',
          padding: '12px',
          fontFamily: 'inherit',
          fontSize: '16px',
        }}
      >
        {/* Quill would render here */}
        {/* Placeholder content when Quill is not loaded */}
        <p style={{ color: '#6c757d', margin: 0 }}>
          {content ||
            'Rich text editor would appear here when Quill is integrated...'}
        </p>
      </div>

      {/* HTML Preview */}
      <details style={{ marginTop: '20px' }}>
        <summary
          style={{ cursor: 'pointer', fontWeight: 'bold', color: '#007bff' }}
        >
          View HTML Output
        </summary>
        <pre
          style={{
            marginTop: '12px',
            padding: '16px',
            backgroundColor: '#f8f9fa',
            borderRadius: '6px',
            overflowX: 'auto',
            fontSize: '13px',
            whiteSpace: 'pre-wrap',
            wordBreak: 'break-all',
          }}
        >
          {content || '<p><br></p>'}
        </pre>
      </details>

      {/* Integration reminder */}
      <div
        style={{
          marginTop: '24px',
          padding: '16px',
          backgroundColor: '#e7f3ff',
          border: '1px solid #b3d4fc',
          borderRadius: '6px',
          fontSize: '14px',
        }}
      >
        <strong>Next steps to make it real:</strong>
        <ul style={{ margin: '12px 0 0 20px', paddingLeft: 0 }}>
          <li>npm install quill</li>
          <li>Import Quill and CSS</li>
          <li>Uncomment Quill initialization code in useEffect</li>
          <li>Add custom image upload handler if needed</li>
          <li>Implement auto-save with debounce</li>
        </ul>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi (với placeholder):
- Hiển thị giao diện editor với controls Clear & Insert
- Character counter cập nhật khi insert text
- Clear button xóa nội dung
- HTML preview hiển thị nội dung hiện tại
- Khi tích hợp Quill thật → editor sẽ trở thành rich text đầy đủ tính năng
- Ref được sử dụng đúng cách: chỉ khởi tạo 1 lần, cleanup đúng, không double-init
*/

📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)

Bảng So Sánh: Khi nào dùng Refs vs State cho DOM

ScenariouseStateuseRefLý do
Show/Hide element✅ Recommended❌ Anti-patternState triggers re-render → React updates DOM correctly
Toggle CSS class✅ Recommended❌ AvoidConditional className based on state is declarative
Focus input❌ Cannot do✅ RequiredDOM method .focus() cần imperative access
Scroll to position❌ Cannot do✅ Required.scrollTo() là DOM API
Play/Pause video❌ Cannot do✅ RequiredMedia APIs cần DOM reference
Measure element size❌ Cannot do✅ Required.offsetWidth etc. là DOM properties
Animate with CSS✅ Recommended❌ AvoidCSS transitions với state changes
Animate with JS library❌ Insufficient✅ RequiredLibraries like GSAP cần DOM nodes
Form validation✅ Recommended⚠️ SometimesState cho messages, ref cho native validation
Third-party lib❌ Cannot do✅ RequiredCharts, editors cần DOM mount point

Decision Tree: Ref vs State

                Cần thay đổi DOM?

        ┌──────────────┴──────────────┐
        │                             │
       Có                           Không
        │                             │
  React có prop/state           (không cần gì)
  hỗ trợ không?

   ┌────┴────┐
   │         │
  Có       Không
   │         │
useState   useRef
   │         │
Examples:   Examples:
- show     - focus()
- disabled - play()
- value    - scrollTo()
- checked  - getBoundingClientRect()

Pattern Combinations

Pattern 1: State + Ref cho Controlled Focus

jsx
// ✅ GOOD: Combine state và ref
function SearchWithAutocomplete() {
  const [query, setQuery] = useState(''); // UI state
  const [suggestions, setSuggestions] = useState([]);
  const [selectedIndex, setSelectedIndex] = useState(0);

  const inputRef = useRef(null); // DOM access
  const listRef = useRef(null);

  // State controls UI
  useEffect(() => {
    if (query.length > 2) {
      fetchSuggestions(query).then(setSuggestions);
    } else {
      setSuggestions([]);
    }
  }, [query]);

  // Ref controls focus
  const selectSuggestion = (suggestion) => {
    setQuery(suggestion);
    setSuggestions([]);
    inputRef.current?.focus(); // ✅ Ref for imperative focus
  };

  return (
    <div>
      <input
        ref={inputRef}
        value={query} // ✅ State for value
        onChange={(e) => setQuery(e.target.value)}
      />
      {suggestions.length > 0 && (
        <ul ref={listRef}>
          {suggestions.map((s, i) => (
            <li
              key={i}
              onClick={() => selectSuggestion(s)}
              style={{
                backgroundColor: i === selectedIndex ? '#e0e0e0' : 'white',
              }}
            >
              {s}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Pattern 2: Callback Refs cho Dynamic Elements

jsx
// ✅ GOOD: Callback ref khi element có thể thay đổi
function DynamicList({ items }) {
  const [selectedId, setSelectedId] = useState(null);
  const elementRefs = useRef({});

  // Callback ref pattern
  const setElementRef = (id) => (element) => {
    if (element) {
      elementRefs.current[id] = element;
    } else {
      delete elementRefs.current[id];
    }
  };

  const scrollToItem = (id) => {
    elementRefs.current[id]?.scrollIntoView({
      behavior: 'smooth',
      block: 'center',
    });
  };

  return (
    <div>
      {items.map((item) => (
        <div
          key={item.id}
          ref={setElementRef(item.id)} // ✅ Callback ref
          onClick={() => {
            setSelectedId(item.id);
            scrollToItem(item.id);
          }}
        >
          {item.name}
        </div>
      ))}
    </div>
  );
}

Pattern 3: Forward Refs cho Reusable Components

jsx
// ✅ PATTERN: forwardRef để expose DOM ref
import { forwardRef, useRef } from 'react';

const FancyInput = forwardRef((props, ref) => {
  return (
    <input
      ref={ref} // ✅ Forward ref to DOM element
      {...props}
      style={{
        padding: '10px',
        border: '2px solid blue',
        borderRadius: '4px',
      }}
    />
  );
});

// Usage
function Parent() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <FancyInput
        ref={inputRef}
        placeholder='Type here'
      />
      <button onClick={focusInput}>Focus Input</button>
    </div>
  );
}

Chú ý về forwardRef:

jsx
// ❌ KHÔNG thể ref component thường
function MyInput(props) {
  return <input {...props} />;
}

function Parent() {
  const ref = useRef();
  return <MyInput ref={ref} />; // ⚠️ Error: Function components cannot be given refs
}

// ✅ PHẢI dùng forwardRef
const MyInput = forwardRef((props, ref) => {
  return (
    <input
      ref={ref}
      {...props}
    />
  );
});

function Parent() {
  const ref = useRef();
  return <MyInput ref={ref} />; // ✅ Works!
}

Anti-patterns to Avoid

jsx
// ❌ ANTI-PATTERN 1: Dùng ref thay vì state
function BadToggle() {
  const divRef = useRef(null);

  const toggle = () => {
    const current = divRef.current.style.display;
    divRef.current.style.display = current === 'none' ? 'block' : 'none';
  };

  return (
    <div>
      <button onClick={toggle}>Toggle</button>
      <div ref={divRef}>Content</div>
    </div>
  );
}

// ❌ ANTI-PATTERN 2: Access ref trong render
function BadRender() {
  const divRef = useRef(null);
  const width = divRef.current?.offsetWidth; // ⚠️ null on first render!

  return <div ref={divRef}>Width: {width}</div>;
}

// ❌ ANTI-PATTERN 3: Modify ref.current trong render
function BadModify() {
  const countRef = useRef(0);
  countRef.current += 1; // ⚠️ Side effect in render!

  return <div>Count: {countRef.current}</div>;
}

// ❌ ANTI-PATTERN 4: Không cleanup listeners
function BadListener() {
  const divRef = useRef(null);

  useEffect(() => {
    divRef.current.addEventListener('click', handler);
    // ⚠️ No cleanup!
  }, []);

  return <div ref={divRef}>Click me</div>;
}

🧪 PHẦN 5: DEBUG LAB (20 phút)

Bug 1: Ref is null on First Render ⭐

jsx
// ❌ BUG: Trying to use ref before it's set
function BuggyAutoFocus() {
  const inputRef = useRef(null);

  // ⚠️ Chạy trong render phase - ref chưa được set!
  inputRef.current.focus(); // ❌ TypeError: Cannot read property 'focus' of null

  return <input ref={inputRef} />;
}

🔍 Debug Questions:

  1. Tại sao inputRef.current là null?
  2. Khi nào React set giá trị cho ref?
  3. Làm sao fix?

💡 Giải thích:

jsx
// Timeline:
// 1. Component function runs (render phase)
//    → inputRef.current = null
// 2. JSX created with ref={inputRef}
// 3. React commits to DOM
//    → Creates <input> element
//    → Sets inputRef.current = <input>
// 4. useEffect runs
//    → NOW ref.current has value!

// ✅ SOLUTION: Dùng useEffect
function FixedAutoFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus(); // ✅ Safe - runs after commit
  }, []);

  return <input ref={inputRef} />;
}

// ✅ ALTERNATIVE: Callback ref
function AlternativeAutoFocus() {
  const setInputRef = (element) => {
    if (element) {
      element.focus(); // ✅ Called when element mounts
    }
  };

  return <input ref={setInputRef} />;
}

Bug 2: Stale Ref in Event Handler ⭐⭐

jsx
// ❌ BUG: Ref not updating in scroll handler
function BuggyScrollTracker() {
  const [items, setItems] = useState([1, 2, 3]);
  const listRef = useRef(null);

  useEffect(() => {
    const handleScroll = () => {
      console.log('Items count:', items.length); // ⚠️ Always logs 3!
      // Even after adding items, shows stale count
    };

    listRef.current.addEventListener('scroll', handleScroll);

    return () => {
      listRef.current?.removeEventListener('scroll', handleScroll);
    };
  }, []); // ⚠️ Missing dependency: items

  const addItem = () => {
    setItems([...items, items.length + 1]);
  };

  return (
    <div>
      <button onClick={addItem}>Add Item</button>
      <div
        ref={listRef}
        style={{ height: '200px', overflow: 'auto' }}
      >
        {items.map((i) => (
          <div key={i}>Item {i}</div>
        ))}
      </div>
    </div>
  );
}

🔍 Debug Questions:

  1. Tại sao items.length luôn là 3?
  2. Đâu là vấn đề với dependencies?
  3. Cách fix mà không re-attach listener mỗi lần items thay đổi?

💡 Giải thích:

jsx
// ❌ VẤN ĐỀ: Closure captures initial items value
// useEffect runs once, handleScroll captures items = [1,2,3]
// Even when items changes, old handler still has old value

// ✅ SOLUTION 1: Add items to dependencies (not ideal)
useEffect(() => {
  const handleScroll = () => {
    console.log('Items:', items.length); // ✅ Updated
  };

  listRef.current.addEventListener('scroll', handleScroll);

  return () => {
    listRef.current?.removeEventListener('scroll', handleScroll);
  };
}, [items]); // ⚠️ Re-attaches listener every time items change!

// ✅ SOLUTION 2: Use ref to store latest items
function FixedScrollTracker() {
  const [items, setItems] = useState([1, 2, 3]);
  const listRef = useRef(null);
  const itemsRef = useRef(items);

  // Keep ref in sync
  useEffect(() => {
    itemsRef.current = items;
  }, [items]);

  useEffect(() => {
    const handleScroll = () => {
      console.log('Items:', itemsRef.current.length); // ✅ Always current!
    };

    listRef.current.addEventListener('scroll', handleScroll);

    return () => {
      listRef.current?.removeEventListener('scroll', handleScroll);
    };
  }, []); // ✅ Only attach once, but handler has latest value

  const addItem = () => {
    setItems([...items, items.length + 1]);
  };

  return (
    <div>
      <button onClick={addItem}>Add Item</button>
      <div
        ref={listRef}
        style={{ height: '200px', overflow: 'auto' }}
      >
        {items.map((i) => (
          <div
            key={i}
            style={{ height: '50px' }}
          >
            Item {i}
          </div>
        ))}
      </div>
    </div>
  );
}

Bug 3: Memory Leak from Intersection Observer ⭐⭐⭐

jsx
// ❌ BUG: Observer không được cleanup
function BuggyLazyImage({ src }) {
  const imgRef = useRef(null);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          setIsLoaded(true);
          // ⚠️ Không disconnect observer!
        }
      });
    });

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    // ⚠️ No cleanup!
  }, []);

  return (
    <img
      ref={imgRef}
      src={isLoaded ? src : 'placeholder.jpg'}
      alt='Lazy loaded'
    />
  );
}

🔍 Debug Questions:

  1. Điều gì xảy ra khi component unmount?
  2. Observer có bị cleanup không?
  3. Làm sao test memory leak?

💡 Giải thích:

jsx
// ❌ VẤN ĐỀ:
// 1. Observer được tạo và observe element
// 2. Component unmount
// 3. Observer vẫn tồn tại → references old DOM node
// 4. Memory leak!

// ✅ SOLUTION: Proper cleanup
function FixedLazyImage({ src }) {
  const imgRef = useRef(null);
  const observerRef = useRef(null); // Store observer in ref
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    // Create observer
    observerRef.current = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && !isLoaded) {
          setIsLoaded(true);

          // ✅ Disconnect after loading (optional optimization)
          if (observerRef.current && imgRef.current) {
            observerRef.current.unobserve(imgRef.current);
          }
        }
      });
    });

    // Start observing
    if (imgRef.current) {
      observerRef.current.observe(imgRef.current);
    }

    // ✅ Cleanup
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect(); // Disconnect all observations
        observerRef.current = null;
      }
    };
  }, []); // Empty deps - setup once

  return (
    <img
      ref={imgRef}
      src={
        isLoaded
          ? src
          : 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>'
      }
      alt='Lazy loaded'
      style={{
        minHeight: '200px',
        backgroundColor: '#f0f0f0',
      }}
    />
  );
}

// 🔍 Test for memory leaks:
function TestHarness() {
  const [show, setShow] = useState(true);
  const [count, setCount] = useState(0);

  return (
    <div>
      <button
        onClick={() => {
          setShow(false);
          setTimeout(() => setShow(true), 100);
          setCount((c) => c + 1);
        }}
      >
        Remount ({count} times)
      </button>

      <p>Open DevTools → Memory → Take snapshots</p>
      <p>Click button 10 times, take snapshot</p>
      <p>Check for detached DOM nodes</p>

      {show && <FixedLazyImage src='https://via.placeholder.com/400' />}
    </div>
  );
}

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

  • [ ] Làm sao attach ref vào DOM element?
  • [ ] Khi nào React set giá trị cho ref.current?
  • [ ] Tại sao không thể access ref.current trong render phase?
  • [ ] forwardRef là gì và khi nào cần dùng?
  • [ ] Callback ref pattern hoạt động như thế nào?
  • [ ] Sự khác biệt giữa ref object và callback ref?
  • [ ] Làm sao cleanup third-party library instances?
  • [ ] Intersection Observer cần cleanup như thế nào?
  • [ ] Khi nào dùng ref vs state để control DOM?
  • [ ] Làm sao handle conditional refs (element có thể không tồn tại)?

Code Review Checklist

Khi review code có DOM refs, check:

✅ Correct Usage:

  • [ ] Refs only accessed in useEffect/event handlers (not render)
  • [ ] Null checks before using ref.current
  • [ ] forwardRef used when exposing refs from components
  • [ ] Refs used for imperative operations only
  • [ ] State used for declarative UI control

✅ Cleanup:

  • [ ] Event listeners removed on unmount
  • [ ] Intersection/Mutation/Resize Observers disconnected
  • [ ] Third-party library instances destroyed
  • [ ] Refs set to null in cleanup (if needed)

✅ Edge Cases:

  • [ ] Conditional rendering handled (element may not exist)
  • [ ] Multiple refs managed correctly (arrays/maps)
  • [ ] Ref timing considered (useEffect vs useLayoutEffect)
  • [ ] Browser API availability checked (if needed)

✅ Performance:

  • [ ] No unnecessary re-attachments of listeners
  • [ ] Latest values accessed via refs in long-lived closures
  • [ ] Heavy operations debounced/throttled
  • [ ] Memory leaks prevented

❌ Common Mistakes:

  • [ ] Không access ref trong render
  • [ ] Không skip null checks
  • [ ] Không dùng ref thay vì state cho UI
  • [ ] Không cleanup observers/listeners

🏠 BÀI TẬP VỀ NHÀ

Bắt buộc (30 phút)

Exercise: Custom useClickOutside Hook

jsx
/**
 * 🎯 Mục tiêu: Tạo reusable hook cho click outside detection
 *
 * Requirements:
 * 1. Hook nhận ref và callback
 * 2. Call callback khi click outside element
 * 3. Proper cleanup
 * 4. Handle multiple refs (optional)
 *
 * API:
 * useClickOutside(ref, () => console.log('Clicked outside'));
 */

function useClickOutside(ref, handler) {
  // TODO: Implement
  // Hints:
  // - useEffect để add document listener
  // - Check ref.current.contains(event.target)
  // - Call handler if outside
  // - Cleanup listener
  // - Consider using useCallback for handler?
}

// Usage example:
function DropdownWithHook() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useClickOutside(dropdownRef, () => {
    setIsOpen(false);
  });

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(true)}>Open</button>
      {isOpen && <div>Dropdown content</div>}
    </div>
  );
}
💡 Solution
jsx
/**
 * Custom hook to detect clicks outside a specified element
 * Useful for closing modals, dropdowns, popovers, etc.
 *
 * @param {React.RefObject} ref - React ref attached to the target element
 * @param {Function} handler - Callback function to call when click is outside
 * @param {string} [mouseEvent='mousedown'] - Event type to listen for
 */
function useClickOutside(ref, handler, mouseEvent = 'mousedown') {
  useEffect(() => {
    const listener = (event) => {
      // Do nothing if clicking ref's element or children
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }

      handler(event);
    };

    document.addEventListener(mouseEvent, listener);

    // Optional: also listen to touch events for mobile
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener(mouseEvent, listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler, mouseEvent]); // Re-run if any of these change
}

/**
 * Example usage: Dropdown that closes when clicking outside
 */
function DropdownWithHook() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useClickOutside(dropdownRef, () => {
    setIsOpen(false);
  });

  return (
    <div style={{ padding: '20px' }}>
      <h2>Dropdown with useClickOutside Hook</h2>

      <div
        ref={dropdownRef}
        style={{ position: 'relative', display: 'inline-block' }}
      >
        <button
          onClick={() => setIsOpen(!isOpen)}
          style={{
            padding: '10px 20px',
            fontSize: '16px',
            backgroundColor: '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          {isOpen ? 'Close Dropdown' : 'Open Dropdown'}
        </button>

        {isOpen && (
          <div
            style={{
              position: 'absolute',
              top: '100%',
              left: 0,
              marginTop: '8px',
              padding: '16px',
              backgroundColor: 'white',
              border: '1px solid #ccc',
              borderRadius: '6px',
              boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
              minWidth: '220px',
              zIndex: 1000,
            }}
          >
            <p style={{ margin: '0 0 12px 0' }}>Dropdown content</p>
            <button style={{ marginRight: '8px' }}>Option 1</button>
            <button>Option 2</button>
          </div>
        )}
      </div>

      <div
        style={{
          marginTop: '40px',
          padding: '20px',
          backgroundColor: '#f8f9fa',
          borderRadius: '6px',
        }}
      >
        <p>Click anywhere outside the dropdown to close it automatically.</p>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi:
- Click nút → dropdown mở/đóng (toggle)
- Click bên trong dropdown (bao gồm các nút Option) → không đóng
- Click bất kỳ đâu bên ngoài vùng dropdown → tự động đóng
- Hook cleanup đúng cách khi component unmount
- Hoạt động tốt trên cả desktop (mousedown) và mobile (touchstart)
*/

Nâng cao (60 phút)

Exercise: Custom useElementSize Hook

jsx
/**
 * 🎯 Mục tiêu: Track element size với ResizeObserver
 *
 * Scenario:
 * Build responsive component cần biết kích thước thực tế của element.
 *
 * Requirements:
 * 1. Return ref và size { width, height }
 * 2. Update size khi element resize
 * 3. Use ResizeObserver API
 * 4. Debounce updates (optional)
 * 5. Cleanup properly
 *
 * API:
 * const [ref, size] = useElementSize();
 * <div ref={ref}>Size: {size.width}x{size.height}</div>
 */

function useElementSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const ref = useRef(null);
  const observerRef = useRef(null);

  useEffect(() => {
    if (!ref.current) return;

    // TODO: Implement ResizeObserver
    // observerRef.current = new ResizeObserver((entries) => {
    //   const entry = entries[0];
    //   if (entry) {
    //     setSize({
    //       width: entry.contentRect.width,
    //       height: entry.contentRect.height
    //     });
    //   }
    // });

    // observerRef.current.observe(ref.current);

    // return () => {
    //   if (observerRef.current) {
    //     observerRef.current.disconnect();
    //   }
    // };
  }, []);

  return [ref, size];
}

// Usage:
function ResponsiveCard() {
  const [ref, size] = useElementSize();

  return (
    <div
      ref={ref}
      style={{ resize: 'both', overflow: 'auto', border: '1px solid' }}
    >
      <p>Width: {size.width}px</p>
      <p>Height: {size.height}px</p>
      <p>Try resizing this box!</p>
    </div>
  );
}
💡 Solution
jsx
/**
 * Custom hook to track the size of a DOM element using ResizeObserver
 * Returns a ref to attach to the element and the current dimensions
 */
function useElementSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const ref = useRef(null);
  const observerRef = useRef(null);

  useEffect(() => {
    if (!ref.current) return;

    // Create ResizeObserver only once
    observerRef.current = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (entry) {
        const { width, height } = entry.contentRect;
        setSize({ width, height });
      }
    });

    // Start observing the element
    observerRef.current.observe(ref.current);

    // Initial size (in case element already has size before observer)
    const { width, height } = ref.current.getBoundingClientRect();
    setSize({ width, height });

    // Cleanup
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
        observerRef.current = null;
      }
    };
  }, []); // Only run once on mount

  return [ref, size];
}

/**
 * Demo component showing how to use the hook
 */
function ResponsiveCard() {
  const [ref, size] = useElementSize();

  return (
    <div style={{ padding: '20px' }}>
      <h2>Element Size Tracker (Resize Me!)</h2>

      <div
        ref={ref}
        style={{
          resize: 'both',
          overflow: 'auto',
          width: '400px',
          height: '300px',
          minWidth: '200px',
          minHeight: '150px',
          border: '2px dashed #007bff',
          borderRadius: '8px',
          padding: '20px',
          backgroundColor: '#f8f9fa',
          margin: '20px auto',
          cursor: 'se-resize',
          boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
        }}
      >
        <h3 style={{ marginTop: 0 }}>Resizable Box</h3>
        <p>Current size:</p>
        <div
          style={{
            fontSize: '24px',
            fontWeight: 'bold',
            color: '#007bff',
            margin: '20px 0',
          }}
        >
          {Math.round(size.width)} × {Math.round(size.height)} px
        </div>
        <p style={{ color: '#666', fontSize: '14px' }}>
          Drag the bottom-right corner to resize this box and watch the
          dimensions update in real-time.
        </p>
      </div>

      <div
        style={{
          padding: '16px',
          backgroundColor: '#e9f5ff',
          borderRadius: '6px',
          maxWidth: '500px',
          margin: '0 auto',
        }}
      >
        <strong>Features of useElementSize hook:</strong>
        <ul style={{ margin: '12px 0 0 20px' }}>
          <li>Uses native ResizeObserver API</li>
          <li>Returns real pixel dimensions (contentRect)</li>
          <li>Proper cleanup on unmount</li>
          <li>Initial size captured immediately</li>
          <li>Only one observer instance</li>
        </ul>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi:
- Khi component mount → hiển thị kích thước ban đầu của box (~400×300)
- Khi kéo resize box → kích thước cập nhật realtime (width × height)
- Giá trị thay đổi mượt mà mỗi khi element resize
- Khi component unmount → observer được disconnect, không memory leak
- Hoạt động tốt với bất kỳ element nào có ref (div, img, canvas, etc.)
*/

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. React Docs - Manipulating the DOM with Refs:https://react.dev/learn/manipulating-the-dom-with-refs

  2. React Docs - forwardRef:https://react.dev/reference/react/forwardRef

  3. MDN - Intersection Observer API:https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

Đọc thêm

  1. When to use refs vs state:https://blog.logrocket.com/complete-guide-react-refs/

  2. ResizeObserver API:https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver

  3. React Hook Form (uses refs extensively):https://react-hook-form.com/


🔗 KẾT NỐI KIẾN THỨC

Kiến thức nền (cần biết từ trước)

  • Ngày 21: useRef cho mutable values (timer IDs, previous values)
  • Ngày 16-20: useEffect và cleanup patterns
  • Ngày 11-14: useState vs useRef decision making

Hướng tới (sẽ dùng ở)

  • Ngày 23: useLayoutEffect - khi cần measure/update DOM synchronously
  • Ngày 24: Custom hooks - extract ref logic into reusable hooks
  • Ngày 25: Project - combine all hooks để build dashboard
  • Ngày 36-38: Forms - refs cho uncontrolled components

💡 SENIOR INSIGHTS

Cân Nhắc Production

1. Accessibility với Refs

🎯 Mục tiêu

Đảm bảo accessibility cho modal, đặc biệt là với:

  • Người dùng keyboard
  • Screen reader

💡 Ý tưởng chính

Modal không chỉ là hiển thị UI, mà phải kiểm soát focus đúng cách:

1. Khi modal mở

  • Lưu lại element đang được focus trước đó
  • Tự động focus vào modal

➡️ Giúp người dùng keyboard “biết” rằng modal đã xuất hiện


2. Trap focus bên trong modal

  • Khi nhấn Tab, focus không được thoát ra ngoài modal
  • Nếu:
    • Tab ở phần tử cuối → quay về phần tử đầu
    • Shift + Tab ở phần tử đầu → quay về phần tử cuối

➡️ Người dùng chỉ di chuyển trong modal, không bị “lạc”


3. Khi modal đóng

  • Restore focus về element trước khi modal mở

➡️ Trải nghiệm liền mạch, không làm mất ngữ cảnh


4. ARIA & semantic

  • role="dialog"
  • aria-modal="true"
  • tabIndex={-1} để focus bằng JS

➡️ Screen reader hiểu đúng đây là modal

jsx
function AccessibleModal({ isOpen, onClose, children }) {
  // Ref trỏ tới modal container
  const modalRef = useRef(null);

  // Lưu lại element đang được focus trước khi modal mở
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (!isOpen) return;

    // 1. Lưu lại element đang focus (để restore sau khi đóng modal)
    previousFocusRef.current = document.activeElement;

    // 2. Khi modal mở, focus vào chính modal
    modalRef.current?.focus();

    // 3. Xử lý trap focus khi người dùng nhấn Tab
    const handleTab = (e) => {
      // Chỉ quan tâm phím Tab
      if (e.key !== 'Tab') return;

      // Lấy tất cả element có thể focus bên trong modal
      const focusableElements = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
      );

      if (!focusableElements || focusableElements.length === 0) return;

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      // Shift + Tab ở element đầu → nhảy về element cuối
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }

      // Tab ở element cuối → nhảy về element đầu
      else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    };

    // Lắng nghe phím Tab trên toàn document
    document.addEventListener('keydown', handleTab);

    // Cleanup khi modal đóng
    return () => {
      document.removeEventListener('keydown', handleTab);

      // 4. Restore focus về element ban đầu
      previousFocusRef.current?.focus();
    };
  }, [isOpen]);

  // Không render gì nếu modal đóng
  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      tabIndex={-1} // Cho phép focus bằng JS
      role='dialog' // Semantic cho screen reader
      aria-modal='true' // Báo đây là modal
      style={{
        position: 'fixed',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        background: 'white',
        padding: '20px',
        zIndex: 1000,
      }}
    >
      {children}
      <button onClick={onClose}>Close</button>
    </div>
  );
}

2. Performance Monitoring với Refs

jsx
// ✅ GOOD: Track component performance
function usePerformanceMonitor(componentName) {
  const renderCountRef = useRef(0);
  const renderTimesRef = useRef([]);
  const startTimeRef = useRef(null);

  // Track renders
  renderCountRef.current += 1;

  useEffect(() => {
    // Measure render time
    const renderTime =
      performance.now() - (startTimeRef.current || performance.now());
    renderTimesRef.current.push(renderTime);

    // Log slow renders
    if (renderTime > 16) {
      // 60fps threshold
      console.warn(
        `Slow render in ${componentName}:`,
        renderTime.toFixed(2),
        'ms',
      );
    }

    // Report metrics periodically
    if (renderCountRef.current % 10 === 0) {
      const avg =
        renderTimesRef.current.reduce((a, b) => a + b, 0) /
        renderTimesRef.current.length;
      console.log(`${componentName} avg render time:`, avg.toFixed(2), 'ms');
    }
  });

  // Start timing next render
  startTimeRef.current = performance.now();

  return {
    renderCount: renderCountRef.current,
    avgRenderTime:
      renderTimesRef.current.reduce((a, b) => a + b, 0) /
      renderTimesRef.current.length,
  };
}

3. Canvas Animation với Refs

jsx
// ✅ GOOD: Animated canvas
function AnimatedCanvas() {
  const canvasRef = useRef(null);
  const animationRef = useRef(null);
  const frameCountRef = useRef(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    let x = 0;

    const animate = () => {
      // Clear canvas
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // Draw
      ctx.fillStyle = 'blue';
      ctx.fillRect(x, 50, 50, 50);

      // Update
      x = (x + 2) % canvas.width;
      frameCountRef.current += 1;

      // Continue animation
      animationRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
      console.log('Total frames:', frameCountRef.current);
    };
  }, []);

  return (
    <canvas
      ref={canvasRef}
      width={400}
      height={200}
    />
  );
}

Câu Hỏi Phỏng Vấn

Junior Level:

Q1: "Làm sao focus một input khi component mount?"

Expected answer:

jsx
const inputRef = useRef(null);

useEffect(() => {
  inputRef.current?.focus();
}, []);

return <input ref={inputRef} />;

Q2: "Sự khác biệt giữa ref và state?"

Expected answer:

  • State: Triggers re-render, cho UI data
  • Ref: Không trigger re-render, cho DOM access và mutable values
  • State: Async updates, Ref: Sync
  • Dùng state cho UI, ref cho imperative operations

Mid Level:

Q3: "Implement click outside để close dropdown."

Expected answer:

jsx
const dropdownRef = useRef(null);

useEffect(() => {
  const handleClick = (e) => {
    if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
      setIsOpen(false);
    }
  };

  document.addEventListener('mousedown', handleClick);
  return () => document.removeEventListener('mousedown', handleClick);
}, []);

Q4: "Tại sao không nên access ref.current trong render?"

Expected answer:

  • Render phase: ref.current chưa được set (vẫn null)
  • React sets refs trong commit phase (sau render)
  • Access trong render → timing issues, potential null errors
  • Đúng: Access trong useEffect hoặc event handlers

Senior Level:

Q5: "Design một system để manage focus trong complex form với validation."

Expected answer:

jsx

function useFormFocus() {
  const refs = useRef({});

  const register = (name) => (element) => {
    if (element) refs.current[name] = element;
  };

  const focusError = (errors) => {
    const firstError = Object.keys(errors)[0];
    refs.current[firstError]?.focus();
  };

  const focusNext = (currentName) => {
    const fields = Object.keys(refs.current);
    const index = fields.indexOf(currentName);
    const next = fields[index + 1];

    refs.current[next]?.focus();
  };

  return { register, focusError, focusNext };
}



function InputField({
  name,
  label,
  value,
  error,
  onChange,
  register,
  focusNext
}) {
  return (
    <div style={{ marginBottom: 20 }}>
      <label>{label}</label>

      <input
        ref={register(name)}
        value={value}
        onChange={(e) => onChange(name, e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            focusNext(name);
          }
        }}
        style={{
          display: "block",
          padding: 8,
          width: "100%",
          border: error ? "1px solid red" : "1px solid #ccc"
        }}
      />

      {error && (
        <p style={{ color: "red", fontSize: 12 }}>
          {error}
        </p>
      )}
    </div>
  );
}


import React, { useState } from "react";
import { useFormFocus } from "./useFormFocus";

export default function RegisterForm() {
  const { register, focusError, focusNext } = useFormFocus();

  const [form, setForm] = useState({
    name: "",
    email: "",
    password: ""
  });

  const [errors, setErrors] = useState({});

  const handleChange = (field, value) => {
    setForm((prev) => ({
      ...prev,
      [field]: value
    }));
  };

  const validate = () => {
    const newErrors = {};

    if (!form.name) newErrors.name = "Name is required";
    if (!form.email) newErrors.email = "Email is required";
    if (!form.password) newErrors.password = "Password is required";

    return newErrors;
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    const validationErrors = validate();

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      focusError(validationErrors);
      return;
    }

    alert("Submit success!");
  };

  return (
    <form onSubmit={handleSubmit} style={{ width: 300 }}>

      <InputField
        name="name"
        label="Name"
        value={form.name}
        error={errors.name}
        onChange={handleChange}
        register={register}
        focusNext={focusNext}
      />

      <InputField
        name="email"
        label="Email"
        value={form.email}
        error={errors.email}
        onChange={handleChange}
        register={register}
        focusNext={focusNext}
      />

      <InputField
        name="password"
        label="Password"
        value={form.password}
        error={errors.password}
        onChange={handleChange}
        register={register}
        focusNext={focusNext}
      />

      <button type="submit">Register</button>

    </form>
  );
}

/*

Name
↓ Enter
Email
↓ Enter
Password

---

submit

validate

focusError()

focus input đầu tiên bị lỗi

scroll tới input


---

Hook này có thể mở rộng:

focusPrevious
autoFocusFirst
skipDisabled
skipHidden
dynamic fields


focusNext("email", { skipDisabled: true })

*/

Q6: "Explain memory leaks với DOM refs và cách prevent."

Expected answer:

  • Common causes:
    • Event listeners không removed
    • Observers (Intersection, Mutation, Resize) không disconnected
    • Refs trỏ đến large DOM trees không cleared
    • Circular references với closures
  • Prevention:
    • Always cleanup trong useEffect return
    • Set refs to null when no longer needed
    • Use WeakMap/WeakRef cho caching
    • Disconnect observers properly
    • Remove event listeners

War Stories

Story 1: The Autofocus Bug

Production bug: Modal không auto-focus khi mở, phá accessibility.

jsx
// ❌ BUG in production:
function Modal({ isOpen }) {
  const firstInputRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      firstInputRef.current?.focus(); // ⚠️ Doesn't work!
    }
  }, [isOpen]);

  return isOpen ? (
    <div>
      <input ref={firstInputRef} />
    </div>
  ) : null;
}

// VẤN ĐỀ: Khi isOpen thay đổi true→true, effect không chạy lại!

// ✅ FIX:
function Modal({ isOpen }) {
  const firstInputRef = useRef(null);

  useEffect(() => {
    if (isOpen && firstInputRef.current) {
      // Delay để đảm bảo DOM ready
      setTimeout(() => {
        firstInputRef.current?.focus();
      }, 0);
    }
  }, [isOpen]);

  return isOpen ? (
    <div>
      <input ref={firstInputRef} />
    </div>
  ) : null;
}

Lesson learned:

  • Timing issues với refs phức tạp hơn mong đợi
  • Test accessibility thoroughly
  • setTimeout(0) đôi khi cần thiết cho DOM operations

Story 2: The Infinite Scroll Performance Issue

Infinite scroll component làm browser freeze khi scroll nhanh.

jsx
// ❌ PERFORMANCE BUG:
function InfiniteList() {
  const observerRef = useRef(null);

  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            loadMore(); // ⚠️ No throttling!
          }
        });
      },
      { threshold: 0.1 },
    ); // ⚠️ Too sensitive!
  }, []);
}

// ✅ FIX: Throttle + better threshold
function InfiniteList() {
  const observerRef = useRef(null);
  const loadingRef = useRef(false);
  const lastLoadRef = useRef(0);

  const loadMore = async () => {
    const now = Date.now();
    if (loadingRef.current || now - lastLoadRef.current < 1000) {
      return; // Throttle: min 1s between loads
    }

    loadingRef.current = true;
    lastLoadRef.current = now;

    await fetchData();

    loadingRef.current = false;
  };

  useEffect(() => {
    observerRef.current = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry?.isIntersecting) {
          loadMore();
        }
      },
      {
        threshold: 0.5, // Less sensitive
        rootMargin: '100px', // Pre-load
      },
    );
  }, []);
}

Lesson learned:

  • Always throttle/debounce scroll/resize handlers
  • Refs perfect cho tracking throttle state
  • Test với slow devices

🎯 PREVIEW NGÀY MAI

Ngày 23: useLayoutEffect - Synchronous DOM Updates ⚙️

Ngày mai chúng ta sẽ học hook đặc biệt: useLayoutEffect.

Bạn sẽ học:

  • Sự khác biệt useEffect vs useLayoutEffect
  • Khi nào PHẢI dùng useLayoutEffect
  • DOM measurements before paint
  • Preventing visual flickering
  • Tooltip positioning
  • Animation setup

Chuẩn bị mental model:

useEffect:         [Render] → [Paint] → [Effect]
useLayoutEffect:   [Render] → [Effect] → [Paint]

                    Sync - blocks painting!

See you tomorrow! 🚀


✅ CHECKLIST HOÀN THÀNH

Trước khi kết thúc ngày học, check:

  • [ ] Hiểu sâu useRef cho DOM access
  • [ ] Làm đủ 5 exercises
  • [ ] Đọc React docs về refs
  • [ ] Làm bài tập về nhà
  • [ ] Review debug lab
  • [ ] Thực hành với third-party libraries
  • [ ] Chuẩn bị cho useLayoutEffect

🎉 Congratulations! Bạn đã hoàn thành Ngày 22!

Bạn đã học được: ✅ DOM refs fundamentals ✅ Focus & scroll control ✅ Video/audio manipulation ✅ Intersection Observer ✅ Third-party library integration ✅ Click outside detection ✅ Memory leak prevention

Tomorrow: useLayoutEffect cho synchronous DOM operations! 💪

useRef — Tham chiếu tư duy cho Senior

Ngày 21–22 | Mutable Values → DOM Manipulation
Senior không nhớ code, họ nhớ concepts, trade-offs và khi nào dùng gì.


MỤC LỤC

  1. Bản đồ tổng thể — Big Picture
  2. useRef là gì — Bản chất cốt lõi
  3. Hai Use Cases — Phân loại rõ ràng
  4. Use Case 1 — Mutable Values
  5. Use Case 2 — DOM References
  6. State vs Ref — Bảng quyết định
  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 Stories — Bài học thực tế
  11. Decision Framework nhanh

1. Bản đồ tổng thể

Ngày 21 — useRef: Mutable Values
  ↓  Store giá trị tồn tại qua renders mà không trigger re-render
  ↓  Timer IDs, previous values, flags, render count

Ngày 22 — useRef: DOM Manipulation
  ↓  Access trực tiếp DOM node sau khi React đã render
  ↓  Focus, scroll, measurement, third-party libs

Cả hai đều dùng cùng 1 hook — useRef — nhưng phục vụ 2 mục đích khác nhau

Triết lý xuyên suốt:

  • useRef = "một chiếc hộp" mà React không theo dõi nội dung bên trong
  • Thay đổi .current không báo gì cho React → không re-render
  • React điền DOM node vào .current sau khi commit phase hoàn thành
  • Ref là "cửa thoát hiểm" ra khỏi React's declarative model — dùng có chủ đích

2. useRef là gì

Bản chất

useRef trả về một object { current: initialValue } tồn tại suốt lifetime của component. Object này không bao giờ thay đổi (cùng một reference qua mọi render), nhưng nội dung .current có thể thay đổi tự do.

So sánh 3 loại "biến" trong component

LoạiPersist qua render?Trigger re-render?Dùng cho
let x = 0 (biến thường)❌ Reset mỗi renderKhông dùng để lưu trữ
useStateData hiển thị lên UI
useRefData không cần UI sync

Timeline: Khi nào ref.current được gán DOM

Render phase:   Component function chạy → JSX tạo ra → ref.current vẫn là null
Commit phase:   React update real DOM → React gán ref.current = DOM node
Effect phase:   useEffect chạy → ĐÂY MỚI an toàn để access ref.current

Key insight: Không bao giờ đọc ref.current trong render function — DOM chưa được gán tại thời điểm đó.


3. Hai Use Cases

useRef {
  Use Case 1: Mutable Values
    → Timer IDs, interval IDs
    → Previous values (so sánh với render trước)
    → Flags (isMounted, isPaused, isLatest)
    → Render count (debugging)
    → AbortController instances
    → Bất kỳ "biến instance" nào không cần UI sync

  Use Case 2: DOM References
    → Focus management (auto-focus, focus on error)
    → Scroll control (scroll to element, smooth scroll)
    → Measurements (offsetWidth, offsetHeight, getBoundingClientRect)
    → Media control (play, pause, seek)
    → Canvas / animation frame
    → Third-party library integration (chart libs, text editors)
    → Click-outside detection
    → Intersection Observer, Resize Observer
}

4. Use Case 1 — Mutable Values

Pattern: Lưu Timer ID

Vấn đề: setInterval trả về ID để cancel sau này. ID này là internal data — không hiển thị UI, không cần trigger re-render.

Tại sao không dùng biến thường: Biến reset mỗi render → ID bị mất sau re-render đầu tiên.
Tại sao không dùng useState: setState gây re-render không cần thiết — ID không phải UI data.
Đúng: useRef — persist, không re-render.


Pattern: Previous Value Tracking

Use case: So sánh giá trị hiện tại với giá trị render trước (price change, analytics, diff).

Cơ chế hoạt động:

  1. Render: Dùng previousRef.current để tính delta/diff
  2. Sau render (trong useEffect): Cập nhật previousRef.current = currentValue
  3. Render tiếp theo: previousRef.current chứa giá trị của render vừa rồi

Timing quan trọng: Phải cập nhật ref trong useEffect (sau render), không phải trong render function. Nếu cập nhật trong render, sẽ mất đi giá trị cũ trước khi kịp dùng.


Pattern: isMounted / isCancelled Flag

Use case: Tránh setState sau unmount trong async operations.

Cơ chế: Ref flag được đặt true khi mount, cleanup function đặt false. Trước mọi setState, kiểm tra if (isMountedRef.current).

Khác với isCancelled trong closure (Ngày 18): Đây là ref — persist qua renders, có thể đọc ở bất kỳ đâu trong component, không bị stale closure.


Pattern: Render Count (Debugging)

Use case: Đếm số lần component render để debug performance.

Tại sao không dùng useState: setRenderCount(n+1) → trigger thêm 1 render → infinite loop.
Tại sao không dùng biến thường: Reset về 0 mỗi render → luôn là 1.
Đúng: renderCountRef.current += 1 ngay trong render function — increment mỗi render, không trigger thêm render.


Pattern: Avoiding Stale Closure trong Callbacks Lâu Dài

Use case: WebSocket handlers, event listeners dài hạn cần đọc state mới nhất nhưng không muốn recreate.

Vấn đề: Callback được tạo 1 lần (empty deps []) sẽ capture state cũ → stale closure.

Giải pháp với ref:

// Thay vì đọc state trực tiếp trong callback (stale):
setMessages([...messages, newMsg])  ← messages là giá trị cũ lúc tạo callback

// Dùng functional update (không cần ref, không cần state trực tiếp):
setMessages(prev => [...prev, newMsg])  ← luôn đúng

Hoặc: Lưu state vào ref, callback đọc stateRef.current — luôn là giá trị mới nhất vì ref không bị capture theo closure.


5. Use Case 2 — DOM References

Cơ chế attach ref vào DOM

Bước 1: const inputRef = useRef(null)   → { current: null }
Bước 2: <input ref={inputRef} />        → React note: "gán ref này sau khi DOM xong"
Bước 3: Commit phase                     → inputRef.current = <input DOM node>
Bước 4: useEffect chạy                   → an toàn để inputRef.current.focus()

React tự đặt null lại vào ref.current khi element unmount.


Focus Management

Auto-focus khi mount: useEffect(() => { ref.current.focus() }, [])

Focus first error field khi submit:

  • Lưu refs của tất cả fields vào refsMap = useRef({})
  • Khi submit fail, tìm field đầu tiên có lỗi → refsMap.current[firstErrorField].focus()
  • Kết hợp scrollIntoView({ behavior: 'smooth', block: 'center' }) cho UX tốt

Focus next field khi Enter:

  • Lưu refs theo thứ tự (array hoặc named map)
  • onKeyDown: Nếu Enter, focus field tiếp theo trong danh sách

Modal auto-focus:

  • Focus field đầu tiên khi modal mở
  • Trap focus trong modal (Tab chỉ di chuyển trong modal)
  • Trả focus về trigger element khi modal đóng

Scroll Control

Scroll to bottom (chat app): Mỗi khi messages thêm mới → messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })

Scroll to top: containerRef.current.scrollTop = 0

Smooth scroll to anchor: ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' })


Measurements

Khi cần: Responsive tooltip/popover, dynamic sizing, animation based on dimensions.

Pattern:

  • ref.current.offsetWidth / offsetHeight — rendered dimensions
  • ref.current.getBoundingClientRect() — position relative to viewport
  • Chỉ đọc trong useEffect (sau paint) hoặc trong event handlers
  • Nếu cần đọc trước khi browser paint (để tránh flicker) → useLayoutEffect (Ngày 23)

Third-party Library Integration

Use case: Chart.js, D3, video.js, text editors, map libraries — tất cả cần DOM node thực sự.

Pattern chuẩn:

  1. containerRef = useRef(null)
  2. <div ref={containerRef} />
  3. Trong useEffect([]): library.init(containerRef.current, options)
  4. Trong cleanup: library.destroy() hoặc instance.dispose()

Lý do: Library muốn control DOM trực tiếp, không qua React. Cần "escape hatch" — đó là ref.


Click-Outside Detection

Use case: Dropdown, modal, popover đóng khi click ra ngoài.

Cơ chế:

  • dropdownRef.current.contains(event.target) — check click có trong element không
  • Nếu !contains → close dropdown
  • Listener attach trên document, cleanup khi unmount

Timing: Dùng mousedown thay vì click để bắt sớm hơn, tránh conflict với click handler bên trong.


Intersection Observer

Use case: Infinite scroll, lazy loading images, analytics visibility tracking.

Pattern:

  1. observerRef = useRef(null)
  2. sentinelRef = useRef(null) — element ở cuối list
  3. useEffect([]): Tạo new IntersectionObserver(callback, options), gán vào observerRef.current, observe sentinelRef.current
  4. Cleanup: observerRef.current.disconnect()

Production tip: Throttle callback với ref flag (isLoadingRef.current) để tránh gọi loadMore() nhiều lần khi scroll nhanh.


6. State vs Ref

Bảng quyết định

Câu hỏiTrả lờiDùng
Data có cần hiển thị lên UI không?useState
Thay đổi có cần trigger re-render không?useState
Chỉ cần lưu để dùng internal, không hiển thị?useRef
Cần access DOM node?useRef
Cần lưu giá trị qua renders mà không re-render?useRef

Rule of thumb ngắn gọn

Nếu user cần THẤY nó thay đổi → useState
Nếu chỉ CODE cần biết nó tồn tại → useRef
Nếu cần NÓI CHUYỆN với DOM → useRef

Declarative vs Imperative

React khuyến khích declarative: "UI trông như thế này khi state = X" → Dùng state.

Đôi khi phải imperative: "Gọi .focus() ngay bây giờ" → Dùng ref.

Ref là cửa thoát hiểm — dùng khi React's declarative model không đủ. Không nên lạm dụng để "bypass" React.


7. Dạng bài tập

DẠNG 1 — Timer không thể stop

Nhận dạng: clearInterval(intervalId) không hoạt động, timer chạy mãi
Nguyên nhân: intervalId là biến thường → reset mỗi render → stopTimer luôn nhận undefined
Hướng giải: useRef để persist intervalId qua renders

DẠNG 2 — State dùng để lưu non-UI data

Nhận dạng: useState cho timer ID, flag, counter nội bộ → re-render không cần thiết
Nguyên nhân: Nhầm lẫn "cần persist" với "cần render"
Hướng giải: useRef cho mọi data không cần hiển thị lên UI

DẠNG 3 — So sánh giá trị với render trước

Nhận dạng: "Tôi cần biết giá trị đã tăng hay giảm so với lần trước"
Hướng giải: previousRef = useRef(initial) → dùng trong render → cập nhật trong useEffect

DẠNG 4 — Render count gây infinite loop

Nhận dạng: Muốn đếm renders nhưng dùng useState → loop
Hướng giải: renderCountRef.current += 1 trong render body — ref không trigger re-render

DẠNG 5 — Auto-focus input khi component mount

Nhận dạng: "Làm sao focus input ngay khi component hiện ra?"
Hướng giải: inputRef = useRef(null) + ref={inputRef} + useEffect(() => ref.current?.focus(), [])

DẠNG 6 — Access DOM trong render phase

Nhận dạng: ref.current.offsetWidth đọc trong render → null error
Nguyên nhân: DOM chưa được assign vào ref trong render phase
Hướng giải: Chuyển sang useEffect — ref.current đã có giá trị sau commit phase

DẠNG 7 — Click outside để close dropdown

Nhận dạng: Dropdown cần đóng khi click ra ngoài vùng
Hướng giải: dropdownRef + document.addEventListener('mousedown', ...) + ref.current.contains(e.target) + cleanup listener

DẠNG 8 — Focus first error field

Nhận dạng: Form submit fail, cần tự động focus vào field lỗi đầu tiên
Hướng giải: refsMap = useRef({}) + register từng field + khi submit fail, tìm key đầu trong errors → focus + scrollIntoView

DẠNG 9 — Third-party library cần DOM node

Nhận dạng: Chart, map, editor cần init(element) với real DOM
Hướng giải: containerRefuseEffect([]): init(containerRef.current) → cleanup: destroy()

DẠNG 10 — Infinite scroll với Intersection Observer

Nhận dạng: Load thêm data khi scroll đến cuối list
Hướng giải: sentinelRef ở cuối list + observerRef lưu instance + useEffect([]) tạo observer + cleanup: disconnect()

DẠNG 11 — Stale closure trong WebSocket/long-lived callback

Nhận dạng: WebSocket handler dùng state cũ, không update khi state thay đổi
Hướng giải: Functional update setState(prev => ...) để không cần đọc state trong callback; hoặc lưu state vào stateRef.current để callback đọc giá trị mới nhất

DẠNG 12 — Throttle infinite scroll gọi loadMore quá nhiều

Nhận dạng: Scroll nhanh → loadMore bị gọi chục lần liên tục
Hướng giải: isLoadingRef.current làm flag throttle trong callback — kiểm tra trước khi gọi, reset sau khi done


8. Anti-patterns cần tránh

❌ Biến thường để lưu qua renders

Triệu chứng: Giá trị mất sau mỗi re-render, timer không stop được
Fix: useRef

❌ useState cho non-UI data (timer ID, flags)

Triệu chứng: Re-render không cần thiết, performance waste
Fix: useRef

❌ useRef cho UI data (thay vì useState để "tránh re-render")

Triệu chứng: UI không update khi data thay đổi, hiển thị giá trị cũ mãi
Fix: useState — nếu user cần thấy, phải dùng state

❌ Access ref.current trong render phase

Triệu chứng: Cannot read properties of null, lỗi đặc biệt lần render đầu tiên
Fix: Chuyển vào useEffect hoặc event handler; luôn null-check ref.current?.method()

❌ Không null-check ref.current

Triệu chứng: Crash khi element conditional render hoặc chưa mount xong
Fix: ref.current?.focus() hoặc if (ref.current) { ... }

❌ Dùng ref để manipulate style/visibility thay vì state

Triệu chứng: React và DOM out of sync, unpredictable behavior, khó debug
Fix: Dùng state để React manage, {show && <Comp />} thay vì ref.current.style.display

❌ Không cleanup Observers

Triệu chứng: Memory leak, observer callback vẫn chạy sau unmount
Fix: useEffect cleanup: observer.disconnect(), observer.unobserve(element)

❌ Cập nhật previousRef trong render (không trong useEffect)

Triệu chứng: Previous value bị overwrite trước khi kịp dùng để tính delta
Fix: Đọc ref trong render → cập nhật ref trong useEffect sau render


9. Interview Questions

Junior Level

Q: useRef và useState khác nhau như thế nào?
A: Cả hai đều persist across renders nhưng useState trigger re-render khi thay đổi, useRef thì không. useState dùng cho data cần hiển thị lên UI. useRef dùng cho internal tracking và DOM access. useState update async, useRef sync.

Q: Làm sao store timer ID trong React?
A: Dùng useRef(null). Gán khi tạo timer, cleanup khi cần. Không dùng useState vì gây re-render không cần thiết.

Q: Làm sao auto-focus input khi component mount?
A: useRef(null) attach vào input, trong useEffect(fn, []) gọi ref.current?.focus(). useEffect chạy sau DOM mount nên ref.current đã có giá trị.

Q: Tại sao không đọc ref.current trong render?
A: React gán DOM node vào ref ở commit phase (sau render). Trong render phase, ref.current vẫn là null. Đọc trong render → null error. Đọc trong useEffect hoặc event handler mới an toàn.


Mid Level

Q: Giải thích các use case của useRef ngoài DOM manipulation.
A: Previous value tracking (so sánh với render trước), timer/interval IDs, flags (isMounted, isLoading, isPaused), render count (debugging), AbortController instances, mutable instance variables, tránh stale closure trong long-lived callbacks.

Q: Có thể dùng useRef thay useState để optimize performance không?
A: Không thể thay thế hoàn toàn. useRef phù hợp cho non-UI data. Nếu data hiển thị lên UI → bắt buộc dùng useState vì UI không tự update khi ref thay đổi. Trade-off: Ref tránh re-render nhưng mất automatic UI sync.

Q: Implement click-outside để close dropdown.
A: useRef attach vào container dropdown. useEffect attach mousedown listener trên document. Handler check !ref.current.contains(event.target) → nếu click ngoài thì close. Cleanup: removeEventListener.

Q: Khi nào nên update previousRef — trong render hay trong useEffect?
A: Trong useEffect (sau render). Nếu update trong render, giá trị cũ bị overwrite trước khi kịp dùng để tính delta. useEffect chạy sau khi render đã hoàn thành — đó là thời điểm lưu "snapshot của render vừa rồi" cho lần render tiếp theo.


Senior Level

Q: Design system manage focus trong complex form với validation.
A: refsMap = useRef({}) lưu refs của tất cả fields theo tên. register(name) function trả về callback ref để assign vào DOM. Khi submit fail: tìm field đầu tiên có error → refsMap.current[firstError].focus() + scrollIntoView. focusNext(currentName): dùng field order để move đến field tiếp theo khi Enter. Toàn bộ focus logic là imperative, tách ra khỏi render logic.

Q: Explain memory leak patterns với refs và cách prevent.
A: Common causes: event listeners không removed, Observers (Intersection, Mutation, Resize) không disconnected, refs trỏ đến large DOM trees hoặc objects, circular references với closures. Prevention: cleanup trong useEffect return, observer.disconnect(), null out refs sau khi dùng, dùng WeakRef/WeakMap cho caching nếu cần tránh strong reference.

Q: Khi nào nên dùng callback ref thay vì useRef?
A: Callback ref (ref={el => { ... }}) hữu ích khi cần react ngay khi ref được gán/bỏ — ví dụ đo DOM element ngay khi mount mà không cần useEffect, hoặc khi cần attach ref vào dynamic list. useRef đơn giản hơn cho single stable elements. Callback ref cho phép logic phức tạp hơn khi assignment xảy ra.


10. War Stories

Story: Interval Không Stop — $1000 AWS Bill

Dashboard component không cleanup intervals → memory tăng dần → server crash → AWS bill spike. Nguyên nhân: không dùng ref để lưu interval ID + không return cleanup trong useEffect. Lesson: LUÔN lưu resource IDs bằng useRef, LUÔN cleanup trong useEffect return. Test mount/unmount cycles trong development.


Story: Stale Closure trong WebSocket Handler

Chat app: WebSocket handler dùng setMessages([...messages, newMsg]) với empty deps []. Bug: Mỗi tin nhắn mới chỉ thêm vào danh sách gốc (capture lúc mount), không thêm vào danh sách hiện tại. Fix: Đổi thành functional update setMessages(prev => [...prev, newMsg]) — không cần đọc messages, không stale. Lesson: Long-lived callbacks phải dùng functional update hoặc đọc từ ref, không đọc state trực tiếp.


Story: Autofocus Modal Bug — Accessibility Failure

Modal không auto-focus khi mở → phá accessibility (screen reader, keyboard nav). Nguyên nhân tinh tế: isOpen thay đổi false → true nhưng effect check [isOpen] — chỉ re-run khi isOpen thay đổi, không phải khi modal remount. Fix: setTimeout(fn, 0) để đảm bảo DOM thực sự ready trước khi focus. Lesson: Timing của DOM operations phức tạp hơn dự kiến — đôi khi cần defer bằng setTimeout(0). Luôn test accessibility.


Story: Infinite Scroll Freeze Browser

IntersectionObserver không throttle → khi scroll nhanh, loadMore() được gọi hàng chục lần liên tục → tất cả fetch cùng lúc → browser freeze. Fix: isLoadingRef.current làm flag throttle trong callback, kiểm tra trước khi gọi. Lesson: Refs perfect cho tracking throttle state trong callbacks — không trigger re-render, luôn mutable, không stale.


11. Decision Framework nhanh

Có nên dùng ref không?

Data này có cần hiển thị lên UI không?
├── Có → useState (không phải ref)
└── Không → Có cần persist qua renders?
    ├── Không → Biến local trong function là đủ
    └── Có → useRef ✅

Loại ref nào phù hợp?

Cần truy cập DOM element trực tiếp?
├── Có → useRef(null) + ref={...} trên JSX element
└── Không → useRef(initialValue) cho mutable value

Khi nào đọc ref.current?

Trong render function?
├── Có → ❌ Nguy hiểm (DOM ref vẫn là null), dùng useEffect
└── Không → Trong useEffect hoặc event handler → ✅ An toàn
            Nhớ null-check: ref.current?.method()

Khi nào cập nhật previousRef?

Cập nhật trong render? → ❌ Overwrite trước khi kịp dùng
Cập nhật trong useEffect? → ✅ Sau khi render đã dùng xong giá trị cũ

Ref hay State cho DOM control?

Control LIỆU element có render không? → State (conditional rendering)
Control CÁC PROPS của element? → State
Gọi IMPERATIVE DOM API (focus, scroll, play)? → Ref
ĐO đạc kích thước/vị trí? → Ref
Tích hợp thư viện bên ngoài? → Ref

Tổng hợp từ Ngày 21–22: useRef Fundamentals (Mutable Values) & useRef DOM Manipulation

Personal tech knowledge base