📅 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:
Câu 1: Nếu bạn dùng
setIntervaltrong useEffect, điều gì xảy ra khi component unmount?- Đáp án: Interval vẫn chạy → Memory leak! (Ngày 18 sẽ fix)
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)
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:
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 đề:
- Click "Toggle Timer" → Timer component unmount
- Nhưng
setIntervalVẪN CHẠY! (không ai dừng nó) - Interval cố gắng gọi
setSecondstrên component đã unmount - 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:
useEffect(() => {
// Setup code
const id = setInterval(() => {
// ...
}, 1000);
// Cleanup function
return () => {
clearInterval(id); // ← Dọn dẹp
};
}, []);Cleanup Function chạy khi:
- Component unmount (component bị remove khỏi DOM)
- Dependencies thay đổi (trước khi effect chạy lại)
GIẢI PHÁP cho Timer:
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 leaksAnalogy dễ hiểu:
Effect như thuê phòng khách sạn:
- Check-in (Setup): Nhận chìa khóa, bật đèn, mở điều hòa
- Ở trong phòng (Effect active)
- 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"
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"
// ❌ 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"
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 ⭐
/**
* 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 ⭐⭐
/**
* 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 ⭐⭐⭐
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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)
/**
* 🎯 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
/**
* 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 Type | Setup Code | Cleanup Code | Common Mistakes |
|---|---|---|---|
| setInterval | setInterval(fn, ms) | clearInterval(id) | ❌ Không cleanup → Multiple intervals |
| setTimeout | setTimeout(fn, ms) | clearTimeout(id) | ❌ Không cleanup khi deps thay đổi |
| Event Listener | addEventListener(event, handler) | removeEventListener(event, handler) | ❌ Handler reference khác → Không remove |
| Fetch/API | fetch(url) | controller.abort() | ❌ setState sau unmount → Warning |
| WebSocket | new WebSocket(url) | ws.close() | ❌ Connection leak |
| Subscription | observable.subscribe(fn) | subscription.unsubscribe() | ❌ Memory leak |
| Animation | requestAnimationFrame(fn) | cancelAnimationFrame(id) | ❌ Animation continues |
Bảng So Sánh: Cleanup Timing
| Kịch bản | Khi Cleanup Chạy | Ví dụ |
|---|---|---|
| Component Unmount | Trước khi component bị gỡ khỏi DOM | Người dùng chuyển trang |
| Dependencies Thay Đổi | Trước khi effect chạy lại với deps mới | [count] → count thay đổi |
| Effect Bị Vô Hiệu | Khi điều kiện effect trở thành false | if (!enabled) return; |
| Strict Mode (Dev) | Sau khi mount, rồi cleanup ngay và chạy lại | React 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 🐛
/**
* 🐛 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à removeBug #2: Missing Cleanup with Dependencies 🔄
/**
* 🐛 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-runBug #3: Async setState After Unmount ⚠️
/**
* 🐛 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
/**
* 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
React Docs - useEffect Cleanup
- https://react.dev/reference/react/useEffect#cleanup-function
- Đọc kỹ phần Cleanup
- Examples với timers, listeners
Synchronizing with Effects
- https://react.dev/learn/synchronizing-with-effects
- Effect lifecycle
- When cleanup runs
Đọc thêm
AbortController MDN
- https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- How to cancel fetch requests
- Browser support
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:
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:
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:
// 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:
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.
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.
Q: Làm sao cleanup event listener?
A:
jsxuseEffect(() => { const handler = () => { /* ... */ }; window.addEventListener('event', handler); return () => window.removeEventListener('event', handler); }, []);
Mid Level:
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.
Q: Làm sao prevent setState sau unmount?
A: Dùng cleanup flag:
jsxuseEffect(() => { let isCancelled = false; fetchData().then((data) => { if (!isCancelled) setState(data); }); return () => { isCancelled = true; }; }, []);
Senior Level:
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
tsconst 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
tslet isCancelled = false; asyncTask().then(() => { if (!isCancelled) setState(data); }); return () => { isCancelled = true; };Queue management cho batched operations: clear queue khi workflow bị cancel
tsconst 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
tsconst 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
tstry { await step1(); await step2(); commit(); } catch { rollback(); }
Q: Memory leak detection strategy?
A:
Chrome DevTools Memory profiler: so sánh heap snapshot
js// Take snapshot before & after navigationTrack component instances count: đếm instance còn sống
tslet instanceCount = 0; useEffect(() => { instanceCount++; return () => instanceCount--; }, []);Monitor event listeners (
getEventListeners()): check listener chưa removejsgetEventListeners(window).resize;Automated tests với mount/unmount cycles: stress test lifecycle
tsfor (let i = 0; i < 100; i++) { mount(); unmount(); }Production monitoring (Sentry, etc.): log memory theo session
tsSentry.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! 🚀