Skip to content

📅 NGÀY 18: Cleanup & Memory Leaks

📍 Phase 2, Tuần 5, Ngày 18 của 45

⏱️ Thời lượng: 3-4 giờ


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

  • [ ] Hiểu Cleanup Function là gì và tại sao cần thiết
  • [ ] Biết khi nào cleanup function chạy (timing critical!)
  • [ ] Ngăn chặn Memory Leaks với timers, event listeners, subscriptions
  • [ ] Xử lý Async Operations Cleanup (cancel pending requests)
  • [ ] Áp dụng cleanup patterns cho production code

🤔 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 sau:

  1. Câu 1: Nếu bạn dùng setInterval trong useEffect, điều gì xảy ra khi component unmount?

    • Đáp án: Interval vẫn chạy → Memory leak! (Ngày 18 sẽ fix)
  2. Câu 2: Khi dependencies thay đổi, effect chạy lại. Vậy effect CŨ có bị "dọn dẹp" không?

    • Đáp án: Chưa biết cách! (Hôm nay học cleanup)
  3. Câu 3: API call đang pending, nhưng user navigate away. Có vấn đề gì?

    • Đáp án: setState trên unmounted component → Warning! (Cleanup sẽ giải quyết)

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

1.1 Vấn Đề Thực Tế

Hãy xem đoạn code này:

jsx
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    // ❌ PROBLEM: Không cleanup!
  }, []);

  return <div>Seconds: {seconds}</div>;
}

function App() {
  const [showTimer, setShowTimer] = useState(true);

  return (
    <div>
      <button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>
      {showTimer && <Timer />}
    </div>
  );
}

Vấn đề:

  1. Click "Toggle Timer" → Timer component unmount
  2. Nhưng setInterval VẪN CHẠY! (không ai dừng nó)
  3. Interval cố gắng gọi setSeconds trên component đã unmount
  4. Memory leak + Console warning: "Can't perform a React state update on an unmounted component"

Kết quả:

  • Memory leak (interval không bao giờ dừng)
  • Potential crashes
  • Performance degradation
  • Battery drain (mobile)

1.2 Giải Pháp: Cleanup Function

Cleanup Function là function mà effect RETURN để dọn dẹp side effects.

Cú pháp:

jsx
useEffect(() => {
  // Setup code
  const id = setInterval(() => {
    // ...
  }, 1000);

  // Cleanup function
  return () => {
    clearInterval(id); // ← Dọn dẹp
  };
}, []);

Cleanup Function chạy khi:

  1. Component unmount (component bị remove khỏi DOM)
  2. Dependencies thay đổi (trước khi effect chạy lại)

GIẢI PHÁP cho Timer:

jsx
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    console.log('✅ Effect: Setup interval');

    const id = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    // ✅ Cleanup function
    return () => {
      console.log('🧹 Cleanup: Clear interval');
      clearInterval(id);
    };
  }, []);

  return <div>Seconds: {seconds}</div>;
}

// BEHAVIOR:
// Mount → Setup interval
// Unmount → Cleanup (clear interval) ✅
// No memory leak! ✅

1.3 Mental Model: Setup & Cleanup Lifecycle

┌─────────────────────────────────────────────────────────────┐
│           EFFECT LIFECYCLE WITH CLEANUP                      │
└─────────────────────────────────────────────────────────────┘

SCENARIO 1: Component Mount → Unmount
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. Component mounts

2. Render

3. Browser paints

4. useEffect runs (SETUP)
   - Create interval, add listener, etc.

5. ... Component exists ...

6. Component unmounts

7. Cleanup function runs (CLEANUP)
   - Clear interval, remove listener, etc.

8. Component gone ✅

═══════════════════════════════════════════════════════════════

SCENARIO 2: Dependencies Change
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. Effect runs với deps = [A]
   - Setup với A

2. ... Time passes ...

3. Deps thay đổi: A → B

4. Cleanup runs (cleanup OLD setup với A) 🧹

5. Effect runs lại (setup NEW với B) ✅

6. ... And so on ...

═══════════════════════════════════════════════════════════════

KEY INSIGHT:
Cleanup ALWAYS chạy TRƯỚC khi effect chạy lại!
→ Old effect cleaned up BEFORE new effect sets up
→ Prevents resource leaks

Analogy dễ hiểu:

Effect như thuê phòng khách sạn:

  1. Check-in (Setup): Nhận chìa khóa, bật đèn, mở điều hòa
  2. Ở trong phòng (Effect active)
  3. Check-out (Cleanup): Trả chìa khóa, tắt đèn, tắt điều hòa

Nếu không check-out (no cleanup):

  • Đèn vẫn cháy (waste energy)
  • Phòng locked (resource not freed)
  • Hotel bill keeps going (memory leak!)

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

❌ Hiểu lầm #1: "Cleanup chỉ chạy khi unmount"

jsx
function Wrong() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('Effect with count:', count);

    return () => {
      console.log('Cleanup'); // Chạy TRƯỚC mỗi effect re-run!
    };
  }, [count]); // ← Deps có count

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

// Console output khi click 3 lần:
// Effect with count: 0
// (click)
// Cleanup           ← Cleanup OLD effect (count = 0)
// Effect with count: 1  ← New effect (count = 1)
// (click)
// Cleanup           ← Cleanup OLD (count = 1)
// Effect with count: 2  ← New effect (count = 2)
// (unmount)
// Cleanup           ← Final cleanup

✅ Đúng: Cleanup chạy:

  • Trước mỗi effect re-run (khi deps thay đổi)
  • Khi component unmount

❌ Hiểu lầm #2: "Cleanup là optional"

jsx
// ❌ DANGEROUS: No cleanup
function Dangerous() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // Không remove listener → Memory leak!
  }, []);
}

// ✅ SAFE: Always cleanup
function Safe() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll); // ✅
    };
  }, []);
}

Rule: Nếu effect tạo resource (timer, listener, subscription), BẮT BUỘC phải cleanup!


❌ Hiểu lầm #3: "Cleanup chạy synchronously"

jsx
function Wrong() {
  useEffect(() => {
    console.log('1. Effect runs');

    return () => {
      console.log('3. Cleanup runs'); // Chạy SAU, không phải ngay
    };
  }, []);

  console.log('2. Render completes');
}

// Output:
// 2. Render completes
// 1. Effect runs
// (later, on unmount)
// 3. Cleanup runs

✅ Đúng: Cleanup là async, chạy SAU khi cần (unmount hoặc deps change).


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

Demo 1: Pattern Cơ Bản - Timer Cleanup ⭐

jsx
/**
 * Demo: setInterval với cleanup proper
 * Concepts: Cleanup timing, clearInterval
 */

import { useState, useEffect } from 'react';

function TimerWithCleanup() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    if (!isRunning) return; // Don't setup nếu paused

    console.log('✅ Setting up interval');

    const intervalId = setInterval(() => {
      setSeconds((s) => s + 1);
      console.log('⏱️ Tick');
    }, 1000);

    // Cleanup function
    return () => {
      console.log('🧹 Cleaning up interval:', intervalId);
      clearInterval(intervalId);
    };
  }, [isRunning]); // Re-run khi isRunning thay đổi

  return (
    <div>
      <h2>Timer with Cleanup</h2>
      <p>Seconds: {seconds}</p>

      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? '⏸️ Pause' : '▶️ Start'}
      </button>

      <button onClick={() => setSeconds(0)}>🔄 Reset</button>

      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>📋 Test Instructions:</h3>
        <ol>
          <li>Mở Console</li>
          <li>Click Start → Interval được tạo</li>
          <li>Click Pause → Cleanup chạy, interval cleared</li>
          <li>Click Start lại → Interval MỚI được tạo</li>
        </ol>

        <h3>🔍 Observations:</h3>
        <ul>
          <li>✅ Mỗi lần pause → Cleanup removes old interval</li>
          <li>✅ Không có interval nào "leak"</li>
          <li>✅ Console.log clear patterns</li>
        </ul>
      </div>
    </div>
  );
}

export default TimerWithCleanup;

Demo 2: Kịch Bản Thực Tế - Event Listener Cleanup ⭐⭐

jsx
/**
 * Demo: Event listeners cleanup
 * Use case: Window events, keyboard shortcuts
 */

import { useState, useEffect } from 'react';

function MouseTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isTracking, setIsTracking] = useState(true);

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

    console.log('✅ Adding mousemove listener');

    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);

    // ✅ CLEANUP: Remove listener
    return () => {
      console.log('🧹 Removing mousemove listener');
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, [isTracking]);

  return (
    <div>
      <h2>Mouse Tracker</h2>

      <p>
        Mouse Position: ({position.x}, {position.y})
      </p>

      <button onClick={() => setIsTracking(!isTracking)}>
        {isTracking ? '⏸️ Stop Tracking' : '▶️ Start Tracking'}
      </button>

      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>⚠️ Without Cleanup:</h3>
        <ul>
          <li>❌ Listener stays attached forever</li>
          <li>❌ Multiple listeners accumulate</li>
          <li>❌ Memory leak</li>
          <li>❌ Performance degradation</li>
        </ul>

        <h3>✅ With Cleanup:</h3>
        <ul>
          <li>✅ Listener removed when not needed</li>
          <li>✅ No accumulation</li>
          <li>✅ Clean memory</li>
          <li>✅ Optimal performance</li>
        </ul>
      </div>
    </div>
  );
}

// 🔥 ADVANCED: Multiple listeners
function KeyboardShortcuts() {
  const [keys, setKeys] = useState([]);

  useEffect(() => {
    console.log('✅ Setting up keyboard listeners');

    const handleKeyDown = (e) => {
      setKeys((prev) => [...prev, `${e.key} (down)`].slice(-5));
    };

    const handleKeyUp = (e) => {
      setKeys((prev) => [...prev, `${e.key} (up)`].slice(-5));
    };

    // Add MULTIPLE listeners
    window.addEventListener('keydown', handleKeyDown);
    window.addEventListener('keyup', handleKeyUp);

    // Cleanup BOTH listeners
    return () => {
      console.log('🧹 Removing keyboard listeners');
      window.removeEventListener('keydown', handleKeyDown);
      window.removeEventListener('keyup', handleKeyUp);
    };
  }, []); // Empty deps → Setup once

  return (
    <div>
      <h2>Keyboard Shortcuts</h2>
      <p>Press any key...</p>

      <div>
        <h3>Recent Keys:</h3>
        <ul>
          {keys.map((key, i) => (
            <li key={i}>{key}</li>
          ))}
        </ul>
      </div>

      <p>💡 Notice: Both keydown AND keyup listeners cleaned up together</p>
    </div>
  );
}

export default MouseTracker;

Demo 3: Edge Cases - Async Cleanup & Race Conditions ⭐⭐⭐

jsx
/**
 * Demo: Cleanup async operations
 * Edge case: Cancel pending API calls, avoid setState on unmounted component
 */

import { useState, useEffect } from 'react';

// Mock API với delay
const fetchUser = (userId) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        id: userId,
        name: `User ${userId}`,
        email: `user${userId}@example.com`,
      });
    }, 2000); // 2 second delay
  });
};

// ❌ VERSION 1: Without Cleanup (BUGGY!)
function UserProfileBuggy({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('Fetching user:', userId);
    setLoading(true);

    fetchUser(userId).then((data) => {
      // ⚠️ PROBLEM: Nếu component unmount trước khi promise resolve
      // → setState trên unmounted component → Warning!
      setUser(data);
      setLoading(false);
      console.log('✅ User loaded:', data.id);
    });

    // ❌ No cleanup!
  }, [userId]);

  if (loading) return <div>Loading user {userId}...</div>;
  return (
    <div>
      <h3>{user?.name}</h3>
      <p>{user?.email}</p>
    </div>
  );
}

// ✅ VERSION 2: With Cleanup (FIXED!)
function UserProfileFixed({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('✅ Fetching user:', userId);
    setLoading(true);

    // Flag để track nếu component vẫn mounted
    let isCancelled = false;

    fetchUser(userId).then((data) => {
      // Chỉ update state nếu CHƯA cleanup
      if (!isCancelled) {
        setUser(data);
        setLoading(false);
        console.log('✅ User loaded:', data.id);
      } else {
        console.log('🧹 Request cancelled for user:', data.id);
      }
    });

    // Cleanup: Set flag
    return () => {
      console.log('🧹 Cleanup: Cancelling request for user:', userId);
      isCancelled = true;
    };
  }, [userId]);

  if (loading) return <div>Loading user {userId}...</div>;
  return (
    <div>
      <h3>{user?.name}</h3>
      <p>{user?.email}</p>
    </div>
  );
}

