📅 NGÀY 21: useRef - Fundamentals & Mutable Values
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Hiểu bản chất của useRef và khi nào cần dùng thay vì useState
- [ ] Nắm vững cách useRef lưu trữ giá trị mutable không trigger re-render
- [ ] Biết cách persist values across renders mà không gây side effects
- [ ] Phân biệt rõ ràng use cases của useRef vs useState
- [ ] Áp dụng useRef để giải quyết các vấn đề thực tế như tracking previous values, storing timer IDs
🤔 Kiểm tra đầu vào (5 phút)
Trước khi bắt đầu, hãy trả lời 3 câu hỏi này:
Điều gì xảy ra khi bạn update state bằng setState?
- Component re-render với giá trị mới
useEffect cleanup function chạy khi nào?
- Trước khi effect chạy lần tiếp theo hoặc khi component unmount
Làm sao để store một giá trị persists across renders nhưng không muốn trigger re-render khi thay đổi?
- Đây chính là vấn đề useRef 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 xét tình huống này:
// ❌ VẤN ĐỀ: Muốn store interval ID để clear sau này
function Timer() {
const [count, setCount] = useState(0);
let intervalId; // ⚠️ Sẽ bị reset mỗi lần render!
const startTimer = () => {
intervalId = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
};
const stopTimer = () => {
clearInterval(intervalId); // ⚠️ intervalId luôn là undefined!
};
return (
<div>
<p>Count: {count}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}Vấn đề:
- Biến
intervalIdđược declare lại mỗi lần component re-render stopTimerkhông thể access đượcintervalIdtừstartTimer- Timer không thể stop được! 😱
Bạn có thể nghĩ: "Dùng useState để lưu intervalId?"
// ❌ GIẢI PHÁP SAI: Dùng useState
function Timer() {
const [count, setCount] = useState(0);
const [intervalId, setIntervalId] = useState(null); // ⚠️ Overkill!
const startTimer = () => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
setIntervalId(id); // ⚠️ Gây re-render không cần thiết!
};
const stopTimer = () => {
clearInterval(intervalId);
setIntervalId(null); // ⚠️ Lại re-render!
};
return (
<div>
<p>Count: {count}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}Tại sao sai?
intervalIdkhông phải UI data, không cần render- Mỗi lần set intervalId → re-render không cần thiết
- Performance waste! 📉
1.2 Giải Pháp: useRef
// ✅ GIẢI PHÁP ĐÚNG: Dùng useRef
import { useState, useRef } from 'react';
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null); // 🎯 Perfect!
const startTimer = () => {
intervalRef.current = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
};
const stopTimer = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<p>Count: {count}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}Tại sao tốt hơn?
intervalRef.currentpersists across renders- Update
intervalRef.currentKHÔNG trigger re-render - Chỉ re-render khi
countthay đổi (cần thiết cho UI)
1.3 Mental Model
Hãy tưởng tượng useRef như một chiếc hộp có ngăn kéo:
┌─────────────────────────────────┐
│ useRef Container │
├─────────────────────────────────┤
│ │
│ ┌───────────────────┐ │
│ │ .current │ │ ← Ngăn kéo này luôn ở đó
│ │ (mutable value) │ │ giữa các lần render
│ └───────────────────┘ │
│ │
└─────────────────────────────────┘
Đặc điểm:
✅ Hộp (ref object) không bao giờ thay đổi
✅ Ngăn kéo (.current) có thể mở ra và thay đổi nội dung
✅ Thay đổi nội dung ngăn kéo KHÔNG làm React nhận biếtSo sánh với useState:
useState useRef
──────────────────────────────────────────
const [value, setValue] const ref = useRef(value)
setValue(newValue) ref.current = newValue
→ Trigger re-render → KHÔNG re-render
→ Async update → Sync update
→ Cho UI data → Cho non-UI data1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "useRef chỉ dùng để access DOM"
Thực tế: useRef có 2 use cases chính:
- Mutable values (hôm nay học)
- DOM references (ngày mai học)
// ✅ Use case 1: Mutable value
const countRef = useRef(0);
countRef.current += 1; // Không re-render
// ✅ Use case 2: DOM reference (sẽ học ngày mai)
const inputRef = useRef(null);
// <input ref={inputRef} />❌ Hiểu lầm 2: "ref.current thay đổi thì component re-render"
// ❌ SAI: Nghĩ ref.current thay đổi → re-render
function Counter() {
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
console.log(countRef.current); // Giá trị tăng
// ⚠️ Nhưng UI KHÔNG update!
};
return (
<div>
<p>Count: {countRef.current}</p> {/* Luôn hiển thị 0 */}
<button onClick={increment}>+1</button>
</div>
);
}❌ Hiểu lầm 3: "Có thể dùng useRef thay useState để tránh re-render"
// ❌ ANTI-PATTERN: Dùng ref cho UI data
function BadCounter() {
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
// ⚠️ UI không update vì không re-render!
};
return <p>Count: {countRef.current}</p>;
}
// ✅ ĐÚNG: Dùng useState cho UI data
function GoodCounter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount((c) => c + 1); // UI updates correctly
};
return <p>Count: {count}</p>;
}💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Pattern Cơ Bản - Tracking Render Count ⭐
/**
* 🎯 Mục tiêu: Đếm số lần component render
* 💡 Insight: useRef perfect cho tracking mà không gây re-render
*/
import { useState, useRef, useEffect } from 'react';
function RenderCounter() {
const [count, setCount] = useState(0);
const renderCount = useRef(0);
// ⚠️ CHÚ Ý: Không dùng useEffect để increment!
// Vì useEffect chạy AFTER render
// ✅ Increment ngay trong render phase
renderCount.current += 1;
console.log(`Render #${renderCount.current}`);
return (
<div>
<h2>Render Counter Demo</h2>
<p>This component has rendered: {renderCount.current} times</p>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
</div>
);
}
// 🔍 Output Render #1 (initial)
// This component has rendered: 1 times | Count: 0
// 🔍 Khi click button:
// Click button → Render #2 | This component has rendered: 2 times | Count: 1
// Click button → Render #3 | This component has rendered: 3 times | Count: 2Giải thích chi tiết:
// ❌ SAI: Dùng useState để count renders
function BadRenderCounter() {
const [count, setCount] = useState(0);
const [renderCount, setRenderCount] = useState(0);
// ⚠️ Gây infinite loop!
setRenderCount(renderCount + 1); // Trigger re-render → lại gọi setRenderCount → loop!
return <p>Renders: {renderCount}</p>;
}
// ❌ SAI: Dùng biến thường
function BadRenderCounter2() {
const [count, setCount] = useState(0);
let renderCount = 0; // ⚠️ Reset về 0 mỗi render!
renderCount += 1; // Luôn là 1
return <p>Renders: {renderCount}</p>; // Luôn hiển thị 1
}
// ✅ ĐÚNG: Dùng useRef
function GoodRenderCounter() {
const [count, setCount] = useState(0);
const renderCount = useRef(0);
renderCount.current += 1; // Persists, không trigger re-render
return <p>Renders: {renderCount.current}</p>;
}Demo 2: Kịch Bản Thực Tế - Previous Value Tracking ⭐⭐
/**
* 🎯 Use case: So sánh giá trị hiện tại với giá trị trước đó
* 💼 Real-world: Analytics, change detection, diff calculation
*/
import { useEffect, useRef, useState } from 'react';
export default function PriceTracker() {
const [price, setPrice] = useState(100);
const previousPrice = useRef(price);
// DERIVED VALUES
// Calculate change
const priceChange = price - previousPrice.current;
const changePercent = ((priceChange / previousPrice.current) * 100).toFixed(
2,
);
// SIDE EFFECT (Commit phase → After render)
// Chạy SAU khi DOM đã render xong
// Lưu lại snapshot giá hiện tại cho LẦN RENDER TIẾP THEO
useEffect(() => {
previousPrice.current = price;
}, [price]); // Chỉ update khi price thay đổi
// Random price change (demo purpose)
const updatePrice = () => {
const change = Math.random() * 20 - 10; // -10 to +10
setPrice((prev) => Math.max(1, prev + change)); // Không âm
};
/*
* 1. Render
- price = NEW
- previousPrice = OLD
2. Paint UI
3. useEffect chạy
- previousPrice = price (NEW)
*/
return (
<div style={{ padding: '20px', border: '1px solid #ccc' }}>
<h2>Stock Price Tracker</h2>
<div style={{ fontSize: '32px', fontWeight: 'bold' }}>
${price.toFixed(2)}
</div>
<div
style={{
color: priceChange >= 0 ? 'green' : 'red',
fontSize: '18px',
}}
>
{priceChange >= 0 ? '▲' : '▼'}${Math.abs(priceChange).toFixed(2)}(
{changePercent}%)
</div>
<div style={{ marginTop: '10px', color: '#666' }}>
Previous: ${previousPrice.current.toFixed(2)}
</div>
<button
onClick={updatePrice}
style={{ marginTop: '10px' }}
>
Update Price
</button>
</div>
);
}Tại sao dùng useRef thay vì useState?
// ❌ Cách 1: Dùng useState (không tốt)
function BadPriceTracker() {
const [price, setPrice] = useState(100);
const [previousPrice, setPreviousPrice] = useState(100);
const updatePrice = () => {
setPreviousPrice(price); // ⚠️ Gây thêm 1 re-render!
setPrice(newPrice); // ⚠️ Lại 1 re-render!
// → 2 renders thay vì 1!
};
return (
<div>
<p>Current: ${price}</p>
<p>Previous: ${previousPrice}</p>
</div>
);
}
// ✅ Cách 2: Dùng useRef (tốt)
function GoodPriceTracker() {
const [price, setPrice] = useState(100);
const previousPrice = useRef(100);
useEffect(() => {
previousPrice.current = price; // Không gây re-render
}, [price]);
const updatePrice = () => {
setPrice(newPrice); // Chỉ 1 re-render!
};
return (
<div>
<p>Current: ${price}</p>
<p>Previous: ${previousPrice.current}</p>
</div>
);
}Timeline so sánh:
useState approach:
──────────────────────────────────────
Initial render → price: 100, prev: 100
Click button:
1. setPreviousPrice(100) → Render #2
2. setPrice(120) → Render #3
→ 2 unnecessary renders!
useRef approach:
──────────────────────────────────────
Initial render → price: 100, prev.current: 100
Click button:
1. setPrice(120) → Render #2
2. useEffect: prev.current = 120 (no render)
→ Only 1 necessary render!Demo 3: Edge Cases - Timer Management ⭐⭐⭐
/**
* 🎯 Use case: Timer có pause / resume chính xác
* 🧠 Core idea:
* - Date.now() = nguồn thời gian duy nhất (single source of truth)
* - setInterval chỉ dùng để trigger re-render
* - useRef giữ mutable data xuyên render
*/
import { useEffect, useRef, useState } from 'react';
export default function AccurateTimer() {
// time = số GIÂY đã trôi qua (derived từ Date.now)
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
// Lưu interval ID để tránh start nhiều lần
const intervalRef = useRef(null);
// Lưu "thời điểm bắt đầu giả định"
// → dùng để tính thời gian trôi qua (elapsed time) chính xác
const startTimeRef = useRef(null);
const startTimer = () => {
// ⚠️ Prevent multiple intervals
if (intervalRef.current) return;
// Khi Resume : `time` hiện tại là số giây đã chạy trước đó.
// Ví dụ hiện tại 10:00 , đã chạy 5s rồi pause
// Date.now() trả về Milliseconds nên cần đổi `time` sang đơn vị Milliseconds ( * 1000 )
// => Timer bắt đầu từ 10:00 - 5s = 9:59:55, lưu vào `startTimeRef`
startTimeRef.current = Date.now() - time * 1000;
setIsRunning(true);
/**
* ❌ KHÔNG dùng:
* setTime(prev => prev + 1)
*
* ✅ LÝ DO:
* 1. setInterval KHÔNG chính xác 1000ms
* 2. Tab background → delay (Browser sẽ chủ động làm chậm / tạm ngưng timer để tiết kiệm tài nguyên khi chuyển tab khác / lock màn hình /...)
* 3. Drift theo thời gian ( càng chạy lâu càng lệch xa thời gian thật )
*
* 👉 Thay vào đó:
* - Tính lại time từ Date.now()
* - Mỗi tick là một phép "sync lại với thời gian thực"
*/
intervalRef.current = setInterval(() => {
// Thời gian trôi qua = Hiện tại - Bắt đầu.
// Sau đó chia 1000 để đổi đơn vị từ ms -> s
const elapsedSeconds = Math.floor(
(Date.now() - startTimeRef.current) / 1000,
);
setTime(elapsedSeconds);
}, 100); // Tick nhanh để UI mượt, logic vẫn chuẩn
};
/* =====================================================
* STOP TIMER (Pause)
* ===================================================== */
const stopTimer = () => {
if (!intervalRef.current) return;
clearInterval(intervalRef.current);
intervalRef.current = null;
setIsRunning(false);
};
/* =====================================================
* RESET TIMER
* ===================================================== */
const resetTimer = () => {
stopTimer();
setTime(0);
startTimeRef.current = null;
};
/* =====================================================
* CLEANUP — Chạy khi component UNMOUNT - Dọn dẹp lần cuối
* ===================================================== */
useEffect(() => {
return () => {
// Prevent memory leak - Ngăn chặn rò rỉ bộ nhớ
// Trường hợp đang chạy timer mà tắt trình duyệt đột ngột
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
/* =====================================================
* UI HELPERS
* ===================================================== */
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs
.toString()
.padStart(2, '0')}`;
};
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<h2>Accurate Timer</h2>
<div
style={{
fontSize: 48,
fontFamily: 'monospace',
margin: '20px 0',
}}
>
{formatTime(time)}
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center' }}>
<button
onClick={startTimer}
disabled={isRunning}
>
Start
</button>
<button
onClick={stopTimer}
disabled={!isRunning}
>
Stop
</button>
<button onClick={resetTimer}>Reset</button>
</div>
{/* Debug / teaching purpose */}
<div style={{ marginTop: 20, fontSize: 12, color: '#666' }}>
<p>Running: {isRunning ? 'Yes' : 'No'}</p>
<p>Interval: {intervalRef.current ? 'Active' : 'null'}</p>
<p>
Start time:{' '}
{startTimeRef.current
? new Date(startTimeRef.current).toLocaleTimeString()
: 'null'}
</p>
</div>
</div>
);
}Edge Cases được handle:
// ⚠️ Edge Case 1: Spam start button
const startTimer = () => {
if (intervalRef.current) {
// ✅ Prevent multiple intervals
console.warn('Timer is already running!');
return;
}
// ... start logic
};
// ⚠️ Edge Case 2: Component unmount khi timer running
useEffect(() => {
return () => {
if (intervalRef.current) {
// ✅ Cleanup để tránh memory leak
clearInterval(intervalRef.current);
}
};
}, []);
// ⚠️ Edge Case 3: Reset khi đang chạy
const resetTimer = () => {
stopTimer(); // ✅ Stop trước khi reset
setTime(0);
startTimeRef.current = null;
};Common Mistakes:
// ❌ Mistake 1: Không check timer đã running
const badStart = () => {
// Không check → tạo nhiều intervals!
intervalRef.current = setInterval(/* ... */);
};
// ❌ Mistake 2: Không cleanup
// Không có useEffect cleanup → memory leak khi unmount!
// ❌ Mistake 3: Clear wrong interval
const badStop = () => {
clearInterval(intervalRef.current);
// ⚠️ Quên set null → ref vẫn giữ invalid ID
};
// ✅ ĐÚNG:
const goodStop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null; // Reset ref
};🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Click Counter với Previous Value (15 phút)
/**
* 🎯 Mục tiêu: Thực hành useRef cơ bản với previous value tracking
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useReducer, useContext, custom hooks
*
* Requirements:
* 1. Hiển thị số lần click hiện tại
* 2. Hiển thị số lần click trước đó
* 3. Hiển thị difference (current - previous)
* 4. Button reset về 0
*
* 💡 Gợi ý:
* - Dùng useState cho click count (UI data)
* - Dùng useRef cho previous count (non-UI data)
* - useEffect để update previous sau mỗi click
*/
// ❌ Cách SAI: Dùng 2 useState
function BadClickCounter() {
const [count, setCount] = useState(0);
const [prevCount, setPrevCount] = useState(0);
const handleClick = () => {
setPrevCount(count); // ⚠️ Extra re-render!
setCount(count + 1); // ⚠️ Another re-render!
};
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={handleClick}>Click Me</button>
</div>
);
}
// ✅ Cách ĐÚNG: useState + useRef
function GoodClickCounter() {
// TODO: Implement using useState + useRef
// Step 1: Create state for current count
// Step 2: Create ref for previous count
// Step 3: useEffect to update previous after count changes
// Step 4: Calculate difference
// Step 5: Reset button
}
// 🎯 NHIỆM VỤ CỦA BẠN:
function ClickCounter() {
// TODO: Your implementation here
return (
<div style={{ padding: '20px', border: '2px solid #333' }}>
<h2>Click Counter</h2>
{/* TODO: Display current count */}
{/* TODO: Display previous count */}
{/* TODO: Display difference with color (green if +, red if -) */}
{/* TODO: Click button */}
{/* TODO: Reset button */}
</div>
);
}
// ✅ Expected behavior:
// Initial: Current: 0, Previous: 0, Diff: 0
// Click 1: Current: 1, Previous: 0, Diff: +1 (green)
// Click 2: Current: 2, Previous: 1, Diff: +1 (green)
// Reset: Current: 0, Previous: 2, Diff: -2 (red)💡 Solution
/**
* Click Counter with Previous Value Tracking
* @returns {JSX.Element}
*/
function ClickCounter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef(0);
useEffect(() => {
prevCountRef.current = count;
}, [count]);
const difference = count - prevCountRef.current;
const handleReset = () => {
setCount(0);
// prevCountRef sẽ được cập nhật tự động qua useEffect
};
return (
<div style={{ padding: '20px', border: '2px solid #333' }}>
<h2>Click Counter</h2>
<p>Current: {count}</p>
<p>Previous: {prevCountRef.current}</p>
<p
style={{
color: difference > 0 ? 'green' : difference < 0 ? 'red' : 'inherit',
}}
>
Difference: {difference > 0 ? '+' : ''}
{difference}
</p>
<div style={{ marginTop: '16px' }}>
<button
onClick={() => setCount((c) => c + 1)}
style={{ marginRight: '12px' }}
>
Click Me
</button>
<button
onClick={handleReset}
style={{
backgroundColor: '#dc3545',
color: 'white',
}}
>
Reset
</button>
</div>
</div>
);
}
/*
Kết quả mong đợi:
Initial:
Current: 0
Previous: 0
Difference: 0
Sau 1 lần click:
Current: 1
Previous: 0
Difference: +1 (màu xanh)
Sau 2 lần click:
Current: 2
Previous: 1
Difference: +1 (màu xanh)
Sau khi Reset:
Current: 0
Previous: 2
Difference: -2 (màu đỏ)
*/⭐⭐ Exercise 2: Debounced Search Input (25 phút)
/**
* 🎯 Mục tiêu: Hiểu khi nào dùng useRef vs useState
* ⏱️ Thời gian: 25 phút
*
* Scenario:
* Bạn đang build search box. Mỗi lần user type, bạn muốn gọi API.
* Nhưng KHÔNG muốn gọi API mỗi keystroke → dùng debounce.
*
* 🤔 PHÂN TÍCH:
*
* Approach A: Dùng setTimeout trong useEffect, store timeout ID trong useState
* Pros:
* - Straightforward
* Cons:
* - setState gây unnecessary re-render
* - Performance waste
*
* Approach B: Dùng setTimeout trong useEffect, store timeout ID trong useRef
* Pros:
* - No unnecessary re-renders
* - Better performance
* - Timeout ID là non-UI data
* Cons:
* - None (this is the correct approach!)
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
* Document quyết định của bạn, sau đó implement.
*/
import { useState, useRef, useEffect } from 'react';
function DebouncedSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// TODO: Decide - useState hay useRef cho timeout ID?
// const [timeoutId, setTimeoutId] = useState(null); // Approach A?
// const timeoutRef = useRef(null); // Approach B?
// TODO: Implement debounced search
// Requirements:
// 1. Wait 500ms after user stops typing
// 2. Then "search" (mock với setTimeout)
// 3. Show loading state while searching
// 4. Display results
// 5. Clear previous timeout khi user types again
useEffect(() => {
// TODO: Your debounce logic here
// Pattern structure:
// 1. Clear previous timeout (if exists)
// 2. If searchTerm empty → clear results, return
// 3. Set new timeout:
// - After 500ms: setIsSearching(true)
// - Mock API call (setTimeout 1000ms)
// - setSearchResults, setIsSearching(false)
return () => {
// TODO: Cleanup
};
}, [searchTerm]);
// Mock search function
const mockSearch = (term) => {
return new Promise((resolve) => {
setTimeout(() => {
// Fake results
const results = [
`Result 1 for "${term}"`,
`Result 2 for "${term}"`,
`Result 3 for "${term}"`,
];
resolve(results);
}, 1000);
});
};
return (
<div style={{ padding: '20px' }}>
<h2>Debounced Search</h2>
<input
type='text'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder='Type to search...'
style={{
width: '300px',
padding: '10px',
fontSize: '16px',
}}
/>
{isSearching && <p>Searching...</p>}
<ul>
{searchResults.map((result, index) => (
<li key={index}>{result}</li>
))}
</ul>
{/* Debug info */}
<div style={{ marginTop: '20px', fontSize: '12px', color: '#666' }}>
<p>Search term: "{searchTerm}"</p>
<p>Results count: {searchResults.length}</p>
</div>
</div>
);
}
// 🎯 Expected behavior:
// - Type "react" → wait → see "Searching..." → see results
// - Type "react hooks" → debounce cancels first search → only search "react hooks"
// - Clear input → results disappear immediately💡 Solution
/**
* Debounced Search Input
* Demonstrates proper use of useRef for timeout management to avoid unnecessary re-renders
* @returns {JSX.Element}
*/
function DebouncedSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
// Use useRef → timeout ID is not UI data & changing it shouldn't cause re-render
const timeoutRef = useRef(null);
useEffect(() => {
// 1. Clear any existing timeout (previous search)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 2. If search term is empty → clear results immediately
if (!searchTerm.trim()) {
setSearchResults([]);
setIsSearching(false);
return;
}
// 3. Set new debounced timeout
setIsSearching(true);
timeoutRef.current = setTimeout(async () => {
try {
const results = await mockSearch(searchTerm);
setSearchResults(results);
} catch (err) {
console.error('Search failed:', err);
setSearchResults([]);
} finally {
setIsSearching(false);
timeoutRef.current = null;
}
}, 500);
// 4. Cleanup: clear timeout when effect re-runs or component unmounts
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [searchTerm]);
// Mock search function (simulates API delay)
const mockSearch = (term) => {
return new Promise((resolve) => {
setTimeout(() => {
const results = [
`Result 1 for "${term}"`,
`Result 2 for "${term}"`,
`Result 3 for "${term}"`,
`Result 4 for "${term}"`,
];
resolve(results);
}, 1000);
});
};
return (
<div style={{ padding: '20px' }}>
<h2>Debounced Search</h2>
<input
type='text'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder='Type to search...'
style={{
width: '300px',
padding: '10px',
fontSize: '16px',
}}
/>
{isSearching && (
<p style={{ color: '#007bff', marginTop: '12px' }}>Searching...</p>
)}
{searchResults.length > 0 && (
<ul style={{ marginTop: '16px', paddingLeft: '20px' }}>
{searchResults.map((result, index) => (
<li
key={index}
style={{ marginBottom: '8px' }}
>
{result}
</li>
))}
</ul>
)}
{searchTerm && searchResults.length === 0 && !isSearching && (
<p style={{ color: '#666', marginTop: '12px' }}>No results found</p>
)}
{/* Debug info */}
<div style={{ marginTop: '30px', fontSize: '13px', color: '#555' }}>
<p>Current search term: "{searchTerm}"</p>
<p>Results count: {searchResults.length}</p>
<p>Timeout active: {timeoutRef.current ? 'Yes' : 'No'}</p>
</div>
</div>
);
}
/*
Expected behavior:
• Type "react" slowly → after ~500ms debounce → shows "Searching..." → after ~1s shows 4 results
• Type quickly "react hooks" → previous timeout is cancelled → only one search for "react hooks" runs
• Delete all text → results disappear immediately (no loading state)
• Type → stop for >500ms → search triggers
• Rapid typing → only the last term (after pause) triggers search
*/⭐⭐⭐ Exercise 3: Stopwatch với Lap Times (40 phút)
/**
* 🎯 Mục tiêu: Kịch bản thực tế với multiple refs
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là một runner, tôi muốn track lap times để phân tích performance"
*
* ✅ Acceptance Criteria:
* - [ ] Start/Stop stopwatch
* - [ ] Record lap times (array)
* - [ ] Display current lap time
* - [ ] Display all lap times với fastest/slowest highlight
* - [ ] Reset clears everything
* - [ ] Precise timing (use Date.now() not interval count)
*
* 🎨 Technical Constraints:
* - Chỉ dùng useState, useRef, useEffect (Ngày 11-21)
* - KHÔNG dùng useReducer (chưa học)
* - KHÔNG dùng custom hooks (chưa học deep dive)
*
* 🚨 Edge Cases cần handle:
* - Click Start nhiều lần
* - Click Lap khi chưa start
* - Component unmount khi stopwatch đang chạy
* - Lap khi stopwatch đang stop
*
* 📝 Implementation Checklist:
* - [ ] Core stopwatch functionality
* - [ ] Lap recording
* - [ ] Precise timing calculation
* - [ ] Fastest/Slowest lap detection
* - [ ] Edge cases handling
* - [ ] Cleanup on unmount
*/
import { useState, useRef, useEffect } from 'react';
function Stopwatch() {
// TODO: State for laps array
const [laps, setLaps] = useState([]);
// TODO: State for UI
const [isRunning, setIsRunning] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// TODO: Refs for timing
// - intervalRef: store interval ID
// - startTimeRef: store start timestamp
// - lastLapTimeRef: store last lap timestamp
// TODO: Implement start
const handleStart = () => {
// Edge case: already running?
};
// TODO: Implement stop
const handleStop = () => {
// Clear interval, keep time
};
// TODO: Implement lap
const handleLap = () => {
// Edge case: not running?
// Calculate lap time
// Add to laps array
// Update lastLapTimeRef
};
// TODO: Implement reset
const handleReset = () => {
// Stop if running
// Clear all state
// Reset all refs
};
// TODO: Calculate fastest/slowest
const getFastestLap = () => {
// Return index of fastest lap
};
const getSlowestLap = () => {
// Return index of slowest lap
};
// TODO: Cleanup
useEffect(() => {
return () => {
// Clear interval on unmount
};
}, []);
// Format time helper
const formatTime = (ms) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = Math.floor((ms % 1000) / 10);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
};
return (
<div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
<h2>Stopwatch with Laps</h2>
{/* Main timer display */}
<div
style={{
fontSize: '48px',
fontFamily: 'monospace',
textAlign: 'center',
margin: '20px 0',
padding: '20px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
}}
>
{formatTime(currentTime)}
</div>
{/* Control buttons */}
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
marginBottom: '20px',
}}
>
{/* TODO: Start/Stop button (toggle based on isRunning) */}
{/* TODO: Lap button (disabled if not running) */}
{/* TODO: Reset button */}
</div>
{/* Lap times list */}
<div
style={{
maxHeight: '300px',
overflowY: 'auto',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '10px',
}}
>
<h3>Lap Times</h3>
{laps.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center' }}>No laps yet</p>
) : (
<ol style={{ padding: '0 0 0 20px' }}>
{/* TODO: Map through laps */}
{/* TODO: Highlight fastest (green) and slowest (red) */}
{/* Format: "Lap 1: 00:05.23" */}
</ol>
)}
</div>
</div>
);
}
// 🎯 Expected behavior:
// 1. Click Start → timer runs
// 2. Click Lap → records lap time, continues timing
// 3. Multiple laps → fastest is green, slowest is red
// 4. Click Stop → timer stops, can resume
// 5. Click Reset → everything clears💡 Solution
/**
* Stopwatch with Lap Times
* Precise timing using Date.now(), multiple refs for timers, lap tracking, fastest/slowest highlighting
* @returns {JSX.Element}
*/
function Stopwatch() {
const [laps, setLaps] = useState([]);
const [isRunning, setIsRunning] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// Refs for timing control
const intervalRef = useRef(null);
const startTimeRef = useRef(null);
const lastLapTimeRef = useRef(null);
// Start / Resume
const handleStart = () => {
if (isRunning) return; // Prevent multiple intervals
setIsRunning(true);
const now = Date.now();
startTimeRef.current = now - currentTime; // Resume from current time
lastLapTimeRef.current = lastLapTimeRef.current ?? now;
intervalRef.current = setInterval(() => {
setCurrentTime(Date.now() - startTimeRef.current);
}, 10); // 10ms for smooth display
};
// Stop
const handleStop = () => {
if (!isRunning) return;
setIsRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Keep currentTime as is for resume
};
// Lap
const handleLap = () => {
if (!isRunning) return; // Can't lap when stopped
const now = Date.now();
const lapTime = now - (lastLapTimeRef.current || startTimeRef.current);
lastLapTimeRef.current = now;
setLaps((prev) => [
...prev,
{
time: lapTime,
totalTime: currentTime,
},
]);
};
// Reset
const handleReset = () => {
handleStop();
setCurrentTime(0);
setLaps([]);
startTimeRef.current = null;
lastLapTimeRef.current = null;
};
// Find fastest & slowest lap indices
const getFastestLapIndex = () => {
if (laps.length < 2) return -1;
let min = laps[0].time;
let index = 0;
laps.forEach((lap, i) => {
if (lap.time < min) {
min = lap.time;
index = i;
}
});
return index;
};
const getSlowestLapIndex = () => {
if (laps.length < 2) return -1;
let max = laps[0].time;
let index = 0;
laps.forEach((lap, i) => {
if (lap.time > max) {
max = lap.time;
index = i;
}
});
return index;
};
const fastestIndex = getFastestLapIndex();
const slowestIndex = getSlowestLapIndex();
// Format time (mm:ss.ss)
const formatTime = (ms) => {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((ms % 1000) / 10);
return `${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return (
<div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
<h2>Stopwatch with Laps</h2>
{/* Main timer */}
<div
style={{
fontSize: '48px',
fontFamily: 'monospace',
textAlign: 'center',
margin: '20px 0',
padding: '20px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
}}
>
{formatTime(currentTime)}
</div>
{/* Controls */}
<div
style={{
display: 'flex',
gap: '10px',
justifyContent: 'center',
marginBottom: '20px',
}}
>
{isRunning ? (
<button
onClick={handleStop}
style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
Stop
</button>
) : (
<button
onClick={handleStart}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
{currentTime === 0 ? 'Start' : 'Resume'}
</button>
)}
<button
onClick={handleLap}
disabled={!isRunning}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
opacity: isRunning ? 1 : 0.6,
}}
>
Lap
</button>
<button
onClick={handleReset}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
Reset
</button>
</div>
{/* Lap list */}
<div
style={{
maxHeight: '300px',
overflowY: 'auto',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '10px',
}}
>
<h3>Lap Times</h3>
{laps.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center' }}>No laps yet</p>
) : (
<ol style={{ padding: '0 0 0 20px', margin: 0 }}>
{laps.map((lap, index) => {
const isFastest = index === fastestIndex;
const isSlowest = index === slowestIndex;
const style = {
color: isFastest ? 'green' : isSlowest ? 'red' : 'inherit',
fontWeight: isFastest || isSlowest ? 'bold' : 'normal',
};
return (
<li
key={index}
style={style}
>
Lap {index + 1}: {formatTime(lap.time)} (Total:{' '}
{formatTime(lap.totalTime)})
</li>
);
})}
</ol>
)}
</div>
</div>
);
}
/*
Expected behavior:
1. Click Start → timer runs smoothly
2. Click Lap → records current lap time, continues timing
3. Multiple laps → fastest lap is green, slowest is red
4. Click Stop → timer pauses, can resume
5. Click Reset → timer and laps clear
6. Edge cases handled:
- Cannot lap when stopped
- Cannot start multiple intervals
- Cleanup on unmount prevents memory leak
- Precise timing using Date.now()
*/⭐⭐⭐⭐ Exercise 4: Form with Auto-save (60 phút)
/**
* 🎯 Mục tiêu: Architectural decision với multiple approaches
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Scenario:
* Build form tự động save draft sau khi user stop typing 2 giây.
* Hiển thị last saved time và saving status.
*
* Nhiệm vụ:
* 1. So sánh ít nhất 3 approaches:
* - Approach A: All useState (including timeout ID)
* - Approach B: useState + useRef for timeout
* - Approach C: useState + useRef for timeout + last save time
* 2. Document pros/cons mỗi approach
* 3. Chọn approach phù hợp nhất
* 4. Viết ADR (Architecture Decision Record)
*
* ADR Template:
* ────────────────────────────────────────
* Context:
* - Form có nhiều fields
* - Auto-save sau 2s không type
* - Hiển thị saving status
* - Hiển thị last saved time
*
* Decision: [Approach bạn chọn]
*
* Rationale:
* - [Lý do 1]
* - [Lý do 2]
* - [Lý do 3]
*
* Consequences:
* Trade-offs accepted:
* - [Trade-off 1]
* - [Trade-off 2]
*
* Alternatives Considered:
* - Approach A: [Brief summary + why rejected]
* - Approach B: [Brief summary + why rejected]
* ────────────────────────────────────────
*
* 💻 PHASE 2: Implementation (30 phút)
*/
import { useState, useRef, useEffect } from 'react';
function AutoSaveForm() {
// Form data
const [formData, setFormData] = useState({
title: '',
content: '',
category: 'general',
});
// UI states
const [savingStatus, setSavingStatus] = useState('idle'); // 'idle' | 'saving' | 'saved'
const [lastSavedAt, setLastSavedAt] = useState(null);
// TODO: Decide architecture
// What refs do you need?
// - Timeout ID?
// - Last saved data (để compare)?
// - Save timestamp?
// TODO: Implement auto-save logic
useEffect(() => {
// Debounce logic (2 seconds)
// 1. Clear previous timeout
// 2. Set new timeout
// 3. Compare current data với last saved
// 4. If different → save
// 5. Update saving status
return () => {
// Cleanup
};
}, [formData]);
// Mock save function
const saveDraft = async (data) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('Saved:', data);
resolve();
}, 1000);
});
};
const handleChange = (field) => (e) => {
setFormData((prev) => ({
...prev,
[field]: e.target.value,
}));
};
// Manual save
const handleManualSave = async () => {
setSavingStatus('saving');
await saveDraft(formData);
setSavingStatus('saved');
setLastSavedAt(new Date());
};
// Format last saved time
const formatLastSaved = () => {
if (!lastSavedAt) return 'Never';
const now = Date.now();
const diff = now - lastSavedAt.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minutes ago`;
return lastSavedAt.toLocaleTimeString();
};
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>Auto-save Form</h2>
{/* Saving status indicator */}
<div
style={{
padding: '10px',
marginBottom: '20px',
backgroundColor:
savingStatus === 'saving'
? '#fff3cd'
: savingStatus === 'saved'
? '#d1e7dd'
: '#f8f9fa',
border: '1px solid',
borderColor:
savingStatus === 'saving'
? '#ffc107'
: savingStatus === 'saved'
? '#28a745'
: '#dee2e6',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>
{savingStatus === 'saving' && '💾 Saving...'}
{savingStatus === 'saved' && '✅ Saved'}
{savingStatus === 'idle' && '📝 Draft'}
</span>
<span style={{ fontSize: '12px', color: '#666' }}>
Last saved: {formatLastSaved()}
</span>
</div>
{/* Form fields */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Title
</label>
<input
type='text'
value={formData.title}
onChange={handleChange('title')}
placeholder='Enter title...'
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Content
</label>
<textarea
value={formData.content}
onChange={handleChange('content')}
placeholder='Enter content...'
rows={6}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #ccc',
borderRadius: '4px',
fontFamily: 'inherit',
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
>
Category
</label>
<select
value={formData.category}
onChange={handleChange('category')}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
>
<option value='general'>General</option>
<option value='work'>Work</option>
<option value='personal'>Personal</option>
</select>
</div>
<button
onClick={handleManualSave}
disabled={savingStatus === 'saving'}
style={{
padding: '10px 20px',
fontSize: '16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: savingStatus === 'saving' ? 'not-allowed' : 'pointer',
opacity: savingStatus === 'saving' ? 0.6 : 1,
}}
>
Save Now
</button>
{/* Debug info */}
<div
style={{
marginTop: '30px',
padding: '10px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace',
}}
>
<p>
<strong>Debug Info:</strong>
</p>
<p>Status: {savingStatus}</p>
<p>Form Data: {JSON.stringify(formData)}</p>
</div>
</div>
);
}
// 🧪 PHASE 3: Testing (10 phút)
// Manual testing checklist:
// - [ ] Type in title → waits 2s → auto-saves
// - [ ] Type quickly → only saves once after stop typing
// - [ ] Change category → auto-saves
// - [ ] Click "Save Now" → saves immediately
// - [ ] Status indicator updates correctly
// - [ ] Last saved time updates
// - [ ] No unnecessary re-renders (check React DevTools)💡 Solution
/**
* Auto-save Form with Debounced Saving
* Uses useRef for timeout management to prevent unnecessary re-renders
* Shows saving status and last saved timestamp
* @returns {JSX.Element}
*/
function AutoSaveForm() {
const [formData, setFormData] = useState({
title: '',
content: '',
category: 'general',
});
const [savingStatus, setSavingStatus] = useState('idle'); // 'idle' | 'saving' | 'saved'
const [lastSavedAt, setLastSavedAt] = useState(null);
// Refs for non-UI mutable values
const timeoutRef = useRef(null);
const lastSavedDataRef = useRef(null); // to compare and avoid unnecessary saves
// Mock save function
const saveDraft = async (data) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log('Saved draft:', data);
resolve();
}, 1000);
});
};
// Format time since last save
const formatLastSaved = () => {
if (!lastSavedAt) return 'Never';
const diffMs = Date.now() - lastSavedAt;
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minutes ago`;
return new Date(lastSavedAt).toLocaleTimeString();
};
// Handle form changes
const handleChange = (field) => (e) => {
setFormData((prev) => ({
...prev,
[field]: e.target.value,
}));
};
// Manual save button
const handleManualSave = async () => {
setSavingStatus('saving');
try {
await saveDraft(formData);
setLastSavedAt(Date.now());
lastSavedDataRef.current = { ...formData };
setSavingStatus('saved');
} catch (err) {
console.error('Manual save failed:', err);
setSavingStatus('idle');
}
};
// Auto-save logic with debounce
useEffect(() => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Skip save if data hasn't actually changed since last save
if (
lastSavedDataRef.current &&
JSON.stringify(lastSavedDataRef.current) === JSON.stringify(formData)
) {
return;
}
// Set new debounced save (2 seconds)
timeoutRef.current = setTimeout(async () => {
setSavingStatus('saving');
try {
await saveDraft(formData);
setLastSavedAt(Date.now());
lastSavedDataRef.current = { ...formData };
setSavingStatus('saved');
// Reset to idle after a short delay so user sees "saved" feedback
setTimeout(() => {
setSavingStatus('idle');
}, 2000);
} catch (err) {
console.error('Auto-save failed:', err);
setSavingStatus('idle');
}
}, 2000);
// Cleanup on unmount or when formData changes
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [formData]);
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h2>Auto-save Form</h2>
{/* Status indicator */}
<div
style={{
padding: '12px',
marginBottom: '20px',
backgroundColor:
savingStatus === 'saving'
? '#fff3cd'
: savingStatus === 'saved'
? '#d1e7dd'
: '#f8f9fa',
border: '1px solid',
borderColor:
savingStatus === 'saving'
? '#ffc107'
: savingStatus === 'saved'
? '#28a745'
: '#dee2e6',
borderRadius: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: '500' }}>
{savingStatus === 'saving' && '💾 Saving...'}
{savingStatus === 'saved' && '✅ Saved'}
{savingStatus === 'idle' && '📝 Draft ready to save'}
</span>
<span style={{ fontSize: '14px', color: '#555' }}>
Last saved: {formatLastSaved()}
</span>
</div>
{/* Form fields */}
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '6px', fontWeight: 'bold' }}
>
Title
</label>
<input
type='text'
value={formData.title}
onChange={handleChange('title')}
placeholder='Enter title...'
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
borderRadius: '4px',
border: '1px solid #ccc',
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label
style={{ display: 'block', marginBottom: '6px', fontWeight: 'bold' }}
>
Content
</label>
<textarea
value={formData.content}
onChange={handleChange('content')}
placeholder='Enter content...'
rows={6}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
borderRadius: '4px',
border: '1px solid #ccc',
fontFamily: 'inherit',
}}
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label
style={{ display: 'block', marginBottom: '6px', fontWeight: 'bold' }}
>
Category
</label>
<select
value={formData.category}
onChange={handleChange('category')}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
borderRadius: '4px',
border: '1px solid #ccc',
}}
>
<option value='general'>General</option>
<option value='work'>Work</option>
<option value='personal'>Personal</option>
</select>
</div>
<button
onClick={handleManualSave}
disabled={savingStatus === 'saving'}
style={{
padding: '12px 24px',
fontSize: '16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: savingStatus === 'saving' ? 'not-allowed' : 'pointer',
opacity: savingStatus === 'saving' ? 0.6 : 1,
}}
>
Save Now
</button>
{/* Debug panel */}
<div
style={{
marginTop: '40px',
padding: '12px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
fontSize: '13px',
color: '#444',
}}
>
<strong>Debug Info:</strong>
<br />
Status: {savingStatus}
<br />
Auto-save timeout active: {timeoutRef.current ? 'Yes' : 'No'}
<br />
Form data changed since last save:{' '}
{lastSavedDataRef.current
? JSON.stringify(lastSavedDataRef.current) !==
JSON.stringify(formData)
: 'Yes (first save)'}
</div>
</div>
);
}
/*
Expected behavior:
• Type in any field → after 2 seconds of inactivity → shows "Saving..." → then "Saved" → status returns to idle
• Rapid typing → only one save attempt after user stops for 2s
• Changing category also triggers auto-save
• Click "Save Now" → saves immediately, bypasses debounce
• If content doesn't change → no unnecessary save attempts
• Status indicator changes color appropriately
• Last saved time updates correctly
*/⭐⭐⭐⭐⭐ Exercise 5: Advanced Polling Component (90 phút)
/**
* 🎯 Mục tiêu: Production-ready component với complex ref management
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* Build một component fetch data từ API với:
* 1. Tự động refresh theo chu kỳ X giây (có thể cấu hình)
* 2. Hỗ trợ pause / resume polling
* 3. Cho phép refresh thủ công
* 4. Hiển thị thời gian kể từ lần cập nhật thành công gần nhất
* 5. Hiển thị trạng thái kết nối (idle / loading / success / error)
* 6. Tự động tạm dừng polling khi tab không active
* 7. Retry request khi lỗi bằng exponential backoff
* 8. Hỗ trợ huỷ request đang chạy (AbortController)
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture:
* - State: data, loading, error, status, lastUpdate
* - Refs: intervalId, retryCount, abortController
* - Props: url, interval, onData, onError
*
* 2. State Management Strategy:
* - useState cho UI-critical data
* - useRef cho timers, counters, abort controllers
* - Không dùng useReducer (chưa học)
*
* 3. API Integration:
* - fetch với AbortController
* - Error handling với retry logic
* - Response validation
*
* 4. Performance Considerations:
* - Cleanup tất cả subscriptions
* - Cancel in-flight requests
* - Pause khi tab hidden
*
* 5. Error Handling Strategy:
* - Try exponential backoff: 1s, 2s, 4s, 8s, 16s
* - Max 5 retries
* - Reset retry count on success
* - Display error message
*
* ✅ Production Checklist:
* - [ ] TypeScript types đầy đủ (ở đây dùng JSDoc comments)
* - [ ] Comprehensive error handling
* - [ ] Loading states
* - [ ] Empty states
* - [ ] Edge case handling (rapid pause/resume, unmount during fetch)
* - [ ] Performance optimization (unnecessary re-renders)
* - [ ] Memory leak prevention
* - [ ] Visibility API integration
* - [ ] Request cancellation
* - [ ] Console logging cho debugging
*/
import { useState, useRef, useEffect } from 'react';
/**
* @typedef {Object} PollingConfig
* @property {string} url - API endpoint
* @property {number} interval - Polling interval in ms (default: 5000)
* @property {boolean} pauseOnHidden - Pause when tab hidden (default: true)
* @property {Function} onData - Callback khi có data mới
* @property {Function} onError - Callback khi có error
*/
/**
* Advanced Polling Component
* @param {PollingConfig} props
*/
function AdvancedPolling({
url,
interval = 5000,
pauseOnHidden = true,
onData,
onError,
}) {
// ═══════════════════════════════════════
// STATE MANAGEMENT
// ═══════════════════════════════════════
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [status, setStatus] = useState('idle'); // 'idle' | 'polling' | 'paused' | 'error'
const [lastUpdate, setLastUpdate] = useState(null);
// ═══════════════════════════════════════
// REFS FOR NON-UI DATA
// ═══════════════════════════════════════
// TODO: Implement refs
// - intervalRef: store setInterval ID
// - retryCountRef: track consecutive failures
// - abortControllerRef: cancel requests
// - isPausedRef: track pause state (sync with visibility)
// ═══════════════════════════════════════
// CORE FETCHING LOGIC
// ═══════════════════════════════════════
const fetchData = async () => {
// TODO: Implement với:
// 1. Check if paused
// 2. Create AbortController
// 3. setLoading(true)
// 4. try-catch fetch
// 5. Validate response
// 6. Update data, lastUpdate, reset retryCount
// 7. Call onData callback
// 8. catch: handle errors, exponential backoff
// 9. finally: setLoading(false)
};
// ═══════════════════════════════════════
// POLLING CONTROL
// ═══════════════════════════════════════
const startPolling = () => {
// TODO:
// 1. Check if already polling
// 2. Clear existing interval
// 3. Fetch immediately
// 4. Set up interval
// 5. Update status
};
const pausePolling = () => {
// TODO:
// 1. Clear interval
// 2. Update status
// 3. Set isPausedRef
};
const resumePolling = () => {
// TODO:
// 1. Reset isPausedRef
// 2. Start polling
};
const manualRefresh = () => {
// TODO:
// 1. Cancel current request if any
// 2. Reset retry count
// 3. Fetch immediately
};
// ═══════════════════════════════════════
// VISIBILITY CHANGE HANDLING
// ═══════════════════════════════════════
useEffect(() => {
if (!pauseOnHidden) return;
// TODO: Implement Visibility API
// 1. Add event listener for 'visibilitychange'
// 2. If hidden → pause polling
// 3. If visible → resume polling
// 4. Cleanup listener
return () => {
// Cleanup
};
}, [pauseOnHidden]);
// ═══════════════════════════════════════
// INITIAL START & CLEANUP
// ═══════════════════════════════════════
useEffect(() => {
// TODO:
// 1. Start polling on mount
// 2. Cleanup everything on unmount
// - Clear interval
// - Abort in-flight request
return () => {
// Critical cleanup
};
}, [url, interval]);
// ═══════════════════════════════════════
// HELPER: Calculate time since last update
// ═══════════════════════════════════════
const getTimeSinceUpdate = () => {
if (!lastUpdate) return 'Never';
const diff = Date.now() - lastUpdate;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
};
// ═══════════════════════════════════════
// HELPER: Get status color
// ═══════════════════════════════════════
const getStatusColor = () => {
switch (status) {
case 'polling':
return '#28a745';
case 'paused':
return '#ffc107';
case 'error':
return '#dc3545';
default:
return '#6c757d';
}
};
// ═══════════════════════════════════════
// RENDER
// ═══════════════════════════════════════
return (
<div
style={{
padding: '20px',
maxWidth: '800px',
margin: '0 auto',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<h2>Advanced Polling Component</h2>
{/* Status Bar */}
<div
style={{
padding: '15px',
marginBottom: '20px',
backgroundColor: '#f8f9fa',
border: '2px solid',
borderColor: getStatusColor(),
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<span
style={{
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: getStatusColor(),
marginRight: '10px',
}}
/>
<strong>Status:</strong> {status}
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
Last update: {getTimeSinceUpdate()}
</div>
</div>
{/* Control Buttons */}
<div
style={{
display: 'flex',
gap: '10px',
marginBottom: '20px',
}}
>
{status === 'polling' ? (
<button
onClick={pausePolling}
style={{
padding: '10px 20px',
backgroundColor: '#ffc107',
color: '#000',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
⏸ Pause
</button>
) : (
<button
onClick={resumePolling}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
}}
>
▶ Resume
</button>
)}
<button
onClick={manualRefresh}
disabled={loading}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
fontWeight: 'bold',
}}
>
🔄 Refresh Now
</button>
</div>
{/* Loading Indicator */}
{loading && (
<div
style={{
padding: '10px',
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
borderRadius: '4px',
marginBottom: '20px',
textAlign: 'center',
}}
>
⏳ Fetching data...
</div>
)}
{/* Error Display */}
{error && (
<div
style={{
padding: '15px',
backgroundColor: '#f8d7da',
border: '1px solid #dc3545',
borderRadius: '4px',
marginBottom: '20px',
color: '#721c24',
}}
>
<strong>❌ Error:</strong> {error}
<div style={{ marginTop: '10px', fontSize: '14px' }}>
Retry attempt: {/* TODO: show retryCount */}
</div>
</div>
)}
{/* Data Display */}
<div
style={{
padding: '20px',
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '8px',
minHeight: '200px',
}}
>
<h3>Data:</h3>
{data ? (
<pre
style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '14px',
}}
>
{JSON.stringify(data, null, 2)}
</pre>
) : (
<p style={{ color: '#999', textAlign: 'center' }}>
No data yet. Waiting for first poll...
</p>
)}
</div>
{/* Debug Info */}
<details style={{ marginTop: '20px' }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold' }}>
🔍 Debug Information
</summary>
<div
style={{
marginTop: '10px',
padding: '15px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace',
}}
>
<p>
<strong>URL:</strong> {url}
</p>
<p>
<strong>Interval:</strong> {interval}ms
</p>
<p>
<strong>Status:</strong> {status}
</p>
<p>
<strong>Loading:</strong> {loading ? 'Yes' : 'No'}
</p>
<p>
<strong>Has Data:</strong> {data ? 'Yes' : 'No'}
</p>
<p>
<strong>Has Error:</strong> {error ? 'Yes' : 'No'}
</p>
<p>
<strong>Pause on Hidden:</strong> {pauseOnHidden ? 'Yes' : 'No'}
</p>
{/* TODO: Add more debug info from refs */}
</div>
</details>
</div>
);
}
// ═══════════════════════════════════════
// EXAMPLE USAGE
// ═══════════════════════════════════════
function App() {
return (
<AdvancedPolling
url='https://jsonplaceholder.typicode.com/posts/1'
interval={5000}
pauseOnHidden={true}
onData={(data) => console.log('New data:', data)}
onError={(error) => console.error('Polling error:', error)}
/>
);
}
// 📝 Documentation Requirements:
//
// Write a README.md explaining:
// 1. Component API (props)
// 2. Features
// 3. Usage examples
// 4. Edge cases handled
// 5. Performance considerations
//
// 🔍 Code Review Self-Checklist:
// - [ ] All refs properly initialized
// - [ ] All intervals/timeouts cleared
// - [ ] All requests cancellable
// - [ ] No memory leaks
// - [ ] Error handling comprehensive
// - [ ] Loading states accurate
// - [ ] Status updates correct
// - [ ] Visibility API working
// - [ ] Exponential backoff implemented
// - [ ] Console logs helpful for debugging
// - [ ] Code readable and maintainable
// - [ ] Edge cases handled
// - [ ] Comments explain complex logic💡 Solution
/**
* Advanced Polling Component
* Features: auto-polling, pause/resume, manual refresh, visibility handling,
* exponential backoff on error, request cancellation
* @param {Object} props
* @param {string} props.url - API endpoint to poll
* @param {number} [props.interval=5000] - Polling interval in ms
* @param {boolean} [props.pauseOnHidden=true] - Pause polling when tab is hidden
* @param {Function} [props.onData] - Optional callback when new data arrives
* @param {Function} [props.onError] - Optional callback on error
*/
function AdvancedPolling({
url,
interval = 5000,
pauseOnHidden = true,
onData,
onError,
}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [status, setStatus] = useState('idle'); // idle | polling | paused | error
const [lastUpdate, setLastUpdate] = useState(null);
// Refs for mutable, non-UI values
const intervalRef = useRef(null);
const abortControllerRef = useRef(null);
const retryCountRef = useRef(0);
const isPausedRef = useRef(false);
const isMountedRef = useRef(true);
// Exponential backoff delays (in ms)
const backoffDelays = [1000, 2000, 4000, 8000, 16000];
const fetchData = async (isManual = false) => {
if (isPausedRef.current && !isManual) return;
// Cancel any previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
setData(result);
setLastUpdate(Date.now());
setStatus('polling');
retryCountRef.current = 0; // Reset retry on success
if (onData) onData(result);
} catch (err) {
if (err.name === 'AbortError') return;
console.error('Fetch error:', err);
const message = err.message || 'Failed to fetch data';
setError(message);
setStatus('error');
if (onError) onError(err);
// Exponential backoff retry (only for automatic polling)
if (!isManual && retryCountRef.current < backoffDelays.length) {
const delay = backoffDelays[retryCountRef.current];
retryCountRef.current += 1;
setTimeout(() => {
if (isMountedRef.current && !isPausedRef.current) {
fetchData();
}
}, delay);
}
} finally {
setLoading(false);
abortControllerRef.current = null;
}
};
const startPolling = () => {
if (intervalRef.current) return;
isPausedRef.current = false;
setStatus('polling');
// Initial fetch
fetchData();
// Then set interval
intervalRef.current = setInterval(() => {
if (!isPausedRef.current) {
fetchData();
}
}, interval);
};
const pausePolling = () => {
isPausedRef.current = true;
setStatus('paused');
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const resumePolling = () => {
isPausedRef.current = false;
setStatus('polling');
startPolling(); // Will do initial fetch + set new interval
};
const manualRefresh = () => {
fetchData(true); // Force fetch regardless of pause state
};
// Handle page visibility
useEffect(() => {
if (!pauseOnHidden) return;
const handleVisibilityChange = () => {
if (document.hidden) {
pausePolling();
} else if (status !== 'paused' && status !== 'error') {
resumePolling();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [pauseOnHidden, status]);
// Main polling lifecycle
useEffect(() => {
isMountedRef.current = true;
startPolling();
return () => {
isMountedRef.current = false;
// Comprehensive cleanup
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [url, interval]); // Re-start polling if url or interval changes
// Helpers
const getTimeSinceUpdate = () => {
if (!lastUpdate) return 'Never';
const diff = Date.now() - lastUpdate;
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
};
const getStatusColor = () => {
switch (status) {
case 'polling':
return '#28a745';
case 'paused':
return '#ffc107';
case 'error':
return '#dc3545';
default:
return '#6c757d';
}
};
return (
<div
style={{
padding: '20px',
maxWidth: '800px',
margin: '0 auto',
fontFamily: 'system-ui, sans-serif',
}}
>
<h2>Advanced Polling Component</h2>
{/* Status bar */}
<div
style={{
padding: '14px',
marginBottom: '20px',
backgroundColor: '#f8f9fa',
border: `2px solid ${getStatusColor()}`,
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: getStatusColor(),
}}
/>
<strong>Status:</strong>{' '}
{status.charAt(0).toUpperCase() + status.slice(1)}
{retryCountRef.current > 0 && status === 'error' && (
<span style={{ marginLeft: '12px', color: '#dc3545' }}>
Retry attempt {retryCountRef.current}
</span>
)}
</div>
<div style={{ fontSize: '14px', color: '#555' }}>
Last update: {getTimeSinceUpdate()}
</div>
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px' }}>
{status === 'polling' ? (
<button
onClick={pausePolling}
style={{
padding: '10px 20px',
backgroundColor: '#ffc107',
color: '#000',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 600,
}}
>
⏸ Pause
</button>
) : (
<button
onClick={resumePolling}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 600,
}}
>
▶ Resume
</button>
)}
<button
onClick={manualRefresh}
disabled={loading}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.6 : 1,
fontWeight: 600,
}}
>
{loading ? 'Fetching...' : '🔄 Refresh Now'}
</button>
</div>
{/* Loading */}
{loading && (
<div
style={{
padding: '12px',
backgroundColor: '#e3f2fd',
border: '1px solid #2196f3',
borderRadius: '6px',
textAlign: 'center',
marginBottom: '20px',
}}
>
⏳ Fetching latest data...
</div>
)}
{/* Error */}
{error && (
<div
style={{
padding: '16px',
backgroundColor: '#f8d7da',
border: '1px solid #dc3545',
borderRadius: '6px',
marginBottom: '20px',
color: '#721c24',
}}
>
<strong>Error:</strong> {error}
{retryCountRef.current > 0 && (
<div style={{ marginTop: '8px', fontSize: '14px' }}>
Will retry in{' '}
{Math.round(backoffDelays[retryCountRef.current - 1] / 1000)}s...
</div>
)}
</div>
)}
{/* Data display */}
<div
style={{
padding: '20px',
backgroundColor: 'white',
border: '1px solid #dee2e6',
borderRadius: '8px',
minHeight: '220px',
}}
>
<h3>Data</h3>
{data ? (
<pre
style={{
backgroundColor: '#f8f9fa',
padding: '16px',
borderRadius: '6px',
overflow: 'auto',
fontSize: '14px',
}}
>
{JSON.stringify(data, null, 2)}
</pre>
) : (
<p style={{ color: '#777', textAlign: 'center', marginTop: '60px' }}>
Waiting for first successful poll...
</p>
)}
</div>
</div>
);
}
/*
Expected behavior:
• Mounts → starts polling immediately
• Data updates every `interval` ms (default 5000)
• Pause button → stops polling, status → paused
• Resume button → restarts polling + immediate fetch
• Refresh Now → forces fetch even when paused
• Tab hidden (if pauseOnHidden) → auto-pauses
• Tab visible again → auto-resumes (if was polling before)
• Network error → shows error + exponential backoff retries
• Request in progress → can be cancelled on new request / unmount
• Unmount → clears interval + aborts fetch → no memory leak
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: useState vs useRef
| Tiêu chí | useState | useRef | Khi nào dùng? |
|---|---|---|---|
| Re-render | ✅ Trigger re-render | ❌ KHÔNG trigger re-render | useState: UI data useRef: Non-UI data |
| Update timing | Async (batched) | Sync (immediate) | useState: Cần consistency useRef: Cần instant access |
| Purpose | UI state management | Mutable values persistence | useState: Hiển thị lên UI useRef: Internal tracking |
| Access pattern | value | ref.current | - |
| Persistence | ✅ Persists across renders | ✅ Persists across renders | Cả hai đều persist |
| Initial value | useState(initial) | useRef(initial) | - |
| Performance | Can cause re-renders | No re-render overhead | useRef: Better cho high-frequency updates |
| Use cases | Form inputs, toggles, counters hiển thị | Timer IDs, previous values, flags | - |
Trade-offs Chi Tiết
✅ Khi nào PHẢI dùng useState:
// 1. UI data - cần hiển thị lên màn hình
const [count, setCount] = useState(0);
return <p>Count: {count}</p>; // ✅ UI needs this
// 2. Conditional rendering
const [isOpen, setIsOpen] = useState(false);
return isOpen ? <Modal /> : null; // ✅ Affects what renders
// 3. Derived values dùng trong JSX
const [items, setItems] = useState([]);
return <p>Total: {items.length}</p>; // ✅ UI depends on this
// 4. Props passed to children
const [theme, setTheme] = useState('dark');
return <Button theme={theme} />; // ✅ Child needs this✅ Khi nào PHẢI dùng useRef:
// 1. Timer/Interval IDs
const intervalRef = useRef(null);
intervalRef.current = setInterval(/* ... */); // ✅ Non-UI, no need to render
// 2. Previous values
const prevCountRef = useRef(count);
useEffect(() => {
prevCountRef.current = count;
}); // ✅ Tracking, not displaying
// 3. Flags không ảnh hưởng UI
const isMountedRef = useRef(true);
useEffect(() => () => {
isMountedRef.current = false;
}); // ✅ Internal flag
// 4. Mutable values thay đổi thường xuyên
const renderCountRef = useRef(0);
renderCountRef.current += 1; // ✅ Would cause infinite loop with useStateDecision Tree
Cần lưu giá trị?
│
┌───────────────┴───────────────┐
│ │
Có (persist) Không (local variable)
│
Giá trị này hiển thị UI?
│
┌──────┴──────┐
│ │
Có Không
│ │
useState useRef
Ví dụ useState: Ví dụ useRef:
- Counter display - Timer ID
- Form values - Previous value
- Toggle state - Render count
- Loading state - Abort controller
- Error message - Flag variablesPattern Combinations
Pattern 1: useState + useRef cho Derived State
// ✅ GOOD: useState cho source, useRef cho derived
function SearchWithHistory() {
const [searchTerm, setSearchTerm] = useState('');
const previousSearchRef = useRef('');
useEffect(() => {
previousSearchRef.current = searchTerm;
}, [searchTerm]);
const searchChanged = searchTerm !== previousSearchRef.current;
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchChanged && <p>Search term changed!</p>}
</div>
);
}Pattern 2: Multiple Refs cho Related Data
// ✅ GOOD: Group related refs
function ComplexTimer() {
const timerRefs = useRef({
intervalId: null,
startTime: null,
pausedTime: null
});
const start = () => {
timerRefs.current.startTime = Date.now();
timerRefs.current.intervalId = setInterval(/* ... */);
};
const pause = () => {
timerRefs.current.pausedTime = Date.now();
clearInterval(timerRefs.current.intervalId);
};
return (/* ... */);
}Pattern 3: Ref cho Optimization
// ✅ GOOD: useRef tránh unnecessary re-renders
function ChatRoom() {
const [messages, setMessages] = useState([]);
const scrollRef = useRef(null);
const prevMessagesLengthRef = useRef(messages.length);
useEffect(() => {
// Chỉ scroll nếu có message mới
if (messages.length > prevMessagesLengthRef.current) {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLengthRef.current = messages.length;
}, [messages]);
return (
<div>
{messages.map((msg) => (
<div key={msg.id}>{msg.text}</div>
))}
<div ref={scrollRef} />
</div>
);
}🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Stale Ref Value ⭐
// ❌ BUG: Ref value không update như mong đợi
function BuggyCounter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
const logCount = () => {
console.log('Ref value:', countRef.current); // ⚠️ Luôn 0!
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={logCount}>Log Ref</button>
</div>
);
}🔍 Debug Questions:
- Tại sao
countRef.currentluôn là 0? - Ref được update khi nào?
- Làm sao fix?
💡 Giải thích:
// ❌ VẤN ĐỀ:
const countRef = useRef(count);
// useRef chỉ chạy lần đầu (mount)
// count thay đổi nhưng ref KHÔNG tự động sync!
// ✅ SOLUTION 1: Manual sync với useEffect
function FixedCounter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Sync manually
}, [count]);
const logCount = () => {
console.log('Ref value:', countRef.current); // ✅ Correct!
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={logCount}>Log Ref</button>
</div>
);
}
// ✅ SOLUTION 2: Đọc trực tiếp từ state (nếu có thể)
function BetterCounter() {
const [count, setCount] = useState(0);
const logCount = () => {
console.log('Count value:', count); // ✅ Always correct
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={logCount}>Log Count</button>
</div>
);
}Bug 2: Memory Leak - Không Cleanup Timer ⭐⭐
// ❌ BUG: Memory leak khi component unmount
function BuggyTimer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const startTimer = () => {
intervalRef.current = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
};
const stopTimer = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
// ⚠️ THIẾU CLEANUP!
return (
<div>
<p>Count: {count}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}🔍 Debug Questions:
- Điều gì xảy ra nếu component unmount khi timer đang chạy?
- Làm sao detect memory leak?
- Cách fix đúng?
💡 Giải thích:
// ❌ VẤN ĐỀ:
// Component unmount → interval vẫn chạy → call setCount → error + memory leak
// ⚠️ Error trong console:
// "Warning: Can't perform a React state update on an unmounted component"
// ✅ SOLUTION: useEffect cleanup
function FixedTimer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const startTimer = () => {
if (intervalRef.current) return; // Prevent multiple intervals
intervalRef.current = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
};
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// ✅ Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []); // Empty deps = chỉ mount/unmount
return (
<div>
<p>Count: {count}</p>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
}🔍 Cách test memory leak:
function App() {
const [showTimer, setShowTimer] = useState(true);
return (
<div>
<button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>
{showTimer && <FixedTimer />}
</div>
);
}
// Test steps:
// 1. Start timer
// 2. Click "Toggle Timer" (unmount component)
// 3. Check console - không có warning = good!
// 4. Open DevTools → Memory → Take heap snapshot
// 5. Unmount/remount nhiều lần
// 6. Take another snapshot
// 7. Compare → detached intervals = memory leakBug 3: Ref vs State Confusion ⭐⭐⭐
// ❌ BUG: Dùng ref cho UI data
function BuggyTodoList() {
const todosRef = useRef([]);
const addTodo = (text) => {
todosRef.current = [...todosRef.current, { id: Date.now(), text }];
console.log('Todos:', todosRef.current); // ✅ Updated in ref
};
return (
<div>
<button onClick={() => addTodo('New todo')}>Add Todo</button>
<ul>
{todosRef.current.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
{/* ⚠️ UI never updates! */}
</div>
);
}🔍 Debug Questions:
- Tại sao UI không update?
- Console.log shows correct data nhưng UI stale?
- Khi nào thì UI update?
💡 Giải thích:
// ❌ VẤN ĐỀ:
// - todosRef.current thay đổi ✅
// - Nhưng không trigger re-render ❌
// - UI chỉ render lần đầu với empty array
// ✅ SOLUTION: Dùng useState cho UI data
function FixedTodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos((prev) => [...prev, { id: Date.now(), text }]); // ✅ Triggers re-render
};
return (
<div>
<button onClick={() => addTodo('New todo')}>Add Todo</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
// 💡 QUY TẮC:
// If it's displayed in JSX → useState
// If it's internal tracking → useRefKhi nào UI sẽ update nếu dùng ref?
function WhenRefUpdates() {
const countRef = useRef(0);
const [, forceRender] = useState({});
const increment = () => {
countRef.current += 1;
// UI vẫn không update!
};
const incrementAndRender = () => {
countRef.current += 1;
forceRender({}); // ✅ Force re-render
// Bây giờ UI mới update!
};
return (
<div>
<p>Count: {countRef.current}</p>
<button onClick={increment}>Increment (no update)</button>
<button onClick={incrementAndRender}>Increment (with update)</button>
</div>
);
}
// ⚠️ Nhưng đây là ANTI-PATTERN!
// Nếu cần UI update → dùng useState!✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu các câu bạn có thể trả lời tự tin:
- [ ] useRef trả về gì và object đó có cấu trúc như thế nào?
- [ ] Tại sao update
ref.currentkhông trigger re-render? - [ ] Sự khác biệt chính giữa useState và useRef là gì?
- [ ] Khi nào nên dùng useRef thay vì useState?
- [ ] Làm sao track previous value của một state?
- [ ] Tại sao cần cleanup timers trong useEffect?
- [ ] useRef có thể thay thế useState để tối ưu performance không?
- [ ] Ref object có thay đổi giữa các lần render không?
- [ ] Có thể dùng useRef để store object/array không?
- [ ] Làm sao debug memory leak từ timers?
Code Review Checklist
Khi review code có useRef, check:
✅ Correct Usage:
- [ ] Dùng ref cho non-UI data (timer IDs, flags, previous values)
- [ ] Dùng state cho UI data
- [ ] Update ref.current synchronously khi cần
- [ ] Không dựa vào ref.current để trigger re-renders
✅ Cleanup:
- [ ] Tất cả timers đều được cleared
- [ ] useEffect có return cleanup function
- [ ] Cleanup chạy on unmount và trước next effect
- [ ] Refs được reset khi cần (set về null)
✅ Edge Cases:
- [ ] Handle multiple starts (prevent duplicate timers)
- [ ] Handle unmount mid-operation
- [ ] Validate ref.current trước khi dùng
- [ ] Consider race conditions
✅ Performance:
- [ ] Không setState unnecessarily
- [ ] Refs được dùng đúng chỗ (không gây extra renders)
- [ ] No memory leaks
❌ Common Mistakes:
- [ ] Không dùng ref cho UI data
- [ ] Không quên cleanup
- [ ] Không expect ref thay đổi trigger render
- [ ] Không dùng ref initial value như useState
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Exercise: Custom useTimeout Hook
/**
* 🎯 Mục tiêu: Tạo custom hook wrap setTimeout
*
* Requirements:
* 1. Hook nhận callback và delay
* 2. Tự động cleanup on unmount
* 3. Có thể reset timer
* 4. Có thể cancel timer
*
* API:
* const { reset, cancel } = useTimeout(callback, delay);
*/
// TODO: Implement useTimeout
function useTimeout(callback, delay) {
// Hints:
// - useRef cho timeout ID
// - useRef cho callback (để tránh stale closure)
// - useEffect để setup/cleanup
// - Return reset và cancel functions
}
// Usage example:
function NotificationDemo() {
const [show, setShow] = useState(false);
const { reset, cancel } = useTimeout(() => {
setShow(false);
}, 3000);
const showNotification = () => {
setShow(true);
reset(); // Reset timer
};
return (
<div>
<button onClick={showNotification}>Show Notification</button>
{show && (
<div>
<p>This will disappear in 3 seconds</p>
<button onClick={cancel}>Keep it</button>
</div>
)}
</div>
);
}💡 Solution
/**
* Custom useTimeout Hook
* Wraps setTimeout with proper cleanup and control methods
* Uses useRef to store timeout ID and latest callback
* @param {Function} callback - Function to execute after delay
* @param {number} delay - Delay in milliseconds (null/undefined to not set timer)
* @returns {{ reset: Function, cancel: Function }} - Control methods
*/
function useTimeout(callback, delay) {
const timeoutRef = useRef(null);
const callbackRef = useRef(callback);
// Always keep the latest callback in ref (avoid stale closures)
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Setup / cleanup timeout
useEffect(() => {
// If delay is null/undefined → don't set timer
if (delay == null) {
return;
}
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout using latest callback
timeoutRef.current = setTimeout(() => {
callbackRef.current();
timeoutRef.current = null; // Clean up after execution
}, delay);
// Cleanup on unmount or when delay/callback changes
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [delay]); // Re-run when delay changes
const reset = () => {
// Clear existing and set new one with current delay
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (delay != null) {
timeoutRef.current = setTimeout(() => {
callbackRef.current();
timeoutRef.current = null;
}, delay);
}
};
const cancel = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
return { reset, cancel };
}
// ────────────────────────────────────────────────
// Example usage:
function NotificationDemo() {
const [show, setShow] = useState(false);
const { reset, cancel } = useTimeout(() => {
setShow(false);
}, 3000);
const showNotification = () => {
setShow(true);
reset(); // (re)start the 3-second timer
};
return (
<div style={{ padding: '20px' }}>
<button
onClick={showNotification}
style={{ padding: '10px 20px', fontSize: '16px' }}
>
Show Notification
</button>
{show && (
<div
style={{
marginTop: '16px',
padding: '16px',
backgroundColor: '#d4edda',
border: '1px solid #c3e6cb',
borderRadius: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<p style={{ margin: 0 }}>
This notification will disappear in 3 seconds
</p>
<button
onClick={() => {
cancel();
// Optional: keep it visible longer or forever
}}
style={{
padding: '6px 12px',
backgroundColor: '#fff',
border: '1px solid #28a745',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Keep it
</button>
</div>
)}
</div>
);
}
/*
Expected behavior:
• Click "Show Notification" → message appears + 3s countdown starts
• After 3 seconds → message disappears automatically
• Click "Show Notification" again before 3s → timer resets → another full 3s
• Click "Keep it" → timer cancelled → message stays visible
• Component unmount → timeout cleaned up (no memory leak)
*/Nâng cao (60 phút)
Exercise: Request Deduplication
/**
* 🎯 Mục tiêu: Prevent duplicate API requests
*
* Scenario:
* User clicks "Load Data" nhiều lần nhanh.
* Bạn chỉ muốn gọi API 1 lần, các request sau dùng lại kết quả.
*
* Requirements:
* 1. Track in-flight requests bằng ref
* 2. If request pending → return existing promise
* 3. If request done → return cached result (for 5s)
* 4. After 5s → allow new request
*
* Bonus:
* - Support multiple URLs (cache by URL)
* - Request cancellation
* - Error handling with retry
*/
function useDeduplicatedFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// TODO: Implement với useRef
// - Track pending request
// - Cache results
// - Deduplicate logic
const fetchData = async () => {
// TODO: Your implementation
};
return { data, loading, error, fetchData };
}💡 Solution
/**
* useDeduplicatedFetch - Hook with request deduplication & short-term caching
* Prevents duplicate in-flight requests for the same URL
* Caches successful result for 5 seconds
* Supports cancellation via AbortController
* @param {string} url - The API endpoint to fetch
* @returns {{
* data: any,
* loading: boolean,
* error: string | null,
* fetchData: () => Promise<any>
* }}
*/
function useDeduplicatedFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Refs for deduplication & caching
const pendingPromiseRef = useRef(null); // current in-flight promise
const abortControllerRef = useRef(null); // for cancellation
const cacheRef = useRef({
data: null,
timestamp: 0,
url: null,
});
const CACHE_DURATION = 5000; // 5 seconds
const fetchData = useCallback(async () => {
// 1. Check cache first (fast path)
const now = Date.now();
if (
cacheRef.current.url === url &&
cacheRef.current.data !== null &&
now - cacheRef.current.timestamp < CACHE_DURATION
) {
setData(cacheRef.current.data);
setError(null);
setLoading(false);
return cacheRef.current.data;
}
// 2. If there's already a pending request for this URL → reuse it
if (pendingPromiseRef.current) {
setLoading(true);
try {
const result = await pendingPromiseRef.current;
return result;
} catch (err) {
throw err;
} finally {
setLoading(false);
}
}
// 3. No cache & no pending request → start new fetch
setLoading(true);
setError(null);
const controller = new AbortController();
abortControllerRef.current = controller;
const signal = controller.signal;
const promise = (async () => {
try {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
// Update cache
cacheRef.current = {
data: result,
timestamp: Date.now(),
url,
};
setData(result);
setError(null);
return result;
} catch (err) {
if (err.name === 'AbortError') {
throw err; // let caller handle abort if needed
}
const message = err.message || 'Failed to fetch';
setError(message);
throw err;
} finally {
setLoading(false);
// Clean up refs
if (pendingPromiseRef.current === promise) {
pendingPromiseRef.current = null;
}
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
}
})();
// Store the promise for deduplication
pendingPromiseRef.current = promise;
return promise;
}, [url]);
// Cleanup on unmount or url change
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
pendingPromiseRef.current = null;
};
}, [url]);
return { data, loading, error, fetchData };
}
// ────────────────────────────────────────────────
// Example usage component
function DeduplicationDemo() {
const { data, loading, error, fetchData } = useDeduplicatedFetch(
'https://jsonplaceholder.typicode.com/posts/1',
);
const handleMultipleClicks = () => {
// Simulate user clicking "Load" button 5 times quickly
for (let i = 0; i < 5; i++) {
setTimeout(() => {
fetchData().catch(() => {}); // ignore errors for demo
}, i * 80); // slightly staggered
}
};
return (
<div style={{ padding: '24px', maxWidth: '700px', margin: '0 auto' }}>
<h2>Request Deduplication Demo</h2>
<button
onClick={handleMultipleClicks}
disabled={loading}
style={{
padding: '12px 24px',
fontSize: '16px',
marginBottom: '20px',
backgroundColor: loading ? '#6c757d' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: loading ? 'not-allowed' : 'pointer',
}}
>
{loading ? 'Loading...' : 'Load Data (click many times)'}
</button>
{loading && (
<div
style={{
padding: '12px',
backgroundColor: '#e3f2fd',
borderRadius: '6px',
marginBottom: '16px',
}}
>
Loading (only one real request sent)...
</div>
)}
{error && (
<div
style={{
padding: '16px',
backgroundColor: '#f8d7da',
color: '#721c24',
borderRadius: '6px',
marginBottom: '16px',
}}
>
Error: {error}
</div>
)}
{data && (
<div
style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
border: '1px solid #dee2e6',
}}
>
<h3>Data (fetched once):</h3>
<pre style={{ fontSize: '14px', overflow: 'auto' }}>
{JSON.stringify(data, null, 2)}
</pre>
<p style={{ color: '#666', fontSize: '14px', marginTop: '16px' }}>
Subsequent clicks within 5 seconds will use cached result (no
network)
</p>
</div>
)}
</div>
);
}
/*
Expected behavior:
• Click "Load Data" once → real network request → data shown
• Click many times quickly (within ~100ms) → only ONE real request sent
→ all calls receive the same promise → same result
• After 5+ seconds → cache expires → next click triggers new request
• Rapid clicks after first success → instantly return cached data
• Component unmount / url change → pending request aborted
• No race conditions between multiple overlapping calls
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
- React Docs - useRef:https://react.dev/reference/react/useRef
- React Docs - Referencing Values with Refs:https://react.dev/learn/referencing-values-with-refs
Đọc thêm
When to use Ref vs State:https://kentcdodds.com/blog/usememo-and-usecallback
Avoiding Memory Leaks:https://felixgerschau.com/react-hooks-memory-leaks/
Understanding Refs:https://daveceddia.com/useref-hook/
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (cần biết từ trước)
- Ngày 11-12: useState fundamentals
- Ngày 16-20: useEffect và cleanup
- Ngày 19-20: Data fetching patterns
Hướng tới (sẽ dùng ở)
- Ngày 22: useRef cho DOM manipulation
- Ngày 23: useLayoutEffect với refs
- Ngày 24: Custom hooks với useRef
- Ngày 25: Project - Real-time Dashboard (combine tất cả)
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Performance Optimization
// ✅ GOOD: Dùng ref tránh unnecessary re-renders
function ExpensiveComponent({ data }) {
const previousDataRef = useRef(data);
const hasChanged = !shallowEqual(data, previousDataRef.current);
useEffect(() => {
if (hasChanged) {
// Heavy computation
previousDataRef.current = data;
}
}, [data, hasChanged]);
return (/* ... */);
}2. Debugging Tips
// ✅ GOOD: Add debug info với useRef
function DebuggedComponent() {
const renderCount = useRef(0);
const prevPropsRef = useRef();
useEffect(() => {
renderCount.current += 1;
if (import.meta.env.DEV) {
console.log('Render #', renderCount.current);
console.log('Prev props:', prevPropsRef.current);
console.log('Current props:', props);
}
prevPropsRef.current = props;
});
return (/* ... */);
}3. Resource Management
// ✅ GOOD: Centralized resource cleanup
function useResourceManager() {
const resourcesRef = useRef({
timers: [],
listeners: [],
requests: [],
});
const addTimer = (id) => {
resourcesRef.current.timers.push(id);
};
const addListener = (element, event, handler) => {
element.addEventListener(event, handler);
resourcesRef.current.listeners.push({ element, event, handler });
};
useEffect(() => {
return () => {
// Cleanup all resources
resourcesRef.current.timers.forEach(clearInterval);
resourcesRef.current.listeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
resourcesRef.current.requests.forEach((req) => req.abort());
};
}, []);
return { addTimer, addListener };
}Câu Hỏi Phỏng Vấn
Junior Level:
Q1: "useRef và useState khác nhau như thế nào?"
Expected answer:
- useState trigger re-render khi update, useRef không
- useState cho UI data, useRef cho non-UI tracking
- useState async update, useRef sync update
- Cả hai đều persist across renders
Q2: "Làm sao store timer ID trong React?"
Expected answer:
- Dùng useRef để store
- Ví dụ:
const timerRef = useRef(null) - Update:
timerRef.current = setInterval(...) - Cleanup:
clearInterval(timerRef.current)
Mid Level:
Q3: "Giải thích use case của useRef ngoài DOM manipulation."
Expected answer:
- Previous value tracking
- Timer/interval IDs
- Flags (isMounted, isPaused)
- Mutable instance variables
- Avoiding stale closures
- Request cancellation (AbortController)
Q4: "Có thể dùng useRef thay useState để optimize performance không?"
Expected answer:
- Không thể replace hoàn toàn
- useRef phù hợp cho non-UI data
- Nếu data hiển thị lên UI → phải dùng useState
- useRef tránh unnecessary re-renders cho internal tracking
- Trade-off: Lose automatic UI sync
Senior Level:
Q5: "Design một system để track và cleanup tất cả subscriptions trong một component."
Expected answer:
function useSubscriptionManager() {
const subscriptionsRef = useRef([]);
const subscribe = (cleanup) => {
subscriptionsRef.current.push(cleanup);
return () => {
const index = subscriptionsRef.current.indexOf(cleanup);
if (index > -1) {
subscriptionsRef.current.splice(index, 1);
}
};
};
useEffect(() => {
return () => {
subscriptionsRef.current.forEach((cleanup) => cleanup());
};
}, []);
return subscribe;
}Q6: "Explain memory leak patterns với useRef và cách prevent."
Expected answer:
- Timers không cleared
- Event listeners không removed
- Refs trỏ đến large objects
- Solutions:
- useEffect cleanup
- WeakRef cho circular references
- Null out refs sau khi dùng
- Resource tracking systems
War Stories
Story 1: The Infinite Loop Mystery
// ❌ BUG thực tế trong production:
function ChatRoom() {
const [messages, setMessages] = useState([]);
const wsRef = useRef(null);
useEffect(() => {
wsRef.current = new WebSocket(url);
wsRef.current.onmessage = (e) => {
setMessages([...messages, e.data]); // ⚠️ STALE CLOSURE!
};
}, []); // Missing dependency!
return (/* ... */);
}
// ✅ FIX:
function ChatRoom() {
const [messages, setMessages] = useState([]);
const wsRef = useRef(null);
useEffect(() => {
wsRef.current = new WebSocket(url);
wsRef.current.onmessage = (e) => {
setMessages(prev => [...prev, e.data]); // ✅ Functional update
};
return () => wsRef.current?.close();
}, []); // Safe now
return (/* ... */);
}Lesson learned:
- Luôn dùng functional updates trong callbacks lâu dài
- Refs giúp tránh stale closures
- useEffect dependencies phải chính xác
Story 2: The Memory Leak That Cost $1000
Real story: Dashboard component không cleanup intervals → memory tăng dần → server crash → AWS bill spike.
// ❌ Production bug:
function Dashboard() {
useEffect(() => {
const interval = setInterval(fetchData, 5000);
// ⚠️ No cleanup!
}, []);
}
// ✅ Fix:
function Dashboard() {
const intervalRef = useRef(null);
useEffect(() => {
intervalRef.current = setInterval(fetchData, 5000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
}Lesson learned:
- ALWAYS cleanup resources
- Monitor memory usage in production
- Test component unmount scenarios
🎯 PREVIEW NGÀY MAI
Ngày 22: useRef - DOM Manipulation 🎨
Ngày mai chúng ta sẽ học use case thứ 2 của useRef: accessing DOM nodes.
Bạn sẽ học:
- Ref forwarding với DOM elements
- Focus management
- Scroll control
- Measuring DOM nodes
- Third-party library integration
- When to use refs vs state for DOM
Chuẩn bị mental model:
useRef = {
Use Case 1: Mutable values (hôm nay) ✅
Use Case 2: DOM references (ngày mai) 🎯
}See you tomorrow! 🚀
✅ CHECKLIST HOÀN THÀNH
Trước khi kết thúc ngày học, check:
- [ ] Hiểu sâu useRef vs useState
- [ ] Làm đủ 5 exercises
- [ ] Đọc React docs về useRef
- [ ] Làm bài tập về nhà
- [ ] Review debug lab
- [ ] Chuẩn bị cho ngày mai
🎉 Congratulations! Bạn đã hoàn thành Ngày 21!