// 🔥 VERSION 3: With AbortController (MODERN!)
function UserProfileModern({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log('✅ Fetching user:', userId);
    setLoading(true);
    setError(null);

    // Create AbortController
    const controller = new AbortController();

    // Fetch với signal
    fetch(`/api/users/${userId}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
        console.log('✅ User loaded:', data.id);
      })
      .catch((err) => {
        if (err.name === 'AbortError') {
          console.log('🧹 Request aborted for user:', userId);
        } else {
          setError(err.message);
          setLoading(false);
        }
      });

    // Cleanup: Abort request
    return () => {
      console.log('🧹 Aborting request for user:', userId);
      controller.abort();
    };
  }, [userId]);

  if (loading) return <div>Loading user {userId}...</div>;
  if (error) return <div>Error: {error}</div>;
  return (
    <div>
      <h3>{user?.name}</h3>
      <p>{user?.email}</p>
    </div>
  );
}

// Demo Component
function AsyncCleanupDemo() {
  const [userId, setUserId] = useState(1);
  const [showProfile, setShowProfile] = useState(true);

  return (
    <div>
      <h2>Async Cleanup Demo</h2>

      <div>
        <button onClick={() => setUserId(userId + 1)}>
          Next User ({userId + 1})
        </button>
        <button onClick={() => setShowProfile(!showProfile)}>
          {showProfile ? 'Hide' : 'Show'} Profile
        </button>
      </div>

      {showProfile && <UserProfileFixed userId={userId} />}

      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>🧪 Test Race Condition:</h3>
        <ol>
          <li>Click "Next User" nhiều lần NHANH (mỗi 0.5s)</li>
          <li>Hoặc click "Hide Profile" trong khi loading</li>
        </ol>

        <h3>📋 Expected Behavior:</h3>
        <ul>
          <li>✅ Old requests marked as cancelled</li>
          <li>✅ No setState on unmounted component</li>
          <li>✅ Only latest request updates state</li>
          <li>✅ No console warnings</li>
        </ul>

        <h3>🎯 Cleanup Strategies:</h3>
        <ul>
          <li>
            <strong>v1 (Buggy):</strong> No cleanup → Warnings
          </li>
          <li>
            <strong>v2 (Flag):</strong> isCancelled flag → Works!
          </li>
          <li>
            <strong>v3 (AbortController):</strong> Actually cancel request →
            Best!
          </li>
        </ul>
      </div>
    </div>
  );
}

export default AsyncCleanupDemo;

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

⭐ Level 1: Áp Dụng Concept (15 phút)

jsx
/**
 * 🎯 Mục tiêu: Practice cleanup với setTimeout
 * ⏱️ Thời gian: 15 phút
 * 🚫 KHÔNG dùng: useRef, custom hooks
 *
 * Requirements:
 * 1. Notification component tự động ẩn sau 3 giây
 * 2. Dùng setTimeout trong useEffect
 * 3. Cleanup timeout khi component unmount
 * 4. Cleanup timeout khi message thay đổi (show new notification)
 *
 * 💡 Gợi ý:
 * - setTimeout return timeoutId
 * - clearTimeout(timeoutId) để cleanup
 * - Dependencies: [message]
 */

// ❌ Cách SAI (Anti-pattern):
function WrongNotification({ message }) {
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    // ❌ No cleanup → Timeout vẫn chạy sau unmount!
    setTimeout(() => {
      setVisible(false);
    }, 3000);
  }, [message]);

  if (!visible) return null;
  return <div className='notification'>{message}</div>;
}

// Tại sao sai?
// - Nếu message thay đổi trong 3 giây → Multiple timeouts!
// - Nếu component unmount → Timeout vẫn chạy → setState warning
// - Memory leak

// ✅ Cách ĐÚNG (Best practice):
function CorrectNotification({ message }) {
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    // Reset visible khi message thay đổi
    setVisible(true);

    const timeoutId = setTimeout(() => {
      setVisible(false);
    }, 3000);

    // ✅ Cleanup timeout
    return () => {
      clearTimeout(timeoutId);
    };
  }, [message]); // Re-run khi message thay đổi

  if (!visible) return null;
  return (
    <div
      style={{
        padding: '10px 20px',
        background: '#4CAF50',
        color: 'white',
        borderRadius: '4px',
        margin: '10px 0',
      }}
    >
      {message}
    </div>
  );
}

// Tại sao tốt hơn?
// ✅ Old timeout cleared khi message thay đổi
// ✅ Timeout cleared khi unmount
// ✅ No memory leaks
// ✅ No warnings

// 🎯 NHIỆM VỤ CỦA BẠN:
function Notification({ message, duration = 3000 }) {
  // TODO: State cho visible

  // TODO: useEffect với setTimeout
  // - Set visible = true khi message thay đổi
  // - setTimeout để set visible = false sau `duration`
  // - Return cleanup function để clearTimeout

  // TODO: Render notification nếu visible

  return null; // Replace this
}

// Test Component
function NotificationDemo() {
  const [message, setMessage] = useState('');
  const [count, setCount] = useState(0);

  const showNotification = () => {
    setMessage(`Notification #${count + 1}`);
    setCount(count + 1);
  };

  return (
    <div>
      <h2>Auto-Hide Notification</h2>

      <button onClick={showNotification}>Show Notification</button>

      <Notification
        message={message}
        duration={3000}
      />

      <div style={{ marginTop: '20px' }}>
        <h3>✅ Test Checklist:</h3>
        <ul>
          <li>Click button → Notification appears</li>
          <li>Wait 3s → Notification hides</li>
          <li>Click again quickly (before 3s) → Old notification replaced</li>
          <li>Console: No warnings</li>
        </ul>
      </div>
    </div>
  );
}
💡 Solution
jsx
/**
 * Notification component with auto-hide after duration
 * - Shows message when received
 * - Automatically hides after duration ms
 * - Cleans up timeout when message changes or component unmounts
 * - Prevents memory leaks and setState on unmounted component
 */
function Notification({ message, duration = 3000 }) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    // Khi message thay đổi → hiển thị lại và bắt đầu đếm ngược
    if (message) {
      setVisible(true);

      const timeoutId = setTimeout(() => {
        setVisible(false);
      }, duration);

      // Cleanup: hủy timeout cũ khi message thay đổi hoặc unmount
      return () => {
        clearTimeout(timeoutId);
      };
    }
  }, [message, duration]);

  if (!visible || !message) return null;

  return (
    <div
      style={{
        padding: '12px 20px',
        background: '#4CAF50',
        color: 'white',
        borderRadius: '6px',
        margin: '12px 0',
        boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
      }}
    >
      {message}
    </div>
  );
}

⭐⭐ Level 2: Nhận Biết Pattern (25 phút)

jsx
/**
 * 🎯 Mục tiêu: Cleanup multiple resources
 * ⏱️ Thời gian: 25 phút
 *
 * Scenario: Debounced search với multiple cleanups
 * Yêu cầu:
 * - Debounce input (500ms)
 * - Cancel pending searches
 * - Cleanup event listeners
 *
 * 🤔 PHÂN TÍCH:
 *
 * RESOURCES cần cleanup:
 * 1. setTimeout (debounce timer)
 * 2. Fetch request (if using AbortController)
 * 3. Event listeners (nếu có)
 *
 * APPROACH: Single effect với multiple cleanups
 * - Return cleanup function
 * - Cleanup ALL resources trong đó
 * - Order matters? Không, nhưng nên có comment
 *
 * 💭 IMPLEMENT STRATEGY
 */

// Mock search API
const searchProducts = (query) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const products = [
        'iPhone 15',
        'iPhone 14',
        'iPad Pro',
        'iPad Air',
        'MacBook Pro',
        'MacBook Air',
        'AirPods Pro',
      ];
      const filtered = products.filter((p) =>
        p.toLowerCase().includes(query.toLowerCase()),
      );
      resolve(filtered);
    }, 1000);
  });
};

function DebouncedSearch() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);

  // TODO: Effect 1 - Debounce query
  useEffect(() => {
    // TODO:
    // 1. Set isSearching = true
    // 2. setTimeout 500ms để setDebouncedQuery
    // 3. Return cleanup để clearTimeout
    // Dependencies: [query]
  }, [query]);

  // TODO: Effect 2 - Search khi debouncedQuery thay đổi
  useEffect(() => {
    // TODO:
    // 1. Nếu debouncedQuery empty → Clear results
    // 2. Nếu có query → Call searchProducts
    // 3. Dùng isCancelled flag để prevent setState sau unmount
    // 4. Return cleanup để set isCancelled = true
    // Dependencies: [debouncedQuery]
  }, [debouncedQuery]);

  return (
    <div>
      <h2>Debounced Search</h2>

      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='Search products...'
        style={{ padding: '10px', width: '300px', fontSize: '16px' }}
      />

      {isSearching && <p>🔍 Searching...</p>}

      <div>
        <h3>Results:</h3>
        {results.length > 0 ? (
          <ul>
            {results.map((product, i) => (
              <li key={i}>{product}</li>
            ))}
          </ul>
        ) : (
          debouncedQuery && <p>No results found.</p>
        )}
      </div>

      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>🧪 Test Cleanup:</h3>
        <ol>
          <li>Type "iphone" NHANH (mỗi 100ms một chữ)</li>
          <li>Console: Chỉ 1 search SAU KHI ngừng typing 500ms</li>
          <li>Type "ip" → Wait → "ad" → 2 searches (debounced)</li>
          <li>Clear input nhanh → Search cancelled</li>
        </ol>

        <h3>📋 Cleanup Points:</h3>
        <ul>
          <li>✅ Timeout cleared khi typing continues</li>
          <li>✅ Search cancelled khi new query arrives</li>
          <li>✅ No setState on unmounted component</li>
        </ul>
      </div>
    </div>
  );
}

export default DebouncedSearch;
💡 Solution
jsx
/**
 * Debounced Search component
 * - Debounces input changes (500ms)
 * - Cancels pending searches when new query arrives
 * - Cleans up debounce timeout and fetch cancellation
 * - Prevents setState on unmounted component
 */
function DebouncedSearch() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);

  // Effect 1: Debounce input
  useEffect(() => {
    setIsSearching(true);

    const timeoutId = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

    return () => {
      clearTimeout(timeoutId);
      setIsSearching(false);
    };
  }, [query]);

  // Effect 2: Perform search when debounced query changes
  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      setIsSearching(false);
      return;
    }

    let isCancelled = false;
    setIsSearching(true);

    searchProducts(debouncedQuery)
      .then((data) => {
        if (!isCancelled) {
          setResults(data);
          setIsSearching(false);
        }
      })
      .catch((err) => {
        if (!isCancelled) {
          console.error('Search error:', err);
          setIsSearching(false);
        }
      });

    return () => {
      isCancelled = true;
      setIsSearching(false);
    };
  }, [debouncedQuery]);

  return (
    <div>
      <h2>Debounced Search</h2>
      <input
        type='text'
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder='Search products...'
      />
      {isSearching && <p>🔍 Searching...</p>}
      <div>
        <h3>Results:</h3>
        {results.length > 0 ? (
          <ul>
            {results.map((product, i) => (
              <li key={i}>{product}</li>
            ))}
          </ul>
        ) : (
          debouncedQuery && <p>No results found.</p>
        )}
      </div>
    </div>
  );
}

⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)

jsx
/**
 * 🎯 Mục tiêu: Real-time Chat Subscription
 * ⏱️ Thời gian: 40 phút
 *
 * 📋 Product Requirements:
 * User Story: "Là user, tôi muốn nhận messages real-time
 * từ chat room, và unsubscribe khi rời khỏi room"
 *
 * ✅ Acceptance Criteria:
 * - [ ] Subscribe to chat room khi component mount
 * - [ ] Receive và display messages real-time
 * - [ ] Unsubscribe khi switch rooms
 * - [ ] Unsubscribe khi component unmount
 * - [ ] No memory leaks
 * - [ ] Handle connection errors
 *
 * 🎨 Technical Constraints:
 * - Simulate WebSocket với setInterval
 * - Cleanup subscription properly
 * - Handle multiple room switches
 *
 * 🚨 Edge Cases cần handle:
 * - Switch room nhanh (< 1s) → Cancel old subscription
 * - Component unmount while receiving → No setState
 * - Reconnection logic (optional)
 *
 * 📝 Implementation Checklist:
 * - [ ] State cho messages array
 * - [ ] State cho current room
 * - [ ] Effect để subscribe/unsubscribe
 * - [ ] Cleanup function comprehensive
 * - [ ] UI cho room selection
 */

// Mock Chat Service
class ChatService {
  constructor() {
    this.subscriptions = new Map();
  }

  subscribe(roomId, callback) {
    console.log(`📡 Subscribing to room: ${roomId}`);

    // Simulate receiving messages every 2 seconds
    const intervalId = setInterval(() => {
      const message = {
        id: Date.now(),
        roomId,
        text: `Message from ${roomId} at ${new Date().toLocaleTimeString()}`,
        sender: `User${Math.floor(Math.random() * 10)}`,
      };
      callback(message);
    }, 2000);

    this.subscriptions.set(roomId, intervalId);

    // Return unsubscribe function
    return () => {
      console.log(`📴 Unsubscribing from room: ${roomId}`);
      clearInterval(intervalId);
      this.subscriptions.delete(roomId);
    };
  }
}

const chatService = new ChatService();

// 🎯 STARTER CODE:
function ChatRoom() {
  const [currentRoom, setCurrentRoom] = useState('general');
  const [messages, setMessages] = useState([]);
  const [isConnected, setIsConnected] = useState(false);

  // TODO: Effect - Subscribe to chat room
  useEffect(() => {
    console.log(`✅ Setting up subscription for room: ${currentRoom}`);
    setIsConnected(true);
    setMessages([]); // Clear old messages

    // Subscribe to room
    const unsubscribe = chatService.subscribe(currentRoom, (message) => {
      // TODO: Add message to state
      // Hint: setMessages(prev => [...prev, message])
    });

    // TODO: Cleanup function
    return () => {
      console.log(`🧹 Cleaning up subscription for room: ${currentRoom}`);
      unsubscribe(); // Call unsubscribe function
      setIsConnected(false);
    };
  }, [currentRoom]); // Re-subscribe khi room thay đổi

  const rooms = ['general', 'random', 'tech', 'sports'];

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <h2>Real-time Chat Room</h2>

      {/* Room Selection */}
      <div style={{ marginBottom: '20px' }}>
        <strong>Select Room:</strong>
        <div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
          {rooms.map((room) => (
            <button
              key={room}
              onClick={() => setCurrentRoom(room)}
              style={{
                padding: '10px 20px',
                background: currentRoom === room ? '#4CAF50' : '#ddd',
                color: currentRoom === room ? 'white' : 'black',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
              }}
            >
              #{room}
            </button>
          ))}
        </div>
      </div>

      {/* Connection Status */}
      <div
        style={{
          padding: '10px',
          background: isConnected ? '#4CAF50' : '#f44336',
          color: 'white',
          borderRadius: '4px',
          marginBottom: '20px',
        }}
      >
        {isConnected ? '🟢 Connected' : '🔴 Disconnected'} to #{currentRoom}
      </div>

      {/* Messages */}
      <div
        style={{
          border: '1px solid #ddd',
          borderRadius: '4px',
          padding: '10px',
          minHeight: '300px',
          maxHeight: '400px',
          overflowY: 'auto',
          background: '#f9f9f9',
        }}
      >
        {messages.length === 0 ? (
          <p style={{ textAlign: 'center', color: '#999' }}>
            Waiting for messages...
          </p>
        ) : (
          messages.map((msg) => (
            <div
              key={msg.id}
              style={{
                padding: '10px',
                margin: '5px 0',
                background: 'white',
                borderRadius: '4px',
                border: '1px solid #eee',
              }}
            >
              <strong>{msg.sender}:</strong> {msg.text}
            </div>
          ))
        )}
      </div>

      {/* Instructions */}
      <div
        style={{ marginTop: '20px', padding: '10px', background: '#f0f0f0' }}
      >
        <h3>🧪 Test Cleanup:</h3>
        <ol>
          <li>Mở Console</li>
          <li>Đợi messages xuất hiện trong "general"</li>
          <li>Switch sang "tech" → Quan sát cleanup log</li>
          <li>Switch nhanh giữa rooms → Mỗi switch trigger cleanup</li>
        </ol>

        <h3>✅ Expected Behavior:</h3>
        <ul>
          <li>✅ Old subscription cancelled khi switch room</li>
          <li>✅ Messages cleared khi switch</li>
          <li>✅ Only current room receives messages</li>
          <li>✅ No console warnings</li>
        </ul>
      </div>
    </div>
  );
}

export default ChatRoom;
💡 Solution
jsx
/**
 * Real-time Chat Room component using simulated subscription
 * - Subscribes to selected chat room
 * - Receives messages in real-time (simulated every 2s)
 * - Properly unsubscribes when switching rooms or unmounting
 * - Clears messages when changing rooms
 * - Prevents memory leaks from leftover intervals
 */
function ChatRoom() {
  const [currentRoom, setCurrentRoom] = useState('general');
  const [messages, setMessages] = useState([]);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    console.log(`✅ Setting up subscription for room: ${currentRoom}`);

    // Reset state khi đổi room
    setMessages([]);
    setIsConnected(true);

    // Subscribe và nhận unsubscribe function
    const unsubscribe = chatService.subscribe(currentRoom, (message) => {
      setMessages((prev) => [...prev, message]);
    });

    // Cleanup: unsubscribe khi room thay đổi hoặc component unmount
    return () => {
      console.log(`🧹 Cleaning up subscription for room: ${currentRoom}`);
      unsubscribe();
      setIsConnected(false);
    };
  }, [currentRoom]);

  const rooms = ['general', 'random', 'tech', 'sports'];

  return (
    <div>
      <h2>Real-time Chat Room</h2>

      <div>
        <strong>Select Room:</strong>
        <div style={{ margin: '10px 0' }}>
          {rooms.map((room) => (
            <button
              key={room}
              onClick={() => setCurrentRoom(room)}
              style={{
                margin: '0 8px 8px 0',
                padding: '8px 16px',
                background: currentRoom === room ? '#4CAF50' : '#e0e0e0',
                color: currentRoom === room ? 'white' : 'black',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
              }}
            >
              #{room}
            </button>
          ))}
        </div>
      </div>

      <div
        style={{
          padding: '10px',
          background: isConnected ? '#4CAF50' : '#f44336',
          color: 'white',
          borderRadius: '4px',
          marginBottom: '16px',
          display: 'inline-block',
        }}
      >
        {isConnected ? '🟢 Connected' : '🔴 Disconnected'} to #{currentRoom}
      </div>

      <div
        style={{
          border: '1px solid #ddd',
          borderRadius: '6px',
          padding: '16px',
          minHeight: '300px',
          maxHeight: '400px',
          overflowY: 'auto',
          background: '#fafafa',
        }}
      >
        {messages.length === 0 ? (
          <p style={{ textAlign: 'center', color: '#888', marginTop: '100px' }}>
            Waiting for messages in #{currentRoom}...
          </p>
        ) : (
          messages.map((msg) => (
            <div
              key={msg.id}
              style={{
                marginBottom: '12px',
                padding: '10px',
                background: 'white',
                borderRadius: '6px',
                border: '1px solid #eee',
              }}
            >
              <strong>{msg.sender}</strong>: {msg.text}
              <div
                style={{ fontSize: '11px', color: '#888', marginTop: '4px' }}
              >
                {msg.roomId} • {new Date(msg.id).toLocaleTimeString()}
              </div>
            </div>
          ))
        )}
      </div>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Mount → thấy "Setting up subscription for room: general" và bắt đầu nhận message mỗi ~2s
- Chuyển sang "tech" → thấy "Cleaning up subscription for room: general" → "Setting up subscription for room: tech"
- Messages cũ bị xóa, chỉ nhận message từ room mới
- Chuyển room nhanh liên tục → mỗi lần cũ đều được cleanup trước khi tạo mới
- Unmount component (ẩn ChatRoom) → thấy log cleanup cuối cùng, interval dừng hoàn toàn
- Không còn interval chạy ngầm, không warning setState trên unmounted component
*/

⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)

jsx
/**
 * 🎯 Mục tiêu: Analytics Tracker với Multiple Cleanup Strategies
 * ⏱️ Thời gian: 60 phút
 *
 * 🏗️ PHASE 1: Research & Design (20 phút)
 *
 * Context:
 * Xây dựng Analytics tracker theo dõi:
 * - Page views
 * - Time on page
 * - Click events
 * - Scroll depth
 * - User activity (active/idle)
 *
 * Mỗi metric cần cleanup strategy khác nhau:
 * 1. Timer-based (time on page) → clearInterval
 * 2. Event-based (clicks, scroll) → removeEventListener
 * 3. Batching (gửi batch sau N seconds) → clearTimeout + send remaining
 * 4. Visibility (track tab active) → removeEventListener
 *
 * APPROACH OPTIONS:
 *
 * APPROACH 1: Multiple effects, mỗi effect 1 cleanup
 * Pros:
 * - Separation of concerns rõ ràng
 * - Dễ debug từng metric
 * - Dễ enable/disable individual trackers
 * Cons:
 * - Nhiều effects (4-5 effects)
 * - Có thể conflicts giữa effects
 *
 * APPROACH 2: Single effect, tất cả trong 1, return combined cleanup
 * Pros:
 * - Gọn hơn, 1 effect duy nhất
 * - Centralized logic
 * Cons:
 * - Khó đọc, logic phức tạp
 * - Khó maintain
 * - All-or-nothing (khó disable 1 tracker)
 *
 * APPROACH 3: Hybrid - Group related metrics
 * Pros:
 * - Balance clarity vs compactness
 * - Effect 1: Timers (time on page, batching)
 * - Effect 2: Events (clicks, scroll, visibility)
 * - Có thể deps khác nhau
 * Cons:
 * - Vẫn cần cẩn thận với interactions
 *
 * 💭 RECOMMENDATION: Approach 1 (Multiple Effects)
 * Lý do: Clarity > Brevity, easier to maintain
 *
 * ADR:
 * ---
 * # ADR: Analytics Cleanup Strategy
 *
 * ## Context
 * Track multiple metrics, mỗi metric cần cleanup khác nhau
 *
 * ## Decision
 * Multiple effects, mỗi effect responsible cho 1 concern
 *
 * ## Rationale
 * - Clarity: Mỗi effect rõ ràng purpose
 * - Maintainability: Dễ update/remove individual trackers
 * - Debuggability: Console.log từng effect
 * - Flexibility: Enable/disable với flags
 *
 * ## Consequences
 * - More effects (4-5)
 * - Potential slight performance overhead (negligible)
 * - Easier to reason about
 * ---
 */

// 💻 PHASE 2: Implementation (30 phút)

import { useState, useEffect } from 'react';

function AnalyticsTracker({ enableTracking = true }) {
  // Analytics data
  const [analytics, setAnalytics] = useState({
    pageViews: 0,
    timeOnPage: 0,
    clicks: 0,
    maxScrollDepth: 0,
    isActive: true,
    events: [],
  });

  // Effect 1: Page View Tracking
  useEffect(() => {
    if (!enableTracking) return;

    console.log('📊 Tracking page view');

    setAnalytics((prev) => ({
      ...prev,
      pageViews: prev.pageViews + 1,
    }));

    // Send to analytics server (simulated)
    const sendPageView = () => {
      console.log('📤 Sending page view event');
      // analytics.track('page_view', { ... });
    };
    sendPageView();

    // No cleanup needed (one-time event)
  }, [enableTracking]); // Re-track nếu enable thay đổi

  // Effect 2: Time on Page
  useEffect(() => {
    if (!enableTracking) return;

    console.log('⏱️ Starting time tracker');

    const intervalId = setInterval(() => {
      setAnalytics((prev) => ({
        ...prev,
        timeOnPage: prev.timeOnPage + 1,
      }));
    }, 1000);

    return () => {
      console.log('🧹 Stopping time tracker');
      clearInterval(intervalId);
    };
  }, [enableTracking]);

  // Effect 3: Click Tracking
  useEffect(() => {
    if (!enableTracking) return;

    console.log('🖱️ Adding click listener');

    const handleClick = (e) => {
      setAnalytics((prev) => ({
        ...prev,
        clicks: prev.clicks + 1,
        events: [
          ...prev.events,
          {
            type: 'click',
            target: e.target.tagName,
            time: Date.now(),
          },
        ].slice(-10), // Keep last 10
      }));
    };

    document.addEventListener('click', handleClick);

    return () => {
      console.log('🧹 Removing click listener');
      document.removeEventListener('click', handleClick);
    };
  }, [enableTracking]);

  // Effect 4: Scroll Depth Tracking
  useEffect(() => {
    if (!enableTracking) return;

    console.log('📜 Adding scroll listener');

    const handleScroll = () => {
      const scrollPercent = Math.round(
        (window.scrollY /
          (document.documentElement.scrollHeight - window.innerHeight)) *
          100,
      );

      setAnalytics((prev) => ({
        ...prev,
        maxScrollDepth: Math.max(prev.maxScrollDepth, scrollPercent || 0),
      }));
    };

    window.addEventListener('scroll', handleScroll);

    return () => {
      console.log('🧹 Removing scroll listener');
      window.removeEventListener('scroll', handleScroll);
    };
  }, [enableTracking]);

  // Effect 5: Visibility / Activity Tracking
  useEffect(() => {
    if (!enableTracking) return;

    console.log('👁️ Adding visibility listener');

    const handleVisibilityChange = () => {
      setAnalytics((prev) => ({
        ...prev,
        isActive: !document.hidden,
      }));
    };

    document.addEventListener('visibilitychange', handleVisibilityChange);

    return () => {
      console.log('🧹 Removing visibility listener');
      document.removeEventListener('visibilitychange', handleVisibilityChange);
    };
  }, [enableTracking]);

  // Effect 6: Batch Send (every 10 seconds)
  useEffect(() => {
    if (!enableTracking) return;

    console.log('📦 Starting batch sender');

    const batchInterval = setInterval(() => {
      console.log('📤 Sending analytics batch:', analytics);
      // Send to server: analytics.batch(analytics);
    }, 10000);

    // Cleanup: Send remaining data immediately
    return () => {
      console.log('🧹 Sending final batch before cleanup');
      console.log('📤 Final analytics:', analytics);
      clearInterval(batchInterval);
      // analytics.batch(analytics);
    };
  }, [enableTracking, analytics]); // Note: analytics in deps để send latest

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

      {/* Stats Dashboard */}
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
          gap: '10px',
          marginBottom: '20px',
        }}
      >
        <StatCard
          title='Page Views'
          value={analytics.pageViews}
          icon='📊'
        />
        <StatCard
          title='Time on Page'
          value={`${analytics.timeOnPage}s`}
          icon='⏱️'
        />
        <StatCard
          title='Clicks'
          value={analytics.clicks}
          icon='🖱️'
        />
        <StatCard
          title='Scroll Depth'
          value={`${analytics.maxScrollDepth}%`}
          icon='📜'
        />
        <StatCard
          title='Status'
          value={analytics.isActive ? 'Active' : 'Idle'}
          icon={analytics.isActive ? '🟢' : '⚪'}
        />
      </div>

      {/* Recent Events */}
      <div style={{ marginBottom: '20px' }}>
        <h3>Recent Events (Last 10):</h3>
        <div
          style={{
            maxHeight: '200px',
            overflowY: 'auto',
            border: '1px solid #ddd',
            borderRadius: '4px',
            padding: '10px',
          }}
        >
          {analytics.events.length === 0 ? (
            <p>No events yet. Click around!</p>
          ) : (
            analytics.events.map((event, i) => (
              <div
                key={i}
                style={{ padding: '5px', borderBottom: '1px solid #eee' }}
              >
                {event.type} on {event.target} at{' '}
                {new Date(event.time).toLocaleTimeString()}
              </div>
            ))
          )}
        </div>
      </div>

      {/* Test Content */}
      <div style={{ marginTop: '40px' }}>
        <h3>Test Content (Scroll, Click, etc.)</h3>
        {[...Array(20)].map((_, i) => (
          <p
            key={i}
            style={{ marginBottom: '20px' }}
          >
            Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur
            adipiscing elit. Click me! Scroll past me! Switch tabs!
          </p>
        ))}
      </div>

      {/* Instructions */}
      <div
        style={{
          position: 'fixed',
          bottom: '20px',
          right: '20px',
          background: 'white',
          border: '2px solid #4CAF50',
          borderRadius: '8px',
          padding: '15px',
          maxWidth: '300px',
          boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
        }}
      >
        <h4>🧪 Test Cleanup:</h4>
        <ol style={{ fontSize: '14px', paddingLeft: '20px' }}>
          <li>Mở Console</li>
          <li>Scroll page</li>
          <li>Click vài lần</li>
          <li>Switch tab (visibility)</li>
          <li>Navigate away → Observe cleanup logs</li>
        </ol>
      </div>
    </div>
  );
}

function StatCard({ title, value, icon }) {
  return (
    <div
      style={{
        padding: '15px',
        background: '#f5f5f5',
        borderRadius: '8px',
        textAlign: 'center',
      }}
    >
      <div style={{ fontSize: '24px', marginBottom: '5px' }}>{icon}</div>
      <div style={{ fontSize: '12px', color: '#666', marginBottom: '5px' }}>
        {title}
      </div>
      <div style={{ fontSize: '20px', fontWeight: 'bold' }}>{value}</div>
    </div>
  );
}

export default AnalyticsTracker;

// 🧪 PHASE 3: Testing (10 phút)
// Manual testing checklist:
// - [ ] All 6 effects set up on mount (check Console)
// - [ ] Time tracker increments every second
// - [ ] Clicks tracked and displayed
// - [ ] Scroll depth updates
// - [ ] Tab visibility changes detected
// - [ ] Batch sends every 10s
// - [ ] Navigate away → All 6 cleanups execute
// - [ ] No console warnings
// - [ ] Final batch sent with latest data

// 📋 PRODUCTION CONSIDERATIONS:
// - Error handling trong effects (try/catch)
// - Throttle scroll/click handlers
// - localStorage persistence
// - Server API integration
// - Privacy compliance (GDPR)
// - Opt-out mechanism
💡 Solution
jsx
/**
 * Analytics Tracker component with multiple cleanup effects
 * - Tracks page views, time on page, clicks, scroll depth, visibility
 * - Uses separate useEffect for each concern → clear separation
 * - All resources properly cleaned up on unmount or when tracking disabled
 * - Sends final batch on cleanup
 */
function AnalyticsTracker({ enableTracking = true }) {
  const [analytics, setAnalytics] = useState({
    pageViews: 0,
    timeOnPage: 0,
    clicks: 0,
    maxScrollDepth: 0,
    isActive: true,
    events: [],
  });

  // Effect 1: Page View (one-time on mount/enable)
  useEffect(() => {
    if (!enableTracking) return;

    setAnalytics((prev) => ({ ...prev, pageViews: prev.pageViews + 1 }));

    console.log('📤 Page view tracked');

    // No cleanup needed for one-time event
  }, [enableTracking]);

  // Effect 2: Time on Page
  useEffect(() => {
    if (!enableTracking) return;

    const intervalId = setInterval(() => {
      setAnalytics((prev) => ({
        ...prev,
        timeOnPage: prev.timeOnPage + 1,
      }));
    }, 1000);

    return () => {
      console.log('🧹 Clearing time tracker interval');
      clearInterval(intervalId);
    };
  }, [enableTracking]);

  // Effect 3: Click Tracking
  useEffect(() => {
    if (!enableTracking) return;

    const handleClick = (e) => {
      setAnalytics((prev) => ({
        ...prev,
        clicks: prev.clicks + 1,
        events: [
          ...prev.events,
          { type: 'click', target: e.target.tagName, time: Date.now() },
        ].slice(-10),
      }));
    };

    document.addEventListener('click', handleClick);

    return () => {
      console.log('🧹 Removing click listener');
      document.removeEventListener('click', handleClick);
    };
  }, [enableTracking]);

  // Effect 4: Scroll Depth
  useEffect(() => {
    if (!enableTracking) return;

    const handleScroll = () => {
      const scrollPercent =
        Math.round(
          (window.scrollY /
            (document.documentElement.scrollHeight - window.innerHeight)) *
            100,
        ) || 0;

      setAnalytics((prev) => ({
        ...prev,
        maxScrollDepth: Math.max(prev.maxScrollDepth, scrollPercent),
      }));
    };

    window.addEventListener('scroll', handleScroll, { passive: true });

    return () => {
      console.log('🧹 Removing scroll listener');
      window.removeEventListener('scroll', handleScroll);
    };
  }, [enableTracking]);

  // Effect 5: Visibility Change
  useEffect(() => {
    if (!enableTracking) return;

    const handleVisibility = () => {
      setAnalytics((prev) => ({
        ...prev,
        isActive: !document.hidden,
      }));
    };

    document.addEventListener('visibilitychange', handleVisibility);

    return () => {
      console.log('🧹 Removing visibility listener');
      document.removeEventListener('visibilitychange', handleVisibility);
    };
  }, [enableTracking]);

  // Effect 6: Batch sending (every 10s) + final send on cleanup
  useEffect(() => {
    if (!enableTracking) return;

    const batchInterval = setInterval(() => {
      console.log('📤 Sending batch:', analytics);
      // In production: send to server
    }, 10000);

    return () => {
      console.log('🧹 Final batch before unmount / disable');
      console.log('📤 Final analytics data:', analytics);
      // In production: send remaining data immediately
      clearInterval(batchInterval);
    };
  }, [enableTracking, analytics]);

  return (
    <div>
      <h2>Analytics Tracker</h2>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
          gap: '12px',
        }}
      >
        <div>Page Views: {analytics.pageViews}</div>
        <div>Time on Page: {analytics.timeOnPage}s</div>
        <div>Clicks: {analytics.clicks}</div>
        <div>Max Scroll: {analytics.maxScrollDepth}%</div>
        <div>Status: {analytics.isActive ? 'Active 🟢' : 'Idle ⚪'}</div>
      </div>

      <h3>Recent Events (last 10)</h3>
      {analytics.events.length === 0 ? (
        <p>No events yet</p>
      ) : (
        <ul>
          {analytics.events.map((ev, i) => (
            <li key={i}>
              {ev.type} on {ev.target} at{' '}
              {new Date(ev.time).toLocaleTimeString()}
            </li>
          ))}
        </ul>
      )}

      {/* Test content to scroll & click */}
      <div style={{ marginTop: '40px', height: '1200px' }}>
        <p>Scroll down and click around to generate events...</p>
        {[...Array(30)].map((_, i) => (
          <p key={i}>Paragraph {i + 1} - Click me!</p>
        ))}
      </div>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Mount → tất cả 6 effects setup (xem console)
- Time on page tăng mỗi giây
- Click → clicks +1, event ghi lại
- Scroll → maxScrollDepth cập nhật
- Chuyển tab → isActive thay đổi
- Mỗi 10s → batch log
- Disable tracking (enableTracking=false) hoặc unmount → tất cả cleanups chạy
  → intervals cleared, listeners removed, final batch sent
- Không warning, không leak (kiểm tra Memory tab trong DevTools)
*/

⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)

jsx
/**
 * 🎯 Mục tiêu: Video Player với Comprehensive Cleanup
 * ⏱️ Thời gian: 90 phút
 *
 * 📋 Feature Specification:
 * Xây dựng custom video player với:
 * 1. Play/Pause controls
 * 2. Progress bar (updates mỗi giây)
 * 3. Volume control
 * 4. Fullscreen toggle
 * 5. Keyboard shortcuts
 * 6. Auto-save playback position
 * 7. Picture-in-Picture mode
 * 8. Playback speed control
 *
 * 🏗️ Technical Design Doc:
 *
 * 1. Component Architecture:
 *    - VideoPlayer (parent)
 *    - VideoControls (UI controls)
 *    - ProgressBar (seekable)
 *    - VolumeSlider
 *
 * 2. State Management:
 *    - isPlaying, currentTime, duration, volume
 *    - isFullscreen, isPiP, playbackRate
 *
 * 3. Cleanup Requirements (CRITICAL!):
 *    - Effect 1: Progress interval → clearInterval
 *    - Effect 2: Keyboard listeners → removeEventListener (multiple keys)
 *    - Effect 3: Fullscreen listeners → removeEventListener
 *    - Effect 4: Auto-save timer → clearTimeout + save final position
 *    - Effect 5: Video element listeners → removeEventListener (ended, error, etc.)
 *    - Effect 6: PiP listeners → removeEventListener
 *
 * 4. Performance Considerations:
 *    - Throttle progress updates
 *    - Debounce auto-save
 *    - Cancel pending saves on unmount
 *
 * 5. Error Handling:
 *    - Video load errors
 *    - Fullscreen API errors
 *    - PiP not supported
 *    - localStorage errors
 *
 * ✅ Production Checklist:
 * - [ ] All intervals/timeouts cleaned up
 * - [ ] All event listeners removed
 * - [ ] Video playback stopped on unmount
 * - [ ] Auto-save executed before unmount
 * - [ ] No memory leaks
 * - [ ] Keyboard shortcuts disabled on unmount
 * - [ ] Fullscreen exited on unmount
 * - [ ] PiP closed on unmount
 * - [ ] Error boundaries (basic)
 * - [ ] Accessibility (ARIA labels)
 *
 * 📝 Documentation:
 * - Comment each cleanup
 * - Explain WHY cleanup needed
 * - Document keyboard shortcuts
 */

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

function VideoPlayer({ src, autoplay = false }) {
  const videoRef = useRef(null);

  // Playback state
  const [isPlaying, setIsPlaying] = useState(autoplay);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolume] = useState(1);
  const [playbackRate, setPlaybackRate] = useState(1);

  // UI state
  const [isFullscreen, setIsFullscreen] = useState(false);
  const [isPiP, setIsPiP] = useState(false);
  const [showControls, setShowControls] = useState(true);

  // Auto-save state
  const [lastSaved, setLastSaved] = useState(null);

  // TODO: Effect 1 - Video Element Event Listeners
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    console.log('🎬 Setting up video listeners');

    const handleLoadedMetadata = () => {
      setDuration(video.duration);
      console.log('✅ Video metadata loaded');
    };

    const handleEnded = () => {
      setIsPlaying(false);
      console.log('🏁 Video ended');
    };

    const handleError = (e) => {
      console.error('❌ Video error:', e);
      // TODO: Show error UI
    };

    // Add listeners
    video.addEventListener('loadedmetadata', handleLoadedMetadata);
    video.addEventListener('ended', handleEnded);
    video.addEventListener('error', handleError);

    // Cleanup
    return () => {
      console.log('🧹 Cleaning up video listeners');
      video.removeEventListener('loadedmetadata', handleLoadedMetadata);
      video.removeEventListener('ended', handleEnded);
      video.removeEventListener('error', handleError);
    };
  }, [src]); // Re-setup khi video source thay đổi

  // TODO: Effect 2 - Play/Pause Control
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    if (isPlaying) {
      video.play().catch((err) => {
        console.error('Play error:', err);
        setIsPlaying(false);
      });
    } else {
      video.pause();
    }
  }, [isPlaying]);

  // TODO: Effect 3 - Progress Tracker
  useEffect(() => {
    if (!isPlaying) return;

    console.log('⏱️ Starting progress tracker');

    const intervalId = setInterval(() => {
      const video = videoRef.current;
      if (video) {
        setCurrentTime(video.currentTime);
      }
    }, 1000);

    return () => {
      console.log('🧹 Stopping progress tracker');
      clearInterval(intervalId);
    };
  }, [isPlaying]);

  // TODO: Effect 4 - Keyboard Shortcuts
  useEffect(() => {
    console.log('⌨️ Setting up keyboard shortcuts');

    const handleKeyPress = (e) => {
      // Space: Play/Pause
      if (e.code === 'Space') {
        e.preventDefault();
        setIsPlaying((prev) => !prev);
      }
      // F: Fullscreen
      else if (e.code === 'KeyF') {
        e.preventDefault();
        toggleFullscreen();
      }
      // M: Mute
      else if (e.code === 'KeyM') {
        e.preventDefault();
        setVolume((prev) => (prev === 0 ? 1 : 0));
      }
      // Arrow Left: -5s
      else if (e.code === 'ArrowLeft') {
        e.preventDefault();
        const video = videoRef.current;
        if (video) video.currentTime = Math.max(0, video.currentTime - 5);
      }
      // Arrow Right: +5s
      else if (e.code === 'ArrowRight') {
        e.preventDefault();
        const video = videoRef.current;
        if (video)
          video.currentTime = Math.min(duration, video.currentTime + 5);
      }
    };

    document.addEventListener('keydown', handleKeyPress);

    return () => {
      console.log('🧹 Removing keyboard shortcuts');
      document.removeEventListener('keydown', handleKeyPress);
    };
  }, [duration]); // duration needed for arrow keys

  // TODO: Effect 5 - Auto-save Playback Position
  useEffect(() => {
    console.log('💾 Setting up auto-save');

    const timeoutId = setTimeout(() => {
      // Save to localStorage
      try {
        localStorage.setItem('videoPlaybackPosition', currentTime.toString());
        setLastSaved(new Date());
        console.log('💾 Auto-saved position:', currentTime);
      } catch (err) {
        console.error('Save error:', err);
      }
    }, 3000); // Debounce 3s

    // Cleanup: Save immediately before unmount
    return () => {
      console.log('🧹 Saving final position before cleanup');
      clearTimeout(timeoutId);

      try {
        localStorage.setItem('videoPlaybackPosition', currentTime.toString());
        console.log('💾 Final save:', currentTime);
      } catch (err) {
        console.error('Save error:', err);
      }
    };
  }, [currentTime]);

  // TODO: Effect 6 - Load Saved Position (mount only)
  useEffect(() => {
    console.log('📂 Loading saved position');

    try {
      const savedPosition = localStorage.getItem('videoPlaybackPosition');
      if (savedPosition && videoRef.current) {
        const position = parseFloat(savedPosition);
        videoRef.current.currentTime = position;
        setCurrentTime(position);
        console.log('✅ Loaded position:', position);
      }
    } catch (err) {
      console.error('Load error:', err);
    }
  }, []); // Empty deps → Only on mount

  // TODO: Effect 7 - Volume Sync
  useEffect(() => {
    const video = videoRef.current;
    if (video) {
      video.volume = volume;
    }
  }, [volume]);

  // TODO: Effect 8 - Playback Rate Sync
  useEffect(() => {
    const video = videoRef.current;
    if (video) {
      video.playbackRate = playbackRate;
    }
  }, [playbackRate]);

  // TODO: Effect 9 - Fullscreen Listeners
  useEffect(() => {
    console.log('🖥️ Setting up fullscreen listeners');

    const handleFullscreenChange = () => {
      setIsFullscreen(!!document.fullscreenElement);
    };

    document.addEventListener('fullscreenchange', handleFullscreenChange);

    // Cleanup: Exit fullscreen
    return () => {
      console.log('🧹 Exiting fullscreen');
      document.removeEventListener('fullscreenchange', handleFullscreenChange);

      if (document.fullscreenElement) {
        document.exitFullscreen().catch((err) => {
          console.error('Exit fullscreen error:', err);
        });
      }
    };
  }, []);

  // TODO: Effect 10 - Picture-in-Picture Listeners
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    console.log('📺 Setting up PiP listeners');

    const handlePiPEnter = () => {
      setIsPiP(true);
      console.log('📺 Entered PiP');
    };

    const handlePiPLeave = () => {
      setIsPiP(false);
      console.log('📺 Left PiP');
    };

    video.addEventListener('enterpictureinpicture', handlePiPEnter);
    video.addEventListener('leavepictureinpicture', handlePiPLeave);

    // Cleanup: Exit PiP
    return () => {
      console.log('🧹 Exiting PiP');
      video.removeEventListener('enterpictureinpicture', handlePiPEnter);
      video.removeEventListener('leavepictureinpicture', handlePiPLeave);

      if (document.pictureInPictureElement) {
        document.exitPictureInPicture().catch((err) => {
          console.error('Exit PiP error:', err);
        });
      }
    };
  }, []);

  // Helper functions
  const togglePlay = () => {
    setIsPlaying(!isPlaying);
  };

  const toggleFullscreen = () => {
    if (!document.fullscreenElement) {
      videoRef.current?.requestFullscreen();
    } else {
      document.exitFullscreen();
    }
  };

  const togglePiP = async () => {
    try {
      if (document.pictureInPictureElement) {
        await document.exitPictureInPicture();
      } else {
        await videoRef.current?.requestPictureInPicture();
      }
    } catch (err) {
      console.error('PiP error:', err);
    }
  };

  const handleSeek = (e) => {
    const video = videoRef.current;
    if (video) {
      const rect = e.currentTarget.getBoundingClientRect();
      const pos = (e.clientX - rect.left) / rect.width;
      video.currentTime = pos * duration;
      setCurrentTime(pos * duration);
    }
  };

  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={{
        maxWidth: '800px',
        margin: '0 auto',
        padding: '20px',
        background: '#000',
        borderRadius: '8px',
      }}
    >
      {/* Video Element */}
      <video
        ref={videoRef}
        src={src}
        style={{
          width: '100%',
          borderRadius: '4px',
          display: 'block',
        }}
        onClick={togglePlay}
      />

      {/* Controls */}
      <div
        style={{
          padding: '15px',
          background: '#1a1a1a',
          borderRadius: '0 0 8px 8px',
        }}
      >
        {/* Progress Bar */}
        <div
          onClick={handleSeek}
          style={{
            height: '8px',
            background: '#333',
            borderRadius: '4px',
            cursor: 'pointer',
            marginBottom: '15px',
            position: 'relative',
          }}
        >
          <div
            style={{
              width: `${(currentTime / duration) * 100}%`,
              height: '100%',
              background: '#4CAF50',
              borderRadius: '4px',
            }}
          />
        </div>

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

        {/* Control Buttons */}
        <div
          style={{
            display: 'flex',
            gap: '10px',
            alignItems: 'center',
            flexWrap: 'wrap',
          }}
        >
          <button
            onClick={togglePlay}
            style={buttonStyle}
          >
            {isPlaying ? '⏸️ Pause' : '▶️ Play'}
          </button>

          <button
            onClick={toggleFullscreen}
            style={buttonStyle}
          >
            {isFullscreen ? '⬅️ Exit FS' : '⬆️ Fullscreen'}
          </button>

          <button
            onClick={togglePiP}
            style={buttonStyle}
          >
            {isPiP ? '📺 Exit PiP' : '📺 PiP'}
          </button>

          {/* Volume */}
          <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
            <span style={{ color: 'white', fontSize: '14px' }}>🔊</span>
            <input
              type='range'
              min='0'
              max='1'
              step='0.1'
              value={volume}
              onChange={(e) => setVolume(parseFloat(e.target.value))}
              style={{ width: '80px' }}
            />
          </div>

          {/* Playback Speed */}
          <select
            value={playbackRate}
            onChange={(e) => setPlaybackRate(parseFloat(e.target.value))}
            style={{
              padding: '5px',
              borderRadius: '4px',
              border: 'none',
            }}
          >
            <option value='0.5'>0.5x</option>
            <option value='1'>1x</option>
            <option value='1.5'>1.5x</option>
            <option value='2'>2x</option>
          </select>
        </div>

        {/* Keyboard Shortcuts Help */}
        <div
          style={{
            color: '#999',
            fontSize: '12px',
            marginTop: '15px',
            borderTop: '1px solid #333',
            paddingTop: '10px',
          }}
        >
          <strong>Shortcuts:</strong> Space=Play/Pause | F=Fullscreen | M=Mute |
          ←/→=Seek
        </div>

        {/* Auto-save Status */}
        {lastSaved && (
          <div
            style={{
              color: '#4CAF50',
              fontSize: '12px',
              marginTop: '5px',
            }}
          >
            ✅ Last saved: {lastSaved.toLocaleTimeString()}
          </div>
        )}
      </div>
    </div>
  );
}

const buttonStyle = {
  padding: '8px 15px',
  background: '#4CAF50',
  color: 'white',
  border: 'none',
  borderRadius: '4px',
  cursor: 'pointer',
  fontSize: '14px',
};

// Demo Wrapper
function VideoPlayerDemo() {
  const [showPlayer, setShowPlayer] = useState(true);

  return (
    <div>
      <div style={{ textAlign: 'center', marginBottom: '20px' }}>
        <button onClick={() => setShowPlayer(!showPlayer)}>
          {showPlayer ? 'Unmount Player' : 'Mount Player'}
        </button>
        <p style={{ fontSize: '14px', color: '#666', marginTop: '10px' }}>
          Click "Unmount" and watch Console for cleanup logs
        </p>
      </div>

      {showPlayer && (
        <VideoPlayer
          src='https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
          autoplay={false}
        />
      )}
    </div>
  );
}

export default VideoPlayerDemo;

// 📋 TESTING CHECKLIST:
// - [ ] Play video → Progress updates
// - [ ] Pause → Progress stops
// - [ ] Seek → Position changes
// - [ ] Volume slider works
// - [ ] Playback speed changes
// - [ ] Keyboard shortcuts functional
// - [ ] Fullscreen enter/exit
// - [ ] PiP enter/exit
// - [ ] Auto-save every 3s (check localStorage)
// - [ ] Unmount → All cleanups execute (Console)
// - [ ] Unmount → Final position saved
// - [ ] Remount → Resumes from saved position
// - [ ] Switch video src → Old listeners removed, new ones added
// - [ ] No memory leaks (check Chrome DevTools Memory)
// - [ ] No console warnings/errors

// 💡 PRODUCTION ENHANCEMENTS:
// - Error boundaries
// - Loading states
// - Buffering indicator
// - Quality selector
// - Captions/subtitles
// - Playlist support
// - Analytics integration
// - Adaptive bitrate
💡 Solution
jsx
/**
 * Advanced Video Player with comprehensive cleanup
 * - Handles play/pause, progress, volume, fullscreen, PiP, keyboard shortcuts
 * - Auto-saves playback position with debounce
 * - Cleans up ALL timers, listeners, and API states on unmount / src change
 * - Prevents memory leaks and setState warnings
 */
function VideoPlayer({ src, autoplay = false }) {
  const videoRef = useRef(null);

  const [isPlaying, setIsPlaying] = useState(autoplay);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolume] = useState(1);
  const [playbackRate, setPlaybackRate] = useState(1);
  const [isFullscreen, setIsFullscreen] = useState(false);
  const [isPiP, setIsPiP] = useState(false);

  // Progress tracking
  useEffect(() => {
    if (!isPlaying) return;

    const intervalId = setInterval(() => {
      if (videoRef.current) {
        setCurrentTime(videoRef.current.currentTime);
      }
    }, 800);

    return () => {
      console.log('🧹 Clearing progress interval');
      clearInterval(intervalId);
    };
  }, [isPlaying]);

  // Video event listeners (loadedmetadata, ended, error)
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const handleLoaded = () => {
      setDuration(video.duration);
      const saved = localStorage.getItem(`video-pos-${src}`);
      if (saved) {
        video.currentTime = parseFloat(saved);
        setCurrentTime(parseFloat(saved));
      }
    };

    const handleEnded = () => setIsPlaying(false);

    video.addEventListener('loadedmetadata', handleLoaded);
    video.addEventListener('ended', handleEnded);

    return () => {
      console.log('🧹 Removing video event listeners');
      video.removeEventListener('loadedmetadata', handleLoaded);
      video.removeEventListener('ended', handleEnded);
    };
  }, [src]);

  // Play / Pause sync
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    if (isPlaying) {
      video.play().catch(() => setIsPlaying(false));
    } else {
      video.pause();
    }
  }, [isPlaying]);

  // Volume & Playback Rate sync
  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.volume = volume;
    }
  }, [volume]);

  useEffect(() => {
    if (videoRef.current) {
      videoRef.current.playbackRate = playbackRate;
    }
  }, [playbackRate]);

  // Keyboard shortcuts
  useEffect(() => {
    const handleKey = (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')
        return;

      switch (e.code) {
        case 'Space':
          e.preventDefault();
          setIsPlaying((p) => !p);
          break;
        case 'KeyF':
          e.preventDefault();
          toggleFullscreen();
          break;
        case 'KeyM':
          e.preventDefault();
          setVolume((v) => (v === 0 ? 1 : 0));
          break;
        case 'ArrowLeft':
          e.preventDefault();
          if (videoRef.current) videoRef.current.currentTime -= 5;
          break;
        case 'ArrowRight':
          e.preventDefault();
          if (videoRef.current) videoRef.current.currentTime += 5;
          break;
        default:
          break;
      }
    };

    document.addEventListener('keydown', handleKey);

    return () => {
      console.log('🧹 Removing keyboard listeners');
      document.removeEventListener('keydown', handleKey);
    };
  }, [duration]);

  // Fullscreen listener & cleanup
  useEffect(() => {
    const handleFSChange = () => {
      setIsFullscreen(!!document.fullscreenElement);
    };

    document.addEventListener('fullscreenchange', handleFSChange);

    return () => {
      console.log('🧹 Cleaning up fullscreen');
      document.removeEventListener('fullscreenchange', handleFSChange);
      if (document.fullscreenElement) {
        document.exitFullscreen().catch(() => {});
      }
    };
  }, []);

  // Picture-in-Picture listeners
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    const onEnterPiP = () => setIsPiP(true);
    const onLeavePiP = () => setIsPiP(false);

    video.addEventListener('enterpictureinpicture', onEnterPiP);
    video.addEventListener('leavepictureinpicture', onLeavePiP);

    return () => {
      console.log('🧹 Cleaning up PiP');
      video.removeEventListener('enterpictureinpicture', onEnterPiP);
      video.removeEventListener('leavepictureinpicture', onLeavePiP);
      if (document.pictureInPictureElement) {
        document.exitPictureInPicture().catch(() => {});
      }
    };
  }, []);

  // Auto-save position (debounced)
  useEffect(() => {
    if (!currentTime || currentTime < 1) return;

    const timeoutId = setTimeout(() => {
      try {
        localStorage.setItem(`video-pos-${src}`, currentTime.toString());
        console.log('💾 Auto-saved position:', currentTime);
      } catch (err) {}
    }, 2500);

    return () => {
      console.log('🧹 Final save before cleanup');
      clearTimeout(timeoutId);
      try {
        localStorage.setItem(`video-pos-${src}`, currentTime.toString());
      } catch (err) {}
    };
  }, [currentTime, src]);

  const toggleFullscreen = () => {
    if (!document.fullscreenElement) {
      videoRef.current?.requestFullscreen().catch(() => {});
    } else {
      document.exitFullscreen().catch(() => {});
    }
  };

  const togglePiP = () => {
    if (document.pictureInPictureElement) {
      document.exitPictureInPicture().catch(() => {});
    } else {
      videoRef.current?.requestPictureInPicture().catch(() => {});
    }
  };

  const handleSeek = (e) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const pos = (e.clientX - rect.left) / rect.width;
    if (videoRef.current) {
      videoRef.current.currentTime = pos * duration;
      setCurrentTime(pos * duration);
    }
  };

  const formatTime = (s) => {
    const m = Math.floor(s / 60);
    const sec = Math.floor(s % 60);
    return `${m}:${sec.toString().padStart(2, '0')}`;
  };

  return (
    <div
      style={{
        maxWidth: '900px',
        margin: '0 auto',
        background: '#111',
        borderRadius: '12px',
        overflow: 'hidden',
      }}
    >
      <video
        ref={videoRef}
        src={src}
        style={{ width: '100%', display: 'block' }}
        onClick={() => setIsPlaying((p) => !p)}
      />

      <div style={{ padding: '16px', background: '#1a1a1a', color: 'white' }}>
        {/* Progress */}
        <div
          onClick={handleSeek}
          style={{
            height: '8px',
            background: '#444',
            borderRadius: '4px',
            cursor: 'pointer',
            marginBottom: '12px',
            position: 'relative',
          }}
        >
          <div
            style={{
              width: `${duration ? (currentTime / duration) * 100 : 0}%`,
              height: '100%',
              background: '#e91e63',
              borderRadius: '4px',
            }}
          />
        </div>

        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            marginBottom: '12px',
            fontSize: '14px',
          }}
        >
          <span>{formatTime(currentTime)}</span>
          <span>{formatTime(duration)}</span>
        </div>

        {/* Controls */}
        <div
          style={{
            display: 'flex',
            gap: '16px',
            alignItems: 'center',
            flexWrap: 'wrap',
          }}
        >
          <button onClick={() => setIsPlaying((p) => !p)}>
            {isPlaying ? 'Pause' : 'Play'}
          </button>

          <button onClick={toggleFullscreen}>
            {isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
          </button>

          <button onClick={togglePiP}>{isPiP ? 'Exit PiP' : 'PiP'}</button>

          <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
            <span>Vol:</span>
            <input
              type='range'
              min='0'
              max='1'
              step='0.05'
              value={volume}
              onChange={(e) => setVolume(Number(e.target.value))}
              style={{ width: '100px' }}
            />
          </div>

          <select
            value={playbackRate}
            onChange={(e) => setPlaybackRate(Number(e.target.value))}
            style={{ padding: '6px', borderRadius: '4px' }}
          >
            <option value='0.5'>0.5×</option>
            <option value='0.75'>0.75×</option>
            <option value='1'>1×</option>
            <option value='1.25'>1.25×</option>
            <option value='1.5'>1.5×</option>
            <option value='2'>2×</option>
          </select>
        </div>

        <div style={{ marginTop: '12px', fontSize: '13px', color: '#aaa' }}>
          Shortcuts: Space = Play/Pause • F = Fullscreen • M = Mute • ←/→ = Seek
          5s
        </div>
      </div>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Play → progress cập nhật mượt, time tăng
- Pause → progress dừng
- Seek bằng click hoặc phím mũi tên → vị trí thay đổi
- Volume & tốc độ thay đổi → áp dụng ngay
- Fullscreen / PiP → vào/ra đúng, cleanup khi unmount
- Gõ Space, F, M, ←→ → hoạt động
- Chuyển tab hoặc unmount → tất cả interval dừng, listeners gỡ, vị trí cuối cùng được lưu
- Mount lại → resume từ vị trí đã lưu (per src)
- Console sạch, không warning "setState on unmounted", không leak interval/listener
*/

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

Bảng So Sánh: Common Cleanup Patterns

Resource TypeSetup CodeCleanup CodeCommon Mistakes
setIntervalsetInterval(fn, ms)clearInterval(id)❌ Không cleanup → Multiple intervals
setTimeoutsetTimeout(fn, ms)clearTimeout(id)❌ Không cleanup khi deps thay đổi
Event ListeneraddEventListener(event, handler)removeEventListener(event, handler)❌ Handler reference khác → Không remove
Fetch/APIfetch(url)controller.abort()❌ setState sau unmount → Warning
WebSocketnew WebSocket(url)ws.close()❌ Connection leak
Subscriptionobservable.subscribe(fn)subscription.unsubscribe()❌ Memory leak
AnimationrequestAnimationFrame(fn)cancelAnimationFrame(id)❌ Animation continues

Bảng So Sánh: Cleanup Timing

Kịch bảnKhi Cleanup ChạyVí dụ
Component UnmountTrước khi component bị gỡ khỏi DOMNgười dùng chuyển trang
Dependencies Thay ĐổiTrước khi effect chạy lại với deps mới[count] → count thay đổi
Effect Bị Vô HiệuKhi điều kiện effect trở thành falseif (!enabled) return;
Strict Mode (Dev)Sau khi mount, rồi cleanup ngay và chạy lạiReact 18 gọi hai lần

Decision Tree: Khi nào cần Cleanup?

Effect tạo ra resource nào?

├─ Timer (setInterval, setTimeout)?
│  → ✅ BẮT BUỘC cleanup với clearInterval/clearTimeout

├─ Event listener (window, document, element)?
│  → ✅ BẮT BUỘC removeEventListener

├─ Subscription (WebSocket, Observable, etc.)?
│  → ✅ BẮT BUỘC unsubscribe/close

├─ Async operation (fetch, promise)?
│  │
│  ├─ setState trong promise callback?
│  │  → ✅ CẦN cancel flag hoặc AbortController
│  │
│  └─ Không setState?
│     → ⚠️ Consider cleanup nếu operation expensive

├─ DOM manipulation trực tiếp?
│  → ✅ Restore original state

├─ Third-party library instance?
│  → ✅ Call cleanup/destroy method

└─ Chỉ đọc data, không tạo resource?
   → ❌ Không cần cleanup

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

Bug #1: Handler Reference Mismatch 🐛

jsx
/**
 * 🐛 BUG: Event listener không được remove
 * 🎯 Nhiệm vụ: Fix handler reference
 */

function BuggyClickCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('✅ Adding click listener');

    // ❌ BUG: Inline function → New reference mỗi lần!
    window.addEventListener('click', () => {
      setCount((c) => c + 1);
    });

    return () => {
      console.log('🧹 Removing click listener');
      // ❌ This is a DIFFERENT function → Không remove được!
      window.removeEventListener('click', () => {
        setCount((c) => c + 1);
      });
    };
  }, []);

  return <div>Clicks: {count}</div>;
}

// 🤔 CÂU HỎI DEBUG:
// 1. Unmount component → Listener có được remove không?
// 2. Mount lại → Bao nhiêu listeners đang active?
// 3. Sau 5 lần mount/unmount → Bao nhiêu listeners?

// 💡 GIẢI THÍCH:
// - addEventListener và removeEventListener phải dùng SAME function reference
// - Inline arrow functions tạo new reference mỗi lần
// - removeEventListener với different function → No effect!
// - Kết quả: Listeners accumulate → Memory leak

// ✅ FIX: Define handler outside
function Fixed() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('✅ Adding click listener');

    // ✅ Named function với stable reference
    const handleClick = () => {
      setCount((c) => c + 1);
    };

    window.addEventListener('click', handleClick);

    return () => {
      console.log('🧹 Removing click listener');
      window.removeEventListener('click', handleClick); // ✅ Same reference!
    };
  }, []);

  return <div>Clicks: {count}</div>;
}

// 🎓 BÀI HỌC:
// - Event handlers phải có stable reference để remove được
// - Define handler BÊN TRONG effect (có access to closure)
// - SAME handler reference trong add và remove

Bug #2: Missing Cleanup with Dependencies 🔄

jsx
/**
 * 🐛 BUG: Interval không được clear khi deps thay đổi
 * 🎯 Nhiệm vụ: Add proper cleanup
 */

function BuggyIntervalCounter({ interval = 1000 }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`✅ Starting interval with ${interval}ms`);

    const id = setInterval(() => {
      setCount((c) => c + 1);
    }, interval);

    // ❌ BUG: Không cleanup khi interval prop thay đổi!
    // Nếu interval thay đổi từ 1000 → 500:
    // - Effect re-runs, tạo interval MỚI với 500ms
    // - Nhưng interval CŨ (1000ms) vẫn chạy!
    // - Bây giờ có 2 intervals chạy cùng lúc!
  }, [interval]); // Deps có interval → Re-run khi thay đổi

  return (
    <div>
      <p>Count: {count}</p>
      <p>Interval: {interval}ms</p>
    </div>
  );
}

// 🤔 CÂU HỎI DEBUG:
// 1. interval thay đổi từ 1000 → 500 → 250 → Bao nhiêu intervals đang chạy?
// 2. Count tăng với tốc độ nào?
// 3. Memory có leak không?

// 💡 GIẢI THÍCH:
// Mỗi lần interval thay đổi:
// 1. Effect re-runs
// 2. Tạo interval MỚI
// 3. Interval CŨ KHÔNG được clear → Still running!
// 4. Accumulation: 1000ms + 500ms + 250ms = 3 intervals!
// 5. Count tăng nhanh hơn expected

// ✅ FIX: Add cleanup
function Fixed({ interval = 1000 }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`✅ Starting interval with ${interval}ms`);

    const id = setInterval(() => {
      setCount((c) => c + 1);
    }, interval);

    // ✅ Cleanup clears old interval
    return () => {
      console.log(`🧹 Clearing interval with ${interval}ms`);
      clearInterval(id);
    };
  }, [interval]);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Interval: {interval}ms</p>
    </div>
  );
}

// Console output khi interval thay đổi:
// ✅ Starting interval with 1000ms
// (interval changes)
// 🧹 Clearing interval with 1000ms  ← Old cleared!
// ✅ Starting interval with 500ms  ← New created!

// 🎓 BÀI HỌC:
// - Dependencies thay đổi → Effect re-runs
// - LUÔN cleanup old resources trước khi setup new
// - Cleanup chạy TỰ ĐỘNG trước effect re-run

Bug #3: Async setState After Unmount ⚠️

jsx
/**
 * 🐛 BUG: setState trên unmounted component
 * 🎯 Nhiệm vụ: Prevent với cleanup flag
 */

function BuggyDataFetcher({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('Fetching user:', userId);
    setLoading(true);

    // Simulate API call (2 seconds)
    setTimeout(() => {
      const userData = { id: userId, name: `User ${userId}` };

      // ❌ BUG: Nếu component unmount trong 2 giây này
      // → setState trên unmounted component → Warning!
      setUser(userData);
      setLoading(false);
      console.log('✅ User loaded');
    }, 2000);

    // ❌ No cleanup!
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  return <div>User: {user?.name}</div>;
}

// 🤔 CÂU HỎI DEBUG:
// 1. Mount component → Unmount sau 1 giây → Gì xảy ra sau 2 giây?
// 2. Console có warning không?
// 3. Memory có leak không?

// 💡 GIẢI THÍCH:
// Timeline:
// 0s: Mount → Start setTimeout (2s)
// 1s: Unmount → Component gone
// 2s: setTimeout callback runs → setUser() + setLoading()
// → React warning: "Can't perform a React state update on an unmounted component"
// → Potential memory leak (references to unmounted component)

// ✅ FIX #1: Cleanup Flag
function FixedV1({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('Fetching user:', userId);
    setLoading(true);

    let isCancelled = false; // ← Cleanup flag

    setTimeout(() => {
      const userData = { id: userId, name: `User ${userId}` };

      // ✅ Chỉ setState nếu chưa cancelled
      if (!isCancelled) {
        setUser(userData);
        setLoading(false);
        console.log('✅ User loaded');
      } else {
        console.log('🧹 Request cancelled, skipping setState');
      }
    }, 2000);

    // Cleanup: Set flag
    return () => {
      console.log('🧹 Cancelling request');
      isCancelled = true;
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  return <div>User: {user?.name}</div>;
}

// ✅ FIX #2: AbortController (Modern, for real fetch)
function FixedV2({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    console.log('Fetching user:', userId);
    setLoading(true);

    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
        console.log('✅ User loaded');
      })
      .catch((err) => {
        if (err.name === 'AbortError') {
          console.log('🧹 Fetch aborted');
        } else {
          console.error('Error:', err);
        }
      });

    return () => {
      console.log('🧹 Aborting fetch');
      controller.abort();
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  return <div>User: {user?.name}</div>;
}

// 🎓 BÀI HỌC:
// - Async operations cần cleanup để prevent setState sau unmount
// - Flag approach: Simple, works với mọi async code
// - AbortController: Modern, actually cancels network request
// - LUÔN cleanup async operations!

✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)

Knowledge Check

Đánh dấu ✅ những điều bạn đã hiểu:

Concepts:

  • [ ] Tôi hiểu cleanup function là gì
  • [ ] Tôi biết khi nào cleanup function chạy (unmount + deps change)
  • [ ] Tôi hiểu tại sao cần cleanup
  • [ ] Tôi biết memory leak là gì và hậu quả
  • [ ] Tôi hiểu cleanup timing (before re-run, on unmount)

Practices:

  • [ ] Tôi có thể cleanup setInterval/setTimeout
  • [ ] Tôi có thể cleanup event listeners properly
  • [ ] Tôi biết prevent setState sau unmount
  • [ ] Tôi sử dụng AbortController cho fetch
  • [ ] Tôi biết cleanup multiple resources

Debugging:

  • [ ] Tôi nhận biết được memory leaks
  • [ ] Tôi biết debug listener không được remove
  • [ ] Tôi hiểu handler reference issues
  • [ ] Tôi có thể trace cleanup execution
  • [ ] Tôi biết test cleanup với unmount

Code Review Checklist

Khi review code có useEffect, kiểm tra cleanup:

Timers:

  • [ ] setInterval → clearInterval trong cleanup
  • [ ] setTimeout → clearTimeout trong cleanup
  • [ ] requestAnimationFrame → cancelAnimationFrame

Event Listeners:

  • [ ] addEventListener → removeEventListener với SAME handler
  • [ ] Handler defined trong effect (stable reference)
  • [ ] No inline functions trong add/remove

Async Operations:

  • [ ] fetch → AbortController cleanup
  • [ ] Promises → isCancelled flag
  • [ ] No setState sau unmount

Subscriptions:

  • [ ] WebSocket → close() trong cleanup
  • [ ] Observable → unsubscribe() trong cleanup
  • [ ] Third-party libs → cleanup method called

Best Practices:

  • [ ] Cleanup function ALWAYS returned nếu có resources
  • [ ] Console.log cleanup execution (dev)
  • [ ] Comments giải thích WHY cleanup needed
  • [ ] Test unmount behavior

🏠 BÀI TẬP VỀ NHÀ

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

Bài 1: Countdown Timer với Cleanup

jsx
/**
 * Tạo countdown timer:
 * - Input số giây countdown
 * - Button Start/Pause/Reset
 * - Auto stop khi về 0
 * - Cleanup interval properly
 *
 * Requirements:
 * - setInterval để countdown
 * - clearInterval trong cleanup
 * - Test unmount during countdown
 *
 * Hints:
 * - useEffect với [isRunning] deps
 * - Return cleanup function
 * - Functional update: setTime(t => t - 1)
 */
💡 Solution
jsx
/**
 * Countdown Timer component
 * - Nhập số giây ban đầu
 * - Nút Start / Pause / Reset
 * - Tự động dừng khi về 0
 * - Cleanup interval đúng cách khi pause, reset, unmount
 * - Ngăn memory leak và warning setState trên unmounted component
 */
function CountdownTimer() {
  const [initialSeconds, setInitialSeconds] = useState(60);
  const [seconds, setSeconds] = useState(60);
  const [isRunning, setIsRunning] = useState(false);

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

    const intervalId = setInterval(() => {
      setSeconds((prev) => {
        if (prev <= 1) {
          setIsRunning(false);
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => {
      console.log('🧹 Clearing countdown interval');
      clearInterval(intervalId);
    };
  }, [isRunning]);

  const handleStartPause = () => {
    setIsRunning((prev) => !prev);
  };

  const handleReset = () => {
    setIsRunning(false);
    setSeconds(initialSeconds);
  };

  const handleInputChange = (e) => {
    const value = Number(e.target.value);
    if (!isNaN(value) && value >= 0) {
      setInitialSeconds(value);
      if (!isRunning) {
        setSeconds(value);
      }
    }
  };

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
      <h2>Countdown Timer</h2>

      <div style={{ marginBottom: '16px' }}>
        <label>
          Số giây ban đầu:{' '}
          <input
            type='number'
            value={initialSeconds}
            onChange={handleInputChange}
            min='0'
            style={{ width: '80px', padding: '6px' }}
            disabled={isRunning}
          />
        </label>
      </div>

      <div style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0' }}>
        {seconds}
      </div>

      <div style={{ display: 'flex', gap: '12px' }}>
        <button
          onClick={handleStartPause}
          disabled={seconds === 0 && !isRunning}
          style={{
            padding: '10px 20px',
            fontSize: '16px',
            background: isRunning ? '#f44336' : '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer',
          }}
        >
          {isRunning ? 'Pause' : 'Start'}
        </button>

        <button
          onClick={handleReset}
          style={{
            padding: '10px 20px',
            fontSize: '16px',
            background: '#2196F3',
            color: 'white',
            border: 'none',
            borderRadius: '6px',
            cursor: 'pointer',
          }}
        >
          Reset
        </button>
      </div>

      <p style={{ marginTop: '20px', color: '#666', fontSize: '14px' }}>
        Thử unmount component (ẩn nó) trong lúc đang chạy → kiểm tra console
        không còn interval leak
      </p>
    </div>
  );
}
/*
Kết quả mong đợi khi test:
- Nhập 30 → Start → đếm ngược từ 30 → 29 → ... → 0 thì tự dừng
- Pause giữa chừng → đếm dừng, tiếp tục Start thì chạy tiếp
- Reset → về giá trị ban đầu, dừng nếu đang chạy
- Thay đổi input khi đang chạy → không ảnh hưởng (disabled)
- Unmount trong lúc đếm → console log "Clearing countdown interval", không warning setState
- Không còn interval chạy ngầm sau khi pause/unmount/reset
*/

Bài 2: Window Resize Handler với Debounce

jsx
/**
 * Tạo component hiển thị window size:
 * - Track window.innerWidth và innerHeight
 * - Debounce resize events (300ms)
 * - Cleanup listener và timeout
 *
 * Requirements:
 * - addEventListener('resize', ...)
 * - removeEventListener trong cleanup
 * - setTimeout để debounce
 * - clearTimeout trong cleanup
 *
 * Hints:
 * - Effect với [] deps (setup once)
 * - Cleanup function removes listener
 * - Nested cleanup: clear timeout before removing listener
 */
💡 Solution
jsx
/**
 * Window Resize Handler with Debounce
 * - Hiển thị kích thước cửa sổ hiện tại (width × height)
 * - Debounce sự kiện resize 300ms để tránh cập nhật quá thường xuyên
 * - Cleanup cả event listener và timeout khi component unmount
 * - Setup chỉ một lần (empty deps)
 */
function WindowSizeTracker() {
  const [dimensions, setDimensions] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    let timeoutId = null;

    const handleResize = () => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }

      timeoutId = setTimeout(() => {
        setDimensions({
          width: window.innerWidth,
          height: window.innerHeight,
        });
      }, 300);
    };

    window.addEventListener('resize', handleResize);

    return () => {
      console.log('🧹 Cleaning up resize handler');
      window.removeEventListener('resize', handleResize);
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
    };
  }, []);

  return (
    <div style={{ padding: '24px', fontFamily: 'system-ui' }}>
      <h2>Window Size Tracker (Debounced)</h2>
      <div
        style={{
          fontSize: '32px',
          fontWeight: 'bold',
          margin: '20px 0',
          padding: '20px',
          background: '#f0f4f8',
          borderRadius: '12px',
          textAlign: 'center',
        }}
      >
        {dimensions.width} × {dimensions.height}
      </div>
      <p style={{ color: '#555', fontSize: '15px' }}>
        Thử thay đổi kích thước cửa sổ → giá trị chỉ cập nhật sau khi ngừng
        resize ~300ms
      </p>
      <p style={{ color: '#777', fontSize: '13px', marginTop: '24px' }}>
        Unmount component (ẩn nó) → kiểm tra console có log cleanup và không còn
        listener/timeout leak
      </p>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Mount → hiển thị kích thước ban đầu ngay lập tức
- Resize cửa sổ nhanh liên tục → state chỉ cập nhật 1 lần sau khi ngừng ~300ms
- Resize chậm (ngừng >300ms giữa các lần) → cập nhật mỗi lần sau 300ms
- Unmount component trong lúc đang debounce → timeout bị clear, không update state thừa
- Console log "Cleaning up resize handler" khi unmount
- Không warning "Can't perform a React state update on an unmounted component"
- Không còn resize listener hoạt động sau khi component bị gỡ
*/

Nâng cao (60 phút)

Bài 3: Auto-save Form với Multiple Cleanups

jsx
/**
 * Tạo form tự động save:
 * - Fields: name, email, message
 * - Auto-save sau 3s không có thay đổi (debounce)
 * - Show "Saving..." indicator
 * - Cleanup: Save immediately on unmount
 *
 * Requirements:
 * - setTimeout để debounce save
 * - clearTimeout khi fields thay đổi
 * - Final save trong cleanup
 * - localStorage persistence
 *
 * Challenges:
 * - Multiple fields → Single debounce
 * - Unsaved changes warning
 * - Load saved data on mount
 * - Handle localStorage errors
 */
💡 Solution
jsx
/**
 * Auto-save Form với debounce và cleanup toàn diện
 * - Fields: name, email, message
 * - Tự động lưu vào localStorage sau 3 giây không thay đổi (debounce)
 * - Hiển thị trạng thái "Saving..." khi đang lưu
 * - Lưu ngay lập tức khi component unmount (final save)
 * - Load dữ liệu đã lưu khi mount
 * - Xử lý lỗi localStorage cơ bản
 * - Cleanup timeout khi fields thay đổi hoặc unmount
 */
function AutoSaveForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: '',
  });
  const [isSaving, setIsSaving] = useState(false);
  const [lastSaved, setLastSaved] = useState(null);
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

  // Load dữ liệu từ localStorage khi mount
  useEffect(() => {
    try {
      const saved = localStorage.getItem('autoSaveForm');
      if (saved) {
        const parsed = JSON.parse(saved);
        setFormData(parsed);
        setLastSaved(new Date());
        console.log('📂 Loaded saved form data');
      }
    } catch (err) {
      console.error('❌ Error loading form data:', err);
    }
  }, []);

  // Debounce auto-save
  useEffect(() => {
    if (!hasUnsavedChanges) return;

    setIsSaving(true);

    const timeoutId = setTimeout(() => {
      try {
        localStorage.setItem('autoSaveForm', JSON.stringify(formData));
        setLastSaved(new Date());
        setIsSaving(false);
        setHasUnsavedChanges(false);
        console.log('💾 Auto-saved form data');
      } catch (err) {
        console.error('❌ Error saving form data:', err);
        setIsSaving(false);
      }
    }, 3000);

    return () => {
      console.log('🧹 Clearing auto-save timeout');
      clearTimeout(timeoutId);
      setIsSaving(false);
    };
  }, [formData, hasUnsavedChanges]);

  // Final save khi unmount (nếu có thay đổi chưa lưu)
  useEffect(() => {
    return () => {
      if (hasUnsavedChanges) {
        try {
          localStorage.setItem('autoSaveForm', JSON.stringify(formData));
          console.log('💾 Final save on unmount');
        } catch (err) {
          console.error('❌ Final save failed:', err);
        }
      }
    };
  }, [formData, hasUnsavedChanges]);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }));
    setHasUnsavedChanges(true);
  };

  const handleReset = () => {
    if (window.confirm('Bạn có chắc muốn xóa dữ liệu đã nhập?')) {
      setFormData({ name: '', email: '', message: '' });
      setHasUnsavedChanges(false);
      setLastSaved(null);
      try {
        localStorage.removeItem('autoSaveForm');
      } catch (err) {}
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h2>Auto-save Form (Debounce 3s)</h2>

      <form style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
        <div>
          <label
            style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}
          >
            Họ tên
          </label>
          <input
            type='text'
            name='name'
            value={formData.name}
            onChange={handleChange}
            placeholder='Nhập tên của bạn'
            style={{
              width: '100%',
              padding: '10px',
              borderRadius: '6px',
              border: '1px solid #ccc',
            }}
          />
        </div>

        <div>
          <label
            style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}
          >
            Email
          </label>
          <input
            type='email'
            name='email'
            value={formData.email}
            onChange={handleChange}
            placeholder='example@email.com'
            style={{
              width: '100%',
              padding: '10px',
              borderRadius: '6px',
              border: '1px solid #ccc',
            }}
          />
        </div>

        <div>
          <label
            style={{ display: 'block', marginBottom: '6px', fontWeight: '500' }}
          >
            Tin nhắn / Ghi chú
          </label>
          <textarea
            name='message'
            value={formData.message}
            onChange={handleChange}
            placeholder='Viết gì đó...'
            rows={5}
            style={{
              width: '100%',
              padding: '10px',
              borderRadius: '6px',
              border: '1px solid #ccc',
              resize: 'vertical',
            }}
          />
        </div>

        <div
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginTop: '12px',
          }}
        >
          <div
            style={{
              fontSize: '14px',
              color: isSaving ? '#e91e63' : '#4CAF50',
            }}
          >
            {isSaving
              ? 'Đang lưu...'
              : lastSaved
                ? `Đã lưu lần cuối: ${lastSaved.toLocaleTimeString()}`
                : 'Chưa có thay đổi'}
          </div>

          <button
            type='button'
            onClick={handleReset}
            style={{
              padding: '10px 20px',
              background: '#f44336',
              color: 'white',
              border: 'none',
              borderRadius: '6px',
              cursor: 'pointer',
            }}
          >
            Xóa dữ liệu
          </button>
        </div>
      </form>

      {hasUnsavedChanges && (
        <p style={{ color: '#f57c00', fontSize: '14px', marginTop: '16px' }}>
          ⚠️ Bạn có thay đổi chưa lưu. Dữ liệu sẽ tự động lưu sau 3 giây không
          gõ.
        </p>
      )}

      <p style={{ marginTop: '32px', color: '#666', fontSize: '13px' }}>
        Thử gõ gì đó → chờ 3s → thấy "Đã lưu" <br />
        Thay đổi rồi unmount ngay (ẩn component) → kiểm tra console có "Final
        save on unmount" và dữ liệu vẫn được lưu
      </p>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Mount → load dữ liệu cũ từ localStorage (nếu có)
- Gõ bất kỳ field nào → "Đang lưu..." sau 3s → "Đã lưu lần cuối: ..."
- Gõ liên tục nhanh → timeout cũ bị clear, chỉ lưu 1 lần sau 3s ngừng gõ
- Unmount khi đang có thay đổi chưa lưu → console "Final save on unmount", dữ liệu được lưu ngay
- Mount lại → dữ liệu vừa lưu được load lại
- Nhấn "Xóa dữ liệu" → xóa form và localStorage
- Không warning setState trên unmounted component
- Không timeout leak khi gõ nhanh hoặc unmount
*/

Bài 4: Live Search với Cancel

jsx
/**
 * Tạo live search với API:
 * - Input field
 * - Debounce 500ms
 * - Cancel pending requests khi query thay đổi
 * - Cleanup tất cả
 *
 * Requirements:
 * - setTimeout debounce
 * - AbortController để cancel fetch
 * - Cleanup: clear timeout + abort fetch
 * - No setState sau unmount
 *
 * Challenges:
 * - Race conditions
 * - Loading states
 * - Error handling
 * - Empty results
 */
💡 Solution
jsx
/**
 * Live Search với debounce + cancel pending requests
 * - Input field tìm kiếm sản phẩm
 * - Debounce 500ms trước khi gọi API
 * - Sử dụng AbortController để hủy fetch khi query thay đổi
 * - Cleanup: clear timeout + abort controller khi deps thay đổi hoặc unmount
 * - Xử lý loading, error, empty results
 * - Ngăn race condition và setState trên unmounted component
 */
function LiveSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      setError(null);
      setIsLoading(false);
      return;
    }

    let timeoutId = null;
    const controller = new AbortController();

    const performSearch = async () => {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch(
          `https://dummyjson.com/products/search?q=${encodeURIComponent(query)}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();
        setResults(data.products || []);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message || 'Có lỗi khi tìm kiếm');
          setResults([]);
        }
      } finally {
        setIsLoading(false);
      }
    };

    timeoutId = setTimeout(() => {
      performSearch();
    }, 500);

    return () => {
      console.log('🧹 Cleaning up live search');
      if (timeoutId) clearTimeout(timeoutId);
      controller.abort();
    };
  }, [query]);

  const handleChange = (e) => {
    setQuery(e.target.value);
  };

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
      <h2>Live Search (Debounce + Cancel)</h2>

      <input
        type='text'
        value={query}
        onChange={handleChange}
        placeholder='Tìm kiếm sản phẩm (ví dụ: phone, laptop...)'
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          borderRadius: '8px',
          border: '1px solid #ccc',
          marginBottom: '16px',
        }}
      />

      {isLoading && (
        <p style={{ color: '#1976d2', fontWeight: '500' }}>Đang tìm kiếm...</p>
      )}

      {error && (
        <p style={{ color: '#d32f2f', fontWeight: '500' }}>Lỗi: {error}</p>
      )}

      {!isLoading && !error && results.length === 0 && query.trim() && (
        <p style={{ color: '#757575' }}>
          Không tìm thấy sản phẩm nào cho "{query}"
        </p>
      )}

      {results.length > 0 && (
        <div>
          <h3>Kết quả ({results.length} sản phẩm)</h3>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {results.map((product) => (
              <li
                key={product.id}
                style={{
                  padding: '12px',
                  borderBottom: '1px solid #eee',
                  display: 'flex',
                  justifyContent: 'space-between',
                  alignItems: 'center',
                }}
              >
                <div>
                  <strong>{product.title}</strong>
                  <div style={{ color: '#555', fontSize: '14px' }}>
                    {product.description.substring(0, 80)}...
                  </div>
                </div>
                <span style={{ color: '#2e7d32', fontWeight: 'bold' }}>
                  ${product.price}
                </span>
              </li>
            ))}
          </ul>
        </div>
      )}

      <p style={{ marginTop: '32px', color: '#666', fontSize: '14px' }}>
        Thử gõ nhanh "phone" rồi sửa thành "laptop" ngay → request cũ bị hủy
        <br />
        Unmount component khi đang loading → không có warning setState, fetch bị
        abort
      </p>
    </div>
  );
}

/*
Kết quả mong đợi khi test:
- Gõ "phone" → sau 500ms gọi API → hiển thị sản phẩm
- Gõ nhanh "phone" → xóa → gõ "laptop" → request "phone" bị abort, chỉ hiển thị kết quả "laptop"
- Gõ rồi unmount ngay (ẩn component) → fetch bị hủy, không warning "Can't perform a React state update on an unmounted component"
- Query rỗng → kết quả clear ngay
- Lỗi mạng → hiển thị thông báo lỗi
- Console log "Cleaning up live search" khi query thay đổi hoặc unmount
- Không timeout hay fetch nào leak
*/

📚 TÀI LIỆU THAM KHẢO

Bắt buộc đọc

  1. React Docs - useEffect Cleanup

  2. Synchronizing with Effects

Đọc thêm

  1. AbortController MDN

  2. Memory Leaks in React

    • Common patterns that leak
    • Detection với Chrome DevTools
    • Prevention strategies

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

Kiến thức nền (đã học):

  • Ngày 16: useEffect Introduction

    • Đã học: Effect basic syntax
    • Kết nối: Hôm nay complete với cleanup
  • Ngày 17: Dependencies Deep Dive

    • Đã học: When effects re-run
    • Kết nối: Cleanup chạy trước re-run

Hướng tới (sẽ học):

  • Ngày 19-20: Data Fetching

    • Sẽ học: API calls trong effects
    • Sẽ dùng: Cleanup để cancel requests
  • Ngày 21: useRef

    • Sẽ học: Persist values without re-render
    • Sẽ dùng: Alternative to isCancelled flag
  • Ngày 24: Custom Hooks

    • Sẽ học: Extract cleanup logic
    • Sẽ dùng: useDebounce, useInterval custom hooks

💡 SENIOR INSIGHTS

Cân Nhắc Production

1. Cleanup Checklist Template:

jsx
useEffect(() => {
  // ✅ Setup
  const resource = setupResource();

  // ✅ Cleanup checklist:
  return () => {
    // 1. Clear timers
    clearInterval(intervalId);
    clearTimeout(timeoutId);

    // 2. Remove listeners
    element.removeEventListener('event', handler);

    // 3. Close connections
    websocket.close();

    // 4. Cancel async
    controller.abort();

    // 5. Cleanup third-party
    library.destroy();

    // 6. Final sync (save data, send analytics)
    finalSave();
  };
}, [deps]);

2. Debugging Cleanup:

jsx
useEffect(() => {
  const DEBUG = process.env.NODE_ENV === 'development';

  if (DEBUG) console.log('[Effect] Setup:', { deps });

  // Setup code

  return () => {
    if (DEBUG) console.log('[Cleanup] Running:', { deps });
    // Cleanup code
  };
}, [deps]);

3. Testing Cleanup:

jsx
// Test cleanup manually
function TestCleanup() {
  const [show, setShow] = useState(true);

  return (
    <>
      <button onClick={() => setShow(!show)}>Toggle (triggers cleanup)</button>
      {show && <ComponentWithCleanup />}
    </>
  );
}

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

Junior Level:

  1. Q: Cleanup function là gì?

    A: Function được return từ useEffect để dọn dẹp side effects (timers, listeners, etc.). Chạy trước khi effect re-run và khi component unmount.

  2. Q: Khi nào cần cleanup?

    A: Khi effect tạo resources cần được dọn dẹp: timers (setInterval/setTimeout), event listeners, subscriptions, async operations có setState.

  3. Q: Làm sao cleanup event listener?

    A:

    jsx
    useEffect(() => {
      const handler = () => {
        /* ... */
      };
      window.addEventListener('event', handler);
      return () => window.removeEventListener('event', handler);
    }, []);

Mid Level:

  1. Q: Tại sao cleanup chạy trước effect re-run?

    A: Để dọn dẹp old setup trước khi tạo new setup. Prevents resource leaks và conflicts giữa old và new effects.

  2. Q: Làm sao prevent setState sau unmount?

    A: Dùng cleanup flag:

    jsx
    useEffect(() => {
      let isCancelled = false;
      fetchData().then((data) => {
        if (!isCancelled) setState(data);
      });
      return () => {
        isCancelled = true;
      };
    }, []);

Senior Level:

  1. Q: Handle cleanup cho complex async workflows? ( Xử lý việc dọn dẹp cho các quy trình công việc bất đồng bộ phức tạp.)

    A:

    • AbortController cho fetch: hủy request khi component unmount

      ts
      const controller = new AbortController();
      
      fetch(url, { signal: controller.signal });
      
      return () => controller.abort();
    • Cleanup flags cho promises: chặn xử lý khi async hoàn thành muộn

      ts
      let isCancelled = false;
      
      asyncTask().then(() => {
        if (!isCancelled) setState(data);
      });
      
      return () => {
        isCancelled = true;
      };
    • Queue management cho batched operations: clear queue khi workflow bị cancel

      ts
      const queue: Job[] = [];
      
      function cancelAll() {
        queue.length = 0;
      }
    • Timeout để force cleanup nếu chưa hoàn thành (bị hung / treo): watchdog tự động hủy task

      ts
      const timeoutId = setTimeout(() => cancelTask(), 30000);
      
      task.finally(() => clearTimeout(timeoutId));
    • Transaction pattern (commit hoặc rollback): chỉ commit khi tất cả step thành công

      ts
      try {
        await step1();
        await step2();
        commit();
      } catch {
        rollback();
      }
  2. Q: Memory leak detection strategy?

    A:

    • Chrome DevTools Memory profiler: so sánh heap snapshot

      js
      // Take snapshot before & after navigation
    • Track component instances count: đếm instance còn sống

      ts
      let instanceCount = 0;
      useEffect(() => {
        instanceCount++;
        return () => instanceCount--;
      }, []);
    • Monitor event listeners (getEventListeners()): check listener chưa remove

      js
      getEventListeners(window).resize;
    • Automated tests với mount/unmount cycles: stress test lifecycle

      ts
      for (let i = 0; i < 100; i++) {
        mount();
        unmount();
      }
    • Production monitoring (Sentry, etc.): log memory theo session

      ts
      Sentry.captureMessage(`Heap: ${performance.memory.usedJSHeapSize}`);

War Stories

Story #1: The Invisible Memory Leak 💀

"Production app chạy smooth ban đầu, nhưng sau 2-3 giờ → lag, eventual crash. Profiling discover: 1000+ mousemove listeners! Root cause: useEffect thêm listener mỗi re-render, không có cleanup. Fix: Add return () => removeEventListener. Lesson: LUÔN cleanup listeners, test với unmount/remount cycles."

Story #2: Race Condition Hell 🏎️

"Search feature: Type 'react' → 5 letters = 5 API calls. Old requests return sau new request → Wrong results displayed. Issue: No cleanup để cancel pending requests. Fix: AbortController trong cleanup. Bonus: Added debounce. Lesson: Async + No cleanup = Race conditions."

Story #3: The Cleanup That Saved Production 🚑

"Video player app: Users report 'ghost audio' - video stopped nhưng vẫn nghe audio. Debug: Multiple <audio> elements created, không cleanup khi video thay đổi. Fix: Return () => audio.pause() + audio.remove() trong cleanup. Lesson: DOM elements cần explicit cleanup, especially media!"


🎯 NGÀY MAI: Data Fetching - Basics

Preview những gì bạn sẽ học:

fetch API trong useEffect

  • Async/await syntax trong effects
  • Loading/Error/Success states
  • Dependencies cho data fetching

Practical Patterns

  • Initial data fetch (empty deps)
  • Refetch on param change
  • Cancel với AbortController

Error Handling

  • Try/catch trong async effects
  • Error boundaries preview
  • Retry logic

🔥 Chuẩn bị:

  • Ôn lại Promises & async/await
  • Hiểu fetch API basics
  • Practice cleanup (bài tập hôm nay!)

🎉 Chúc mừng! Bạn đã hoàn thành Ngày 18!

Bạn đã:

  • ✅ Master được Cleanup Functions
  • ✅ Prevent Memory Leaks effectively
  • ✅ Handle Async Cleanup (AbortController, flags)
  • ✅ Clean up Timers, Listeners, Subscriptions
  • ✅ Apply cleanup cho production scenarios

Cleanup là foundation cho stable, leak-free React apps. Bạn đã làm chủ nó! 🎊

Ngày 19 sẽ kết hợp tất cả (effects + deps + cleanup) cho Data Fetching thực chiến! 🚀

Personal tech knowledge base