📅 NGÀY 17: useEffect - Dependencies Deep Dive
📍 Phase 2, Tuần 4, Ngày 17 của 45
⏱️ Thời lượng: 3-4 giờ
🎯 Mục tiêu học tập (5 phút)
- [ ] Hiểu và sử dụng thành thạo Dependencies Array trong useEffect
- [ ] Phân biệt được 3 patterns: no deps, empty deps
[], specific deps[a, b] - [ ] Giải quyết được Stale Closure problem trong effects
- [ ] Áp dụng được ESLint exhaustive-deps rule để tránh bugs
- [ ] Tối ưu dependencies với objects và arrays
🤔 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: useEffect không có dependencies array chạy khi nào?
- Đáp án: SAU MỖI render (đã học Ngày 16)
Câu 2: Nếu bạn muốn effect chỉ chạy 1 LẦN khi component mount, làm thế nào?
- Đáp án: Chưa biết! (Hôm nay sẽ học: empty deps
[])
- Đáp án: Chưa biết! (Hôm nay sẽ học: empty deps
Câu 3: Nếu bạn muốn effect chỉ chạy khi
countthay đổi, không phải khinamethay đổi, làm sao?- Đáp án: Cũng chưa biết! (Hôm nay sẽ học:
[count])
- Đáp án: Cũng chưa biết! (Hôm nay sẽ học:
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Nhớ lại ví dụ từ Ngày 16:
function DocumentTitleDemo() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Guest");
// ❌ PROBLEM: Effect chạy cho CẢ count VÀ name
useEffect(() => {
document.title = `Count: ${count}`;
console.log("Effect ran");
}); // No dependencies → Runs EVERY render
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
);
}
// BEHAVIOR:
// Type "A" → name changes → Re-render → Effect runs (không cần thiết!)
// Click +1 → count changes → Re-render → Effect runs (cần thiết!)Vấn đề:
- Effect chỉ quan tâm đến
count, KHÔNG quan tâmname - Nhưng effect vẫn chạy khi
namethay đổi - Lãng phí performance, logic không rõ ràng
❓ Làm sao để effect CHỈ chạy khi count thay đổi?
1.2 Giải Pháp: Dependencies Array
Dependencies Array là tham số thứ 2 của useEffect, cho phép bạn kiểm soát KHI NÀO effect chạy.
Cú pháp:
useEffect(
() => {
// Effect logic
},
[dependencies], // ← Dependencies Array
);3 Patterns Cơ Bản:
// PATTERN 1: No Dependencies
useEffect(() => {
console.log("Runs after EVERY render");
});
// PATTERN 2: Empty Dependencies []
useEffect(() => {
console.log("Runs ONCE after mount");
}, []); // ← Empty array
// PATTERN 3: Specific Dependencies [a, b]
useEffect(() => {
console.log("Runs when a OR b changes");
}, [a, b]); // ← Specific values1.3 Mental Model: Dependencies như Subscription
Hãy nghĩ về Dependencies như "Subscription List" - danh sách những giá trị mà effect "đăng ký" để theo dõi:
┌─────────────────────────────────────────────────────────────┐
│ DEPENDENCIES MENTAL MODEL │
└─────────────────────────────────────────────────────────────┘
useEffect(() => {
// Do something with `count`
}, [count]);
↓ Hiểu như:
"React ơi, hãy chạy effect này mỗi khi `count` thay đổi.
Nếu chỉ `name` thay đổi mà `count` không đổi → ĐỪNG chạy effect!"
═══════════════════════════════════════════════════════════════
COMPARISON TABLE:
┌──────────────────┬────────────────┬─────────────────────────┐
│ Dependencies │ Effect Runs │ Use Case │
├──────────────────┼────────────────┼─────────────────────────┤
│ (no array) │ Every render │ Log all renders │
│ [] │ Once (mount) │ Initial data fetch │
│ [a] │ When a changes │ Sync with specific val │
│ [a, b, c] │ When any changes│ Sync with multiple vals │
└──────────────────┴────────────────┴─────────────────────────┘Analogy dễ hiểu:
Dependencies Array như Netflix Watch List:
- No deps: Watch MỌI show (every render)
- Empty deps
[]: Watch chỉ 1 show duy nhất, xem xong thôi (mount)[showA, showB]: Chỉ watch ShowA và ShowB, nếu có episode mới thì xem (re-run when changed)
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm #1: "Empty deps [] = effect KHÔNG BAO GIỜ chạy"
function Wrong() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("I run!");
}, []); // Empty deps
// Effect VẪN CHẠY 1 lần sau mount!
// Không phải "không chạy"
}✅ Đúng: Empty deps [] = Effect chạy 1 LẦN sau mount, sau đó KHÔNG BAO GIỜ chạy lại.
❌ Hiểu lầm #2: "Dependencies là optional, có thể bỏ qua"
function Wrong() {
const [count, setCount] = useState(0);
// ❌ BAD: Dùng count trong effect nhưng không khai báo trong deps
useEffect(() => {
document.title = `Count: ${count}`;
}, []); // Missing dependency!
// ESLint warning: "React Hook useEffect has a missing dependency: 'count'"
}✅ Đúng: Nếu effect dùng giá trị nào từ component scope, BẮT BUỘC phải khai báo trong deps.
❌ Hiểu lầm #3: "Deps so sánh bằng === là đủ"
function Wrong() {
const [user, setUser] = useState({ name: "John", age: 30 });
useEffect(() => {
console.log("User changed");
}, [user]); // Object reference
const updateAge = () => {
// ❌ Tạo object MỚI → Reference khác → Effect chạy!
setUser({ ...user, age: 31 });
};
// Effect chạy ngay cả khi chỉ thay đổi age!
}✅ Đúng: React so sánh dependencies bằng Object.is() (tương tự ===). Objects/Arrays luôn có reference mới → Effect chạy lại.
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Pattern Cơ Bản - 3 Dependencies Patterns ⭐
/**
* Demo: So sánh 3 patterns của dependencies
* Concepts: No deps, Empty deps [], Specific deps [value]
*/
import { useState, useEffect } from "react";
function DependenciesComparison() {
const [count, setCount] = useState(0);
const [name, setName] = useState("");
// Pattern 1: NO DEPENDENCIES
useEffect(() => {
console.log("1️⃣ No Deps - Runs after EVERY render");
});
// Pattern 2: EMPTY DEPENDENCIES []
useEffect(() => {
console.log("2️⃣ Empty Deps [] - Runs ONCE after mount");
}, []);
// Pattern 3: SPECIFIC DEPENDENCIES [count]
useEffect(() => {
console.log("3️⃣ Specific Deps [count] - Runs when count changes");
}, [count]);
return (
<div>
<h2>Dependencies Comparison</h2>
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
</div>
<div>
<input
placeholder="Type your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<h3>📋 Test Instructions:</h3>
<ol>
<li>Mở Console</li>
<li>Click "Increment Count" → Quan sát logs</li>
<li>Type vào input → Quan sát logs</li>
</ol>
<h3>🔍 Expected Behavior:</h3>
<table border="1" cellPadding="8">
<thead>
<tr>
<th>Action</th>
<th>No Deps</th>
<th>Empty []</th>
<th>Specific [count]</th>
</tr>
</thead>
<tbody>
<tr>
<td>Initial mount</td>
<td>✅ Runs</td>
<td>✅ Runs</td>
<td>✅ Runs</td>
</tr>
<tr>
<td>Click button (count++)</td>
<td>✅ Runs</td>
<td>❌ No</td>
<td>✅ Runs</td>
</tr>
<tr>
<td>Type in input (name change)</td>
<td>✅ Runs</td>
<td>❌ No</td>
<td>❌ No</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
export default DependenciesComparison;Quan sát Console Output:
// Initial mount:
1️⃣ No Deps - Runs after EVERY render
2️⃣ Empty Deps [] - Runs ONCE after mount
3️⃣ Specific Deps [count] - Runs when count changes
// Click button (count: 0 → 1):
1️⃣ No Deps - Runs after EVERY render
3️⃣ Specific Deps [count] - Runs when count changes
// Type "A" (name: "" → "A"):
1️⃣ No Deps - Runs after EVERY render
// ← Notice: Effect 2 và 3 KHÔNG chạy!Demo 2: Kịch Bản Thực Tế - Document Title Sync ⭐⭐
/**
* Demo: Update document title khi specific state thay đổi
* Use case: Browser tab title reflects app state
*/
import { useState, useEffect } from "react";
function DocumentTitleSync() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Guest");
const [page, setPage] = useState("Home");
// Effect 1: Sync title với count (CHỈ khi count thay đổi)
useEffect(() => {
document.title = `Count: ${count}`;
console.log("📄 Title updated with count:", count);
}, [count]); // ← Only re-run when count changes
// Effect 2: Log khi name thay đổi
useEffect(() => {
console.log("👤 Name changed to:", name);
}, [name]); // ← Only re-run when name changes
// Effect 3: Log khi page thay đổi
useEffect(() => {
console.log("📍 Page changed to:", page);
}, [page]); // ← Only re-run when page changes
return (
<div>
<h2>Document Title Sync</h2>
<div>
<h3>Count: {count}</h3>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
<div>
<h3>Name: {name}</h3>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div>
<h3>Current Page: {page}</h3>
<button onClick={() => setPage("Home")}>Home</button>
<button onClick={() => setPage("Profile")}>Profile</button>
<button onClick={() => setPage("Settings")}>Settings</button>
</div>
<div>
<h3>💡 Key Observations:</h3>
<ul>
<li>✅ document.title CHỈ update khi count thay đổi</li>
<li>✅ Typing name KHÔNG trigger title effect</li>
<li>✅ Changing page KHÔNG trigger title effect</li>
<li>✅ Mỗi effect độc lập, chỉ chạy khi deps của nó thay đổi</li>
</ul>
</div>
</div>
);
}
export default DocumentTitleSync;So sánh với Ngày 16:
| Ngày 16 (No Deps) | Ngày 17 (Specific Deps) |
|---|---|
| Effect chạy MỖI lần render | Effect chỉ chạy khi deps thay đổi |
| Type name → Effect runs | Type name → Effect KHÔNG chạy ✅ |
| Click count → Effect runs | Click count → Effect chạy ✅ |
| Không kiểm soát | Kiểm soát chính xác |
Demo 3: Edge Cases - Stale Closure Problem ⭐⭐⭐
/**
* Demo: Stale Closure - Bug phổ biến với dependencies
* Edge case: Values "cũ" trong effect
*/
import { useState, useEffect } from 'react';
function StaleClosureDemo() {
const [count, setCount] = useState(0);
// ❌ BUG: Stale Closure
useEffect(() => {
const id = setInterval(() => {
console.log('Count in interval:', count);
// ⚠️ PROBLEM: `count` ở đây LUÔN là giá trị lúc effect được tạo!
setCount(count + 1); // count luôn = 0!
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps → Effect chỉ run 1 lần
// BEHAVIOR:
// Interval được tạo 1 lần với count = 0
// setCount(0 + 1) → count = 1
// Nhưng interval KHÔNG được re-create → count trong closure vẫn = 0
// setCount(0 + 1) → count = 1 (lại!)
// → Count bị stuck ở 1!
return (
<div>
<h2>❌ Stale Closure Bug</h2>
<p>Count: {count}</p>
<p>⚠️ Count sẽ tăng lên 1, rồi STUCK!</p>
<h3>🐛 Why?</h3>
<pre>{`
useEffect(() => {
setInterval(() => {
setCount(count + 1); // count = 0 (closure!)
}, 1000);
}, []); // Effect chỉ chạy 1 lần
→ Interval capture `count = 0` từ lần render đầu
→ Không bao giờ update!
`}</pre>
</div>
);
}
// ✅ FIX #1: Functional Update
function FixedWithFunctionalUpdate() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ✅ SOLUTION: Dùng functional update
setCount(prevCount => {
console.log('Previous count:', prevCount);
return prevCount + 1; // Always use LATEST value
});
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps OK now!
return (
<div>
<h2>✅ Fixed with Functional Update</h2>
<p>Count: {count}</p>
<p>✅ Count tăng liên tục: 1, 2, 3, 4...</p>
<h3>💡 How it works:</h3>
<pre>{`
setCount(prevCount => prevCount + 1);
→ React đảm bảo prevCount LUÔN là giá trị mới nhất
→ Không phụ thuộc vào closure!
`}</pre>
</div>
);
}
// ✅ FIX #2: Add count to dependencies
function FixedWithDependencies() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('Count in interval:', count);
setCount(count + 1); // Now `count` is always fresh
}, 1000);
return () => clearInterval(id);
}, [count]); // ← Re-run effect when count changes
// BEHAVIOR:
// count = 0 → Effect runs → Create interval
// 1 second later → setCount(1) → count = 1
// count changed → Cleanup old interval → Effect runs again
// → Create NEW interval with count = 1
// → Works, but creates/destroys interval every second!
return (
<div>
<h2>✅ Fixed with Dependencies</h2>
<p>Count: {count}</p>
<p>✅ Count tăng, nhưng interval bị recreate mỗi giây</p>
<h3>⚠️ Trade-off:</h3>
<ul>
<li>✅ Pros: Logic rõ ràng, count luôn fresh</li>
<li>❌ Cons: Performance - interval recreated every second</li>
<li>💡 Fix #1 (Functional Update) tốt hơn cho use case này!</li>
</ul>
</div>
);
}
// 📊 Comparison Component
function StaleClosureComparison() {
const [demo, setDemo] = useState('buggy');
return (
<div>
<div>
<button onClick={() => setDemo('buggy')}>Show Bug</button>
<button onClick={() => setDemo('fix1')}>Fix #1 (Functional)</button>
<button onClick={() => setDemo('fix2')}>Fix #2 (Deps)</button>
</div>
<hr />
{demo === 'buggy' && <StaleClosureDemo />}
{demo === 'fix1' && <FixedWithFunctionalUpdate />}
{demo === 'fix2' && <FixedWithDependencies />}
</div>
);
}
export default StaleClosureComparison;🔥 QUAN TRỌNG - Stale Closure Summary:
PROBLEM: Effect với empty deps [] capture giá trị lúc mount
→ Values trong effect KHÔNG update khi state thay đổi
SOLUTIONS:
1. Functional Update: setCount(prev => prev + 1)
✅ Best cho state updates
2. Add to deps: useEffect(() => {...}, [count])
✅ Best khi cần dùng latest value
⚠️ Effect re-runs khi deps change
3. useRef (Ngày 21): Persist value without re-render
✅ Best cho mutable values🔨 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 dependencies array syntax
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useRef, useCallback, useMemo (chưa học)
*
* Requirements:
* 1. Tạo component với 2 states: firstName, lastName
* 2. Effect 1: Log fullName khi EITHER firstName HOẶC lastName thay đổi
* 3. Effect 2: Update document.title với fullName (same deps)
* 4. Effect 3: Log "Component mounted" CHỈ 1 lần
* 5. Inputs cho firstName và lastName
*
* 💡 Gợi ý:
* - Effect với multiple deps: [a, b] → Chạy khi a HOẶC b thay đổi
* - Empty deps [] → Chỉ chạy lần đầu
*/
// ❌ Cách SAI (Anti-pattern):
function WrongFullNameTracker() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
// ❌ SAI: No dependencies → Chạy MỌI render (lãng phí)
useEffect(() => {
const fullName = firstName + " " + lastName;
console.log("Full name:", fullName);
document.title = fullName;
}); // Missing dependencies!
return (
<div>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="First Name"
/>
<input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Last Name"
/>
</div>
);
}
// Tại sao sai?
// - Effect chạy MỌI render, kể cả khi names không đổi
// - Nếu có state khác (ví dụ: age), effect vẫn chạy khi age thay đổi
// - Không kiểm soát được timing
// 🎯 NHIỆM VỤ CỦA BẠN:
function FullNameTracker() {
// TODO: Khai báo states
// TODO: Effect 1 - Log fullName khi firstName hoặc lastName thay đổi
// TODO: Effect 2 - Update document.title
// TODO: Effect 3 - Log "Component mounted" 1 lần
return (
<div>
<h2>Full Name Tracker</h2>
{/* TODO: Inputs */}
<p>💡 Mở Console và test:</p>
<ul>
<li>Type firstName → Effect 1, 2 chạy</li>
<li>Type lastName → Effect 1, 2 chạy</li>
<li>Effect 3 chỉ chạy lúc mount</li>
</ul>
</div>
);
}
// ✅ Expected Console Output:
// Component mounted
// Full name changed:
// (Type "John")
// Full name changed: John
// (Type "Doe")
// Full name changed: John Doe💡 Solution
/**
* FullNameTracker - Level 1: Áp dụng Dependencies Array
*
* Yêu cầu:
* - 2 inputs: firstName và lastName
* - Effect 1: Log fullName khi firstName HOẶC lastName thay đổi
* - Effect 2: Update document.title với fullName (hoặc fallback)
* - Effect 3: Log "Component mounted" chỉ 1 lần khi mount
*/
import { useState, useEffect } from "react";
function FullNameTracker() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
// Effect 1: Log fullName khi bất kỳ tên nào thay đổi
useEffect(() => {
const fullName = `${firstName} ${lastName}`.trim();
console.log("✅ Full name changed:", fullName || "(empty)");
}, [firstName, lastName]);
// Effect 2: Đồng bộ document title
useEffect(() => {
const fullName = `${firstName} ${lastName}`.trim();
document.title = fullName || "Enter your name";
}, [firstName, lastName]);
// Effect 3: Chỉ chạy một lần khi component mount
useEffect(() => {
console.log("✅ Component mounted");
}, []); // empty deps → chỉ chạy sau lần render đầu tiên
return (
<div>
<h2>Full Name Tracker</h2>
<div style={{ marginBottom: "16px" }}>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="First Name"
style={{ marginRight: "8px", padding: "8px" }}
/>
<input
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Last Name"
style={{ padding: "8px" }}
/>
</div>
<p>💡 Mở Console và test:</p>
<ul>
<li>Type firstName → Effect 1 & 2 chạy</li>
<li>Type lastName → Effect 1 & 2 chạy</li>
<li>Effect 3 chỉ xuất hiện 1 lần lúc đầu</li>
</ul>
</div>
);
}
export default FullNameTracker;
// Tại sao tốt hơn?
// ✅ Effects chỉ chạy khi cần
// ✅ Logic rõ ràng: deps list chỉ ra effect phụ thuộc vào gì
// ✅ Performance tốt hơn
// ✅ Dễ debug: Biết chính xác khi nào effect chạyKết quả ví dụ trong console:
// Khi component vừa mount
✅ Component mounted
// Gõ "Lan" vào First Name
✅ Full name changed: Lan
// → document.title = "Lan"
// Gõ " Nguyễn" vào Last Name
✅ Full name changed: Lan Nguyễn
// → document.title = "Lan Nguyễn"
// Xóa hết cả hai ô input
✅ Full name changed: (empty)
// → document.title = "Enter your name"⭐⭐ Level 2: Nhận Biết Pattern (25 phút)
/**
* 🎯 Mục tiêu: Phát hiện và fix stale closure bugs
* ⏱️ Thời gian: 25 phút
*
* Scenario: Auto-save form với timer
* Yêu cầu: Form tự động save sau 3 giây không có thay đổi
*
* 🤔 PHÂN TÍCH:
*
* Approach A: setTimeout trong effect với empty deps []
* Pros:
* - Đơn giản, timer chỉ tạo 1 lần
* Cons:
* - ❌ STALE CLOSURE! formData trong setTimeout luôn là giá trị ban đầu
* - Không save được data mới
*
* Approach B: setTimeout trong effect với [formData] deps
* Pros:
* - ✅ formData luôn fresh
* - Timer recreated mỗi khi formData thay đổi → Reset countdown
* Cons:
* - Performance: Many timer creations/clearances
* - Phức tạp hơn
*
* Approach C: useRef + functional update (Preview Ngày 21)
* Pros:
* - Best performance
* - No stale closure
* Cons:
* - Cần useRef (chưa học!)
*
* 💭 BẠN CHỌN APPROACH NÀO VÀ TẠI SAO?
* Với kiến thức đến Ngày 17, chọn Approach B!
*/
// ❌ Approach A: Stale Closure Bug
function AutoSaveFormBuggy() {
const [formData, setFormData] = useState({ name: "", email: "" });
const [lastSaved, setLastSaved] = useState(null);
// ❌ BUG: formData trong setTimeout là stale!
useEffect(() => {
console.log("Effect ran, setting timeout...");
const timerId = setTimeout(() => {
console.log("💾 Saving:", formData); // formData = { name: '', email: '' }
// Luôn save empty object!
setLastSaved(new Date().toLocaleTimeString());
}, 3000);
return () => {
console.log("Cleanup: Clearing timeout");
clearTimeout(timerId);
};
}, []); // Empty deps → formData captured at mount!
const updateField = (field, value) => {
setFormData({ ...formData, [field]: value });
};
return (
<div>
<h2>❌ Buggy Auto-Save (Stale Closure)</h2>
<input
placeholder="Name"
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
/>
<input
placeholder="Email"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
/>
<p>Last Saved: {lastSaved || "Not saved yet"}</p>
<div>
<h3>🐛 Bug:</h3>
<p>3 giây sau mount, sẽ save EMPTY object, không phải data hiện tại!</p>
</div>
</div>
);
}
// ✅ Approach B: Fixed với Dependencies
function AutoSaveFormFixed() {
const [formData, setFormData] = useState({ name: "", email: "" });
const [lastSaved, setLastSaved] = useState(null);
useEffect(() => {
console.log("Effect ran with formData:", formData);
// Timer reset MỖI khi formData thay đổi
const timerId = setTimeout(() => {
console.log("💾 Saving:", formData); // ✅ Fresh data!
// TODO: API call here
setLastSaved(new Date().toLocaleTimeString());
}, 3000);
// Cleanup: Clear timer khi formData thay đổi (reset countdown)
return () => {
console.log("Cleanup: Clearing timeout (formData changed)");
clearTimeout(timerId);
};
}, [formData]); // ← Re-run khi formData thay đổi
const updateField = (field, value) => {
setFormData({ ...formData, [field]: value });
};
return (
<div>
<h2>✅ Fixed Auto-Save</h2>
<input
placeholder="Name"
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
/>
<input
placeholder="Email"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
/>
<p>Last Saved: {lastSaved || "Not saved yet"}</p>
<div>
<h3>✅ How it works:</h3>
<ol>
<li>Type "A" → formData thay đổi</li>
<li>Effect re-runs → Clear old timer, tạo timer mới</li>
<li>Type "B" trong 3s → Clear timer, reset countdown</li>
<li>Ngừng typing 3s → Timer triggers → Save!</li>
</ol>
<p>💡 Đây là pattern "debounce" - sẽ học kỹ hơn sau!</p>
</div>
</div>
);
}
// 🎯 NHIỆM VỤ CỦA BẠN:
// 1. Implement CẢ HAI versions trên
// 2. Test và quan sát Console logs
// 3. Giải thích TẠI SAO Approach A bị stale closure
// 4. Viết comment phân tích trade-offs của Approach B
// 5. Bonus: Thử add thêm field (phone) và verify auto-save vẫn hoạt động
// 💡 HINTS:
// - Stale closure xảy ra khi: Effect capture value lúc mount, không update
// - Cleanup function chạy TRƯỚC khi effect chạy lại
// - setTimeout + cleanup = Debounce pattern
// 📝 EXPECTED ANALYSIS:
// - Approach A: Nhanh, đơn giản, NHƯNG sai logic
// - Approach B: Đúng logic, performance OK cho form nhỏ
// - Production: Cần optimize hơn (useRef, custom hook)💡 Solution
/**
* AutoSaveFormFixed - Level 2: Nhận biết và fix Stale Closure pattern
*
* Yêu cầu:
* - Form có 2 fields: name và email (có thể mở rộng thêm phone)
* - Tự động save sau 3 giây không có thay đổi (debounce pattern)
* - Sử dụng useEffect + [formData] để reset timer mỗi khi dữ liệu thay đổi
* - Cleanup timeout khi formData thay đổi hoặc component unmount
* - Hiển thị thời gian last saved
*/
import { useState, useEffect } from "react";
function AutoSaveFormFixed() {
const [formData, setFormData] = useState({
name: "",
email: "",
// Bonus: thêm field phone để test
phone: "",
});
const [lastSaved, setLastSaved] = useState(null);
useEffect(() => {
console.log("Effect ran → Setting new auto-save timer with:", formData);
// Tạo timer mới mỗi khi formData thay đổi
const timerId = setTimeout(() => {
// Giả lập API call / save logic
console.log("💾 Auto-saving data:", formData);
// Cập nhật thời gian đã lưu
const now = new Date().toLocaleTimeString();
setLastSaved(now);
console.log(`Saved successfully at ${now}`);
}, 3000);
// Cleanup: hủy timer cũ khi formData thay đổi hoặc unmount
return () => {
console.log("Cleanup: Clearing previous timeout");
clearTimeout(timerId);
};
}, [formData]); // Dependency chính là toàn bộ formData object
const updateField = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
return (
<div>
<h2>✅ Fixed Auto-Save Form (Debounce Pattern)</h2>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
maxWidth: "400px",
}}
>
<div>
<label>Name:</label>
<input
placeholder="Your name"
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
style={{ width: "100%", padding: "8px", marginTop: "4px" }}
/>
</div>
<div>
<label>Email:</label>
<input
placeholder="your@email.com"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
style={{ width: "100%", padding: "8px", marginTop: "4px" }}
/>
</div>
<div>
<label>Phone:</label>
<input
placeholder="0123 456 789"
value={formData.phone}
onChange={(e) => updateField("phone", e.target.value)}
style={{ width: "100%", padding: "8px", marginTop: "4px" }}
/>
</div>
</div>
<div style={{ marginTop: "20px" }}>
<p>Last Saved: {lastSaved ? lastSaved : "Not saved yet"}</p>
</div>
<div style={{ marginTop: "24px", fontSize: "14px", color: "#555" }}>
<h4>How it works:</h4>
<ul style={{ margin: 0, paddingLeft: "20px" }}>
<li>Mỗi lần gõ → timer cũ bị hủy, timer mới 3s được tạo</li>
<li>Ngừng gõ 3 giây → dữ liệu được "save"</li>
<li>
Approach này tránh stale closure bằng cách đưa formData vào deps
</li>
</ul>
</div>
</div>
);
}
export default AutoSaveFormFixed;Kết quả ví dụ trong console (khi tương tác):
// Ban đầu
Effect ran → Setting new auto-save timer with: {name: "", email: "", phone: ""}
// Gõ "Nguyễn" vào name → sau ~0.2s
Cleanup: Clearing previous timeout
Effect ran → Setting new auto-save timer with: {name: "Nguyễn", email: "", phone: ""}
// Tiếp tục gõ " Văn" → timer lại reset
Cleanup: Clearing previous timeout
Effect ran → Setting new auto-save timer with: {name: "Nguyễn Văn", email: "", phone: ""}
// Ngừng gõ 3 giây
💾 Auto-saving data: {name: "Nguyễn Văn", email: "", phone: ""}
Saved successfully at 14:35:22
// Gõ nhanh "A" rồi xóa → timer reset nhiều lần nhưng chỉ save 1 lần sau 3s ngừng gõ⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)
/**
* 🎯 Mục tiêu: Search với Debounce (Real-world pattern)
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn search products, và kết quả
* chỉ xuất hiện SAU KHI tôi ngừng typing 500ms"
*
* ✅ Acceptance Criteria:
* - [ ] Input field cho search query
* - [ ] Chỉ search khi user ngừng typing 500ms
* - [ ] Display "Searching..." khi đang search
* - [ ] Display results (giả lập với array filter)
* - [ ] Clear results khi query empty
* - [ ] Show số lượng results found
*
* 🎨 Technical Constraints:
* - Dùng useEffect với dependencies
* - setTimeout cho debounce
* - Cleanup để clear timeout
* - KHÔNG dùng useRef, custom hooks (chưa học)
*
* 🚨 Edge Cases cần handle:
* - Query empty → Không search
* - Query quá ngắn (<2 chars) → Không search
* - Rapid typing → Clear old timeout, chỉ search lần cuối
* - Component unmount trong khi đang search → Cleanup
*
* 📝 Implementation Checklist:
* - [ ] State cho searchQuery
* - [ ] State cho debouncedQuery (query sau debounce)
* - [ ] State cho isSearching
* - [ ] State cho results
* - [ ] Effect để debounce: searchQuery → debouncedQuery
* - [ ] Effect để search: debouncedQuery → results
* - [ ] Cleanup timeouts properly
*/
// Mock data
const PRODUCTS = [
{ id: 1, name: 'iPhone 15 Pro', category: 'Phone' },
{ id: 2, name: 'iPhone 15', category: 'Phone' },
{ id: 3, name: 'iPad Pro', category: 'Tablet' },
{ id: 4, name: 'iPad Air', category: 'Tablet' },
{ id: 5, name: 'MacBook Pro', category: 'Laptop' },
{ id: 6, name: 'MacBook Air', category: 'Laptop' },
{ id: 7, name: 'AirPods Pro', category: 'Audio' },
{ id: 8, name: 'AirPods Max', category: 'Audio' },
];
// 🎯 STARTER CODE:
function ProductSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState([]);
// TODO: Effect 1 - Debounce searchQuery → debouncedQuery
// Logic:
// 1. Set isSearching = true
// 2. setTimeout 500ms, sau đó setDebouncedQuery(searchQuery)
// 3. Cleanup: clearTimeout nếu searchQuery thay đổi
// 4. Dependencies: [searchQuery]
useEffect(() => {
// TODO: Implement debounce logic
// setIsSearching(true);
// const timerId = setTimeout(() => {
// setDebouncedQuery(searchQuery);
// setIsSearching(false);
// }, 500);
// return () => clearTimeout(timerId);
}, [searchQuery]);
// TODO: Effect 2 - Search khi debouncedQuery thay đổi
// Logic:
// 1. Nếu debouncedQuery empty hoặc < 2 chars → Clear results
// 2. Ngược lại, filter PRODUCTS
// 3. setResults(filtered)
// 4. Dependencies: [debouncedQuery]
useEffect(() => {
// TODO: Implement search logic
// if (!debouncedQuery || debouncedQuery.length < 2) {
// setResults([]);
// return;
// }
// const filtered = PRODUCTS.filter(product =>
// product.name.toLowerCase().includes(debouncedQuery.toLowerCase())
// );
// setResults(filtered);
}, [debouncedQuery]);
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
};
return (
<div>
<h2>Product Search with Debounce</h2>
{/* Search Input */}
<div>
<input
type="text"
placeholder="Search products... (min 2 chars)"
value={searchQuery}
onChange={handleSearchChange}
style={{
padding: '10px',
fontSize: '16px',
width: '300px'
}}
/>
</div>
{/* Status */}
<div style={{ marginTop: '10px' }}>
{isSearching && <p>🔍 Searching...</p>}
{!isSearching && debouncedQuery && (
<p>Found {results.length} result(s) for "{debouncedQuery}"</p>
)}
</div>
{/* Results */}
<div style={{ marginTop: '20px' }}>
{results.length > 0 ? (
<ul>
{results.map(product => (
<li key={product.id}>
<strong>{product.name}</strong> - {product.category}
</li>
))}
</ul>
) : (
debouncedQuery && debouncedQuery.length >= 2 && (
<p>No results found.</p>
)
)}
</div>
{/* Debug Info */}
<div style={{ marginTop: '30px', padding: '10px', background: '#f0f0f0' }}>
<h3>🔍 Debug Info:</h3>
<p>Search Query: "{searchQuery}"</p>
<p>Debounced Query: "{debouncedQuery}"</p>
<p>Is Searching: {isSearching ? 'Yes' : 'No'}</p>
<p>Results Count: {results.length}</p>
</div>
{/* Instructions */}
<div style={{ marginTop: '20px' }}>
<h3>📋 Test Scenarios:</h3>
<ol>
<li>Type "iphone" nhanh → Chỉ search 1 lần sau 500ms</li>
<li>Type "ip" → Pause → "phone" → 2 searches</li>
<li>Type "xyz" → No results</li>
<li>Clear input → Results cleared</li>
<li>Type "a" → No search (< 2 chars)</li>
</ol>
</div>
</div>
);
}
export default ProductSearch;
// 💡 EXTENSION CHALLENGES:
// 1. Add loading spinner (isSearching state)
// 2. Highlight matching text trong results
// 3. Show "recent searches" history
// 4. Add category filter
// 5. Implement với API call (fake delay với setTimeout)💡 Solution
/**
* ProductSearch - Level 3: Real-world Debounce Search with useEffect
*
* Yêu cầu chính:
* - Input tìm kiếm sản phẩm
* - Debounce 500ms: chỉ search khi người dùng ngừng gõ 500ms
* - Hiển thị "Searching..." trong lúc debounce
* - Chỉ filter khi query ≥ 2 ký tự
* - Hiển thị số lượng kết quả và danh sách sản phẩm
* - Xóa kết quả khi query rỗng hoặc < 2 ký tự
*/
import { useState, useEffect } from "react";
// Dữ liệu giả lập
const PRODUCTS = [
{ id: 1, name: "iPhone 15 Pro", category: "Phone" },
{ id: 2, name: "iPhone 15", category: "Phone" },
{ id: 3, name: "iPad Pro", category: "Tablet" },
{ id: 4, name: "iPad Air", category: "Tablet" },
{ id: 5, name: "MacBook Pro", category: "Laptop" },
{ id: 6, name: "MacBook Air", category: "Laptop" },
{ id: 7, name: "AirPods Pro", category: "Audio" },
{ id: 8, name: "AirPods Max", category: "Audio" },
];
function ProductSearch() {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState([]);
// Effect 1: Debounce logic - chuyển searchQuery → debouncedQuery sau 500ms
useEffect(() => {
setIsSearching(true);
const timerId = setTimeout(() => {
setDebouncedQuery(searchQuery);
setIsSearching(false);
}, 500);
// Cleanup: hủy timer nếu searchQuery thay đổi trước 500ms
return () => {
clearTimeout(timerId);
setIsSearching(false);
};
}, [searchQuery]);
// Effect 2: Thực hiện tìm kiếm khi debouncedQuery thay đổi
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setResults([]);
return;
}
// Filter sản phẩm (case-insensitive)
const filtered = PRODUCTS.filter((product) =>
product.name.toLowerCase().includes(debouncedQuery.toLowerCase()),
);
setResults(filtered);
}, [debouncedQuery]);
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
};
return (
<div>
<h2>Product Search with Debounce (500ms)</h2>
<input
type="text"
placeholder="Search products... (min 2 characters)"
value={searchQuery}
onChange={handleSearchChange}
style={{
width: "100%",
maxWidth: "400px",
padding: "12px",
fontSize: "16px",
borderRadius: "6px",
border: "1px solid #ccc",
marginBottom: "16px",
}}
/>
<div style={{ minHeight: "24px", marginBottom: "16px" }}>
{isSearching && <p style={{ color: "#666" }}>🔍 Searching...</p>}
{!isSearching && debouncedQuery && (
<p>
Found <strong>{results.length}</strong> result(s) for "
<strong>{debouncedQuery}</strong>"
</p>
)}
</div>
{results.length > 0 ? (
<ul style={{ listStyle: "none", padding: 0 }}>
{results.map((product) => (
<li
key={product.id}
style={{
padding: "12px",
borderBottom: "1px solid #eee",
background: "#f9f9f9",
marginBottom: "8px",
borderRadius: "4px",
}}
>
<strong>{product.name}</strong>
<span style={{ color: "#666", marginLeft: "12px" }}>
({product.category})
</span>
</li>
))}
</ul>
) : (
debouncedQuery &&
debouncedQuery.length >= 2 &&
!isSearching && <p style={{ color: "#888" }}>No products found.</p>
)}
{/* Debug info (có thể xóa khi dùng production) */}
<div
style={{
marginTop: "32px",
padding: "16px",
background: "#f0f0f0",
borderRadius: "8px",
fontSize: "14px",
}}
>
<strong>Debug:</strong>
<br />
Real-time query: "{searchQuery}"<br />
Debounced query: "{debouncedQuery}"<br />
Is searching: {isSearching ? "Yes" : "No"}
<br />
Results: {results.length}
</div>
</div>
);
}
export default ProductSearch;Kết quả ví dụ khi tương tác:
// Gõ nhanh "iph" → "iphone" trong vòng 0.4 giây
// → Chỉ 1 lần debounce trigger sau 500ms ngừng gõ
// Console không log liên tục, chỉ chạy effect khi thực sự cần
// Kết quả sau khi ngừng gõ 500ms với "iphone":
Found 2 result(s) for "iphone"
• iPhone 15 Pro (Phone)
• iPhone 15 (Phone)
// Gõ "ip" (2 ký tự) rồi ngừng → vẫn search
// Gõ "i" (1 ký tự) → kết quả bị xóa ngay khi debounced
// Xóa hết input → results = [], không hiển thị thông báo tìm kiếm⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)
/**
* 🎯 Mục tiêu: Multi-Step Form với Validation
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Context:
* Xây dựng multi-step registration form (3 steps):
* - Step 1: Personal Info (name, email, phone)
* - Step 2: Address (street, city, zip)
* - Step 3: Preferences (newsletter, notifications)
*
* Validation requirements:
* - Validate từng field khi user blur (rời khỏi field)
* - Validate toàn bộ step trước khi cho next
* - Show errors immediately
* - Auto-save draft mỗi 5 giây
*
* Có 3 approaches khác nhau:
*
* APPROACH 1: Separate effect cho mỗi field validation
* Pros:
* - Rõ ràng, dễ hiểu
* - Dễ debug từng field
* Cons:
* - Quá nhiều effects (9+ effects!)
* - Performance không tốt
* - Code dài, khó maintain
*
* APPROACH 2: 1 effect validate toàn bộ form
* Pros:
* - Gọn hơn, ít effects hơn
* - Centralized validation logic
* Cons:
* - Effect chạy cho MỌI field change (unnecessary)
* - Khó control validation timing (blur vs change)
*
* APPROACH 3: Hybrid - Effects cho specific concerns
* Pros:
* - Balance giữa clarity và performance
* - Effect 1: Validate current step khi chuyển step
* - Effect 2: Auto-save draft
* - Event handlers: Validate on blur
* Cons:
* - Phức tạp hơn
* - Cần hiểu rõ khi nào dùng effect vs event handler
*
* 💭 NHIỆM VỤ PHASE 1:
* 1. Analyze requirements
* 2. Chọn approach (Recommend: Approach 3)
* 3. Viết ADR
*
* ADR Template:
* ---
* # ADR: Multi-Step Form Validation Strategy
*
* ## Context
* [Mô tả: Form 3 steps, validate trên blur, auto-save]
*
* ## Decision
* [Approach 3: Hybrid]
*
* ## Rationale
* [Tại sao:
* - Event handlers cho field-level validation (blur)
* - useEffect cho step-level validation (step change)
* - useEffect cho auto-save (timer-based)
* - Separation of concerns]
*
* ## Consequences
* [Trade-offs:
* - More complex than Approach 1 or 2
* - Better performance
* - Clearer responsibilities]
*
* ## Alternatives Considered
* [Approach 1, 2 và lý do không chọn]
* ---
*/
// 💻 PHASE 2: Implementation (30 phút)
import { useState, useEffect } from "react";
// Validation helpers
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePhone = (phone) => {
const re = /^\d{10}$/;
return re.test(phone.replace(/\D/g, ""));
};
const validateZip = (zip) => {
const re = /^\d{5}$/;
return re.test(zip);
};
function MultiStepForm() {
// Current step
const [currentStep, setCurrentStep] = useState(1);
// Form data
const [formData, setFormData] = useState({
// Step 1
name: "",
email: "",
phone: "",
// Step 2
street: "",
city: "",
zip: "",
// Step 3
newsletter: false,
notifications: false,
});
// Errors state
const [errors, setErrors] = useState({});
// Auto-save state
const [lastSaved, setLastSaved] = useState(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// TODO: Effect 1 - Auto-save draft every 5 seconds
useEffect(() => {
if (!hasUnsavedChanges) return;
const timerId = setTimeout(() => {
// Simulate API call
console.log("💾 Auto-saving draft:", formData);
localStorage.setItem("formDraft", JSON.stringify(formData));
setLastSaved(new Date().toLocaleTimeString());
setHasUnsavedChanges(false);
}, 5000);
return () => clearTimeout(timerId);
}, [formData, hasUnsavedChanges]);
// TODO: Effect 2 - Validate step khi chuyển step
useEffect(() => {
console.log("Step changed to:", currentStep);
// Có thể validate previous step ở đây
// Hoặc clear errors của step mới
}, [currentStep]);
// TODO: Effect 3 - Load draft từ localStorage khi mount
useEffect(() => {
const saved = localStorage.getItem("formDraft");
if (saved) {
const draft = JSON.parse(saved);
setFormData(draft);
console.log("✅ Loaded draft from localStorage");
}
}, []); // Empty deps → Chỉ chạy lúc mount
// Field update handler
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
// Clear error for this field
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Field validation (on blur)
const validateField = (field) => {
const value = formData[field];
let error = null;
switch (field) {
case "name":
if (!value || value.length < 2) {
error = "Name must be at least 2 characters";
}
break;
case "email":
if (!value) {
error = "Email is required";
} else if (!validateEmail(value)) {
error = "Invalid email format";
}
break;
case "phone":
if (!value) {
error = "Phone is required";
} else if (!validatePhone(value)) {
error = "Phone must be 10 digits";
}
break;
case "street":
if (!value) error = "Street is required";
break;
case "city":
if (!value) error = "City is required";
break;
case "zip":
if (!value) {
error = "ZIP code is required";
} else if (!validateZip(value)) {
error = "ZIP must be 5 digits";
}
break;
}
if (error) {
setErrors((prev) => ({ ...prev, [field]: error }));
}
return !error;
};
// Step validation
const validateStep = (step) => {
let fields = [];
if (step === 1) {
fields = ["name", "email", "phone"];
} else if (step === 2) {
fields = ["street", "city", "zip"];
}
const isValid = fields.every((field) => validateField(field));
return isValid;
};
// Navigation handlers
const nextStep = () => {
if (validateStep(currentStep)) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
setCurrentStep((prev) => prev - 1);
};
const handleSubmit = () => {
if (validateStep(3)) {
console.log("✅ Form submitted:", formData);
localStorage.removeItem("formDraft");
alert("Registration complete!");
}
};
// Render step content
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<div>
<h3>Step 1: Personal Information</h3>
<div>
<label>Name:</label>
<input
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
onBlur={() => validateField("name")}
/>
{errors.name && (
<span style={{ color: "red" }}>{errors.name}</span>
)}
</div>
<div>
<label>Email:</label>
<input
type="email"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
onBlur={() => validateField("email")}
/>
{errors.email && (
<span style={{ color: "red" }}>{errors.email}</span>
)}
</div>
<div>
<label>Phone:</label>
<input
value={formData.phone}
onChange={(e) => updateField("phone", e.target.value)}
onBlur={() => validateField("phone")}
/>
{errors.phone && (
<span style={{ color: "red" }}>{errors.phone}</span>
)}
</div>
</div>
);
case 2:
return (
<div>
<h3>Step 2: Address</h3>
<div>
<label>Street:</label>
<input
value={formData.street}
onChange={(e) => updateField("street", e.target.value)}
onBlur={() => validateField("street")}
/>
{errors.street && (
<span style={{ color: "red" }}>{errors.street}</span>
)}
</div>
<div>
<label>City:</label>
<input
value={formData.city}
onChange={(e) => updateField("city", e.target.value)}
onBlur={() => validateField("city")}
/>
{errors.city && (
<span style={{ color: "red" }}>{errors.city}</span>
)}
</div>
<div>
<label>ZIP Code:</label>
<input
value={formData.zip}
onChange={(e) => updateField("zip", e.target.value)}
onBlur={() => validateField("zip")}
/>
{errors.zip && <span style={{ color: "red" }}>{errors.zip}</span>}
</div>
</div>
);
case 3:
return (
<div>
<h3>Step 3: Preferences</h3>
<div>
<label>
<input
type="checkbox"
checked={formData.newsletter}
onChange={(e) => updateField("newsletter", e.target.checked)}
/>
Subscribe to newsletter
</label>
</div>
<div>
<label>
<input
type="checkbox"
checked={formData.notifications}
onChange={(e) =>
updateField("notifications", e.target.checked)
}
/>
Enable notifications
</label>
</div>
</div>
);
}
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto", padding: "20px" }}>
<h2>Multi-Step Registration Form</h2>
{/* Progress indicator */}
<div style={{ marginBottom: "20px" }}>
Step {currentStep} of 3
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
{[1, 2, 3].map((step) => (
<div
key={step}
style={{
flex: 1,
height: "4px",
background: step <= currentStep ? "#4CAF50" : "#ddd",
}}
/>
))}
</div>
</div>
{/* Auto-save indicator */}
{hasUnsavedChanges && (
<p style={{ color: "#ff9800" }}>⚠️ Unsaved changes...</p>
)}
{lastSaved && (
<p style={{ color: "#4CAF50" }}>✅ Last saved: {lastSaved}</p>
)}
{/* Step content */}
{renderStep()}
{/* Navigation */}
<div style={{ marginTop: "20px", display: "flex", gap: "10px" }}>
{currentStep > 1 && <button onClick={prevStep}>← Previous</button>}
{currentStep < 3 && <button onClick={nextStep}>Next →</button>}
{currentStep === 3 && <button onClick={handleSubmit}>Submit</button>}
</div>
{/* Debug info */}
<div
style={{ marginTop: "30px", padding: "10px", background: "#f0f0f0" }}
>
<h4>Debug Info:</h4>
<pre>{JSON.stringify({ formData, errors, currentStep }, null, 2)}</pre>
</div>
</div>
);
}
export default MultiStepForm;
// 🧪 PHASE 3: Testing (10 phút)
// Manual testing checklist:
// - [ ] Step 1: Fill all fields → Validate on blur
// - [ ] Step 1: Try next with invalid data → Blocked
// - [ ] Step 1: Fix errors → Can proceed
// - [ ] Step 2: Validate address fields
// - [ ] Step 3: Toggle checkboxes
// - [ ] Auto-save: Wait 5s, check localStorage
// - [ ] Refresh page: Draft should load
// - [ ] Submit: Clear draft from storage
// - [ ] Navigation: Prev/Next buttons work
// - [ ] Errors: Show immediately on blur💡 Solution
/**
* MultiStepForm - Level 4: Multi-Step Registration Form với Validation & Auto-save
*
* Quyết định kiến trúc (Approach 3 - Hybrid):
* - Validation onBlur → dùng event handler (không dùng effect cho từng field)
* - Validate toàn bộ step khi nhấn Next → logic trong handler
* - Auto-save draft mỗi 5 giây khi có thay đổi → dùng useEffect + timer
* - Load draft từ localStorage khi mount → empty deps
* - Dependencies rõ ràng, tránh stale closure, tách biệt concerns
*/
import { useState, useEffect } from "react";
// Validation helpers
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const validatePhone = (phone) => {
const cleaned = phone.replace(/\D/g, "");
return cleaned.length === 10;
};
const validateZip = (zip) => {
return /^\d{5}$/.test(zip);
};
function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
street: "",
city: "",
zip: "",
newsletter: false,
notifications: false,
});
const [errors, setErrors] = useState({});
const [lastSaved, setLastSaved] = useState(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Effect: Auto-save draft mỗi 5 giây khi có thay đổi
useEffect(() => {
if (!hasUnsavedChanges) return;
const timerId = setTimeout(() => {
try {
localStorage.setItem("registrationDraft", JSON.stringify(formData));
setLastSaved(new Date().toLocaleTimeString());
setHasUnsavedChanges(false);
console.log("Draft auto-saved");
} catch (err) {
console.error("Auto-save failed:", err);
}
}, 5000);
return () => clearTimeout(timerId);
}, [formData, hasUnsavedChanges]);
// Effect: Load draft khi component mount
useEffect(() => {
try {
const saved = localStorage.getItem("registrationDraft");
if (saved) {
const draft = JSON.parse(saved);
setFormData(draft);
console.log("Loaded draft from localStorage");
}
} catch (err) {
console.error("Load draft failed:", err);
}
}, []); // empty deps → chỉ chạy 1 lần
const updateField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
// Clear error khi người dùng sửa field
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
const validateField = (field) => {
const value = formData[field];
let error = null;
switch (field) {
case "name":
if (!value.trim()) error = "Name is required";
else if (value.trim().length < 2)
error = "Name must be at least 2 characters";
break;
case "email":
if (!value) error = "Email is required";
else if (!validateEmail(value)) error = "Invalid email format";
break;
case "phone":
if (!value) error = "Phone is required";
else if (!validatePhone(value)) error = "Phone must be 10 digits";
break;
case "street":
if (!value.trim()) error = "Street is required";
break;
case "city":
if (!value.trim()) error = "City is required";
break;
case "zip":
if (!value) error = "ZIP code is required";
else if (!validateZip(value)) error = "ZIP must be 5 digits";
break;
default:
break;
}
if (error) {
setErrors((prev) => ({ ...prev, [field]: error }));
return false;
}
return true;
};
const validateCurrentStep = () => {
let fields = [];
if (currentStep === 1) fields = ["name", "email", "phone"];
if (currentStep === 2) fields = ["street", "city", "zip"];
let isValid = true;
fields.forEach((field) => {
if (!validateField(field)) isValid = false;
});
return isValid;
};
const nextStep = () => {
if (validateCurrentStep()) {
setCurrentStep((prev) => prev + 1);
// Clear errors của step cũ khi chuyển sang step mới
setErrors({});
}
};
const prevStep = () => {
setCurrentStep((prev) => prev - 1);
setErrors({});
};
const handleSubmit = () => {
if (validateCurrentStep()) {
console.log("Form submitted successfully:", formData);
localStorage.removeItem("registrationDraft");
alert("Registration completed!");
// Reset form nếu muốn
setFormData({
name: "",
email: "",
phone: "",
street: "",
city: "",
zip: "",
newsletter: false,
notifications: false,
});
setCurrentStep(1);
setErrors({});
setLastSaved(null);
setHasUnsavedChanges(false);
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<>
<h3>Step 1: Personal Information</h3>
<div>
<label>Name</label>
<input
value={formData.name}
onChange={(e) => updateField("name", e.target.value)}
onBlur={() => validateField("name")}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label>Email</label>
<input
type="email"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
onBlur={() => validateField("email")}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label>Phone</label>
<input
value={formData.phone}
onChange={(e) => updateField("phone", e.target.value)}
onBlur={() => validateField("phone")}
/>
{errors.phone && <span className="error">{errors.phone}</span>}
</div>
</>
);
case 2:
return (
<>
<h3>Step 2: Address</h3>
<div>
<label>Street</label>
<input
value={formData.street}
onChange={(e) => updateField("street", e.target.value)}
onBlur={() => validateField("street")}
/>
{errors.street && <span className="error">{errors.street}</span>}
</div>
<div>
<label>City</label>
<input
value={formData.city}
onChange={(e) => updateField("city", e.target.value)}
onBlur={() => validateField("city")}
/>
{errors.city && <span className="error">{errors.city}</span>}
</div>
<div>
<label>ZIP Code</label>
<input
value={formData.zip}
onChange={(e) => updateField("zip", e.target.value)}
onBlur={() => validateField("zip")}
/>
{errors.zip && <span className="error">{errors.zip}</span>}
</div>
</>
);
case 3:
return (
<>
<h3>Step 3: Preferences</h3>
<label>
<input
type="checkbox"
checked={formData.newsletter}
onChange={(e) => updateField("newsletter", e.target.checked)}
/>
Subscribe to newsletter
</label>
<label>
<input
type="checkbox"
checked={formData.notifications}
onChange={(e) => updateField("notifications", e.target.checked)}
/>
Enable notifications
</label>
</>
);
default:
return null;
}
};
return (
<div style={{ maxWidth: "500px", margin: "0 auto", padding: "20px" }}>
<h2>Multi-Step Registration</h2>
<div style={{ marginBottom: "20px" }}>
Step {currentStep} of 3
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
height: "6px",
}}
>
{[1, 2, 3].map((step) => (
<div
key={step}
style={{
flex: 1,
background: step <= currentStep ? "#4caf50" : "#e0e0e0",
borderRadius: "3px",
}}
/>
))}
</div>
</div>
{hasUnsavedChanges && (
<p style={{ color: "#f57c00" }}>Saving draft in 5s...</p>
)}
{lastSaved && <p style={{ color: "#388e3c" }}>Last saved: {lastSaved}</p>}
{renderStepContent()}
<div
style={{
marginTop: "24px",
display: "flex",
gap: "12px",
justifyContent: "space-between",
}}
>
{currentStep > 1 && <button onClick={prevStep}>Previous</button>}
{currentStep < 3 ? (
<button onClick={nextStep}>Next</button>
) : (
<button onClick={handleSubmit}>Submit</button>
)}
</div>
{/* Minimal inline style cho error */}
<style>{`
.error {
color: #d32f2f;
font-size: 0.85em;
display: block;
margin-top: 4px;
}
input, button {
padding: 10px;
margin: 8px 0;
width: 100%;
box-sizing: border-box;
}
button {
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover { background: #1565c0; }
label { display: block; margin: 12px 0 4px; }
`}</style>
</div>
);
}
export default MultiStepForm;Kết quả ví dụ khi tương tác:
// Mount → load draft nếu có → console: "Loaded draft from localStorage"
// Gõ name không hợp lệ → blur → error hiển thị ngay
// Gõ đủ hợp lệ cả step 1 → nhấn Next → chuyển sang step 2, error clear
// Gõ vài ký tự rồi ngừng 5 giây → console: "Draft auto-saved"
// Refresh trang → dữ liệu vẫn giữ nguyên từ localStorage
// Hoàn thành step 3 → Submit → alert thành công + xóa draft khỏi storage⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)
/**
* 🎯 Mục tiêu: Real-time Collaborative Text Editor
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* Xây dựng text editor với các tính năng:
* 1. Auto-save to localStorage (debounced)
* 2. Character count + word count (real-time)
* 3. Reading time estimation
* 4. Undo/Redo history (last 10 actions)
* 5. Collaborative indicator (simulate multiple users)
* 6. Dark mode toggle
* 7. Export to file
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture:
* - TextEditor (parent)
* - EditorToolbar (controls)
* - EditorStats (metrics)
* - EditorCanvas (textarea)
* - CollaboratorsList (fake users)
*
* 2. State Management Strategy:
* - content: Editor text
* - history: Array of past contents (undo/redo)
* - historyIndex: Current position in history
* - isDarkMode: Theme toggle
* - collaborators: Fake users list
* - lastSaved: Timestamp
*
* 3. Side Effects (useEffect usage):
* - Effect 1: Auto-save (debounced, deps: [content])
* - Effect 2: Update document.title with word count
* - Effect 3: Load from localStorage on mount
* - Effect 4: Simulate collaborators joining/leaving
* - Effect 5: Sync dark mode with localStorage
*
* 4. Performance Considerations:
* - Debounce auto-save (3 seconds)
* - Limit history size (max 10)
* - Throttle stats calculations (nếu content rất dài)
*
* 5. Error Handling Strategy:
* - Try/catch localStorage access
* - Fallback nếu localStorage full
* - Graceful degradation
*
* ✅ Production Checklist:
* - [ ] All states initialized
* - [ ] All effects have proper dependencies
* - [ ] Cleanup functions for timers
* - [ ] LocalStorage error handling
* - [ ] Keyboard shortcuts (Ctrl+Z, Ctrl+Y)
* - [ ] Accessibility (ARIA labels)
* - [ ] Visual feedback cho save status
* - [ ] Responsive design
* - [ ] Comments đầy đủ
*
* 📝 Documentation:
* - Component responsibilities
* - State structure
* - Effect purposes
* - How to extend
*/
import { useState, useEffect } from "react";
// Utility functions
const countWords = (text) => {
return text.trim() ? text.trim().split(/\s+/).length : 0;
};
const estimateReadingTime = (text) => {
const words = countWords(text);
const minutes = Math.ceil(words / 200); // Average reading speed
return minutes;
};
const saveToLocalStorage = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error("LocalStorage error:", e);
return false;
}
};
const loadFromLocalStorage = (key) => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (e) {
console.error("LocalStorage error:", e);
return null;
}
};
function CollaborativeTextEditor() {
// Core editor state
const [content, setContent] = useState("");
const [history, setHistory] = useState([""]);
const [historyIndex, setHistoryIndex] = useState(0);
// UI state
const [isDarkMode, setIsDarkMode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState(null);
const [saveStatus, setSaveStatus] = useState("All changes saved");
// Collaboration state (simulated)
const [collaborators, setCollaborators] = useState([
{ id: 1, name: "Alice", color: "#FF6B6B", active: true },
{ id: 2, name: "Bob", color: "#4ECDC4", active: false },
]);
// Computed values (derived state - KHÔNG cần useEffect!)
const charCount = content.length;
const wordCount = countWords(content);
const readingTime = estimateReadingTime(content);
// TODO: Effect 1 - Load từ localStorage khi mount
useEffect(() => {
// Load content
const savedContent = loadFromLocalStorage("editorContent");
if (savedContent) {
setContent(savedContent);
setHistory([savedContent]);
console.log("✅ Loaded content from localStorage");
}
// Load dark mode preference
const savedDarkMode = loadFromLocalStorage("editorDarkMode");
if (savedDarkMode !== null) {
setIsDarkMode(savedDarkMode);
}
}, []); // Empty deps → Run once on mount
// TODO: Effect 2 - Auto-save (debounced)
useEffect(() => {
// Don't save if content hasn't changed or is empty
if (!content) return;
setIsSaving(true);
setSaveStatus("Saving...");
const timerId = setTimeout(() => {
const success = saveToLocalStorage("editorContent", content);
setIsSaving(false);
if (success) {
setLastSaved(new Date());
setSaveStatus("All changes saved");
console.log("💾 Auto-saved at", new Date().toLocaleTimeString());
} else {
setSaveStatus("⚠️ Save failed");
}
}, 3000); // 3 second debounce
return () => {
clearTimeout(timerId);
setSaveStatus("Unsaved changes...");
};
}, [content]); // Re-run khi content thay đổi
// TODO: Effect 3 - Update document.title
useEffect(() => {
document.title = `Editor - ${wordCount} words`;
}, [wordCount]);
// TODO: Effect 4 - Sync dark mode preference
useEffect(() => {
saveToLocalStorage("editorDarkMode", isDarkMode);
// Apply to body class
if (isDarkMode) {
document.body.classList.add("dark-mode");
} else {
document.body.classList.remove("dark-mode");
}
}, [isDarkMode]);
// TODO: Effect 5 - Simulate collaborators activity
useEffect(() => {
const interval = setInterval(() => {
setCollaborators((prev) =>
prev.map((collab) => ({
...collab,
active: Math.random() > 0.5,
})),
);
}, 5000); // Update every 5 seconds
return () => clearInterval(interval);
}, []); // No deps → Continuous simulation
// Content change handler
const handleContentChange = (e) => {
const newContent = e.target.value;
setContent(newContent);
// Add to history (limit to last 10)
const newHistory = [...history.slice(0, historyIndex + 1), newContent];
if (newHistory.length > 10) {
newHistory.shift(); // Remove oldest
} else {
setHistoryIndex(historyIndex + 1);
}
setHistory(newHistory);
};
// Undo handler
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setContent(history[newIndex]);
}
};
// Redo handler
const handleRedo = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setContent(history[newIndex]);
}
};
// Export handler
const handleExport = () => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `document-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
handleUndo();
} else if (e.key === "y" || (e.key === "z" && e.shiftKey)) {
e.preventDefault();
handleRedo();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [historyIndex, history]); // Deps: values used in handler
return (
<div
style={{
minHeight: "100vh",
background: isDarkMode ? "#1e1e1e" : "#ffffff",
color: isDarkMode ? "#d4d4d4" : "#000000",
transition: "all 0.3s ease",
}}
>
<div style={{ maxWidth: "900px", margin: "0 auto", padding: "20px" }}>
{/* Toolbar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
padding: "10px",
background: isDarkMode ? "#2d2d2d" : "#f5f5f5",
borderRadius: "8px",
}}
>
<div style={{ display: "flex", gap: "10px" }}>
<button
onClick={handleUndo}
disabled={historyIndex === 0}
title="Undo (Ctrl+Z)"
>
↶ Undo
</button>
<button
onClick={handleRedo}
disabled={historyIndex === history.length - 1}
title="Redo (Ctrl+Y)"
>
↷ Redo
</button>
<button onClick={handleExport}>📥 Export</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
<span style={{ fontSize: "14px" }}>
{isSaving ? "💾 Saving..." : saveStatus}
</span>
<label
style={{
display: "flex",
alignItems: "center",
gap: "5px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={isDarkMode}
onChange={(e) => setIsDarkMode(e.target.checked)}
/>
🌙 Dark Mode
</label>
</div>
</div>
{/* Stats Bar */}
<div
style={{
display: "flex",
gap: "20px",
marginBottom: "20px",
padding: "10px",
background: isDarkMode ? "#2d2d2d" : "#f5f5f5",
borderRadius: "8px",
fontSize: "14px",
}}
>
<span>📝 {charCount} characters</span>
<span>📊 {wordCount} words</span>
<span>⏱️ {readingTime} min read</span>
<span>
📚 History: {historyIndex + 1}/{history.length}
</span>
</div>
{/* Collaborators */}
<div
style={{
marginBottom: "20px",
padding: "10px",
background: isDarkMode ? "#2d2d2d" : "#f5f5f5",
borderRadius: "8px",
}}
>
<strong>👥 Collaborators:</strong>
<div style={{ display: "flex", gap: "10px", marginTop: "5px" }}>
{collaborators.map((collab) => (
<div
key={collab.id}
style={{
padding: "5px 10px",
background: collab.color,
color: "white",
borderRadius: "20px",
fontSize: "12px",
opacity: collab.active ? 1 : 0.5,
}}
>
{collab.name} {collab.active ? "🟢" : "⚪"}
</div>
))}
</div>
</div>
{/* Editor */}
<textarea
value={content}
onChange={handleContentChange}
placeholder="Start writing your masterpiece..."
style={{
width: "100%",
minHeight: "400px",
padding: "20px",
fontSize: "16px",
lineHeight: "1.6",
border: "none",
borderRadius: "8px",
background: isDarkMode ? "#2d2d2d" : "#ffffff",
color: isDarkMode ? "#d4d4d4" : "#000000",
resize: "vertical",
fontFamily: "monospace",
}}
aria-label="Text editor"
/>
{/* Footer */}
<div
style={{
marginTop: "20px",
padding: "10px",
textAlign: "center",
fontSize: "12px",
opacity: 0.7,
}}
>
{lastSaved && (
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
)}
<br />
Tip: Use Ctrl+Z to undo, Ctrl+Y to redo
</div>
</div>
</div>
);
}
export default CollaborativeTextEditor;
// 📋 TESTING CHECKLIST:
// - [ ] Type text → Auto-save after 3s
// - [ ] Undo/Redo with buttons
// - [ ] Undo/Redo with Ctrl+Z/Ctrl+Y
// - [ ] Toggle dark mode → Persists on refresh
// - [ ] Export to file
// - [ ] Stats update in real-time
// - [ ] Collaborators change active status
// - [ ] Refresh page → Content loads
// - [ ] History limited to 10 items
// - [ ] Save status indicators accurate
// 💡 EXTENSION IDEAS:
// 1. Rich text formatting (bold, italic)
// 2. Markdown preview
// 3. Real WebSocket collaboration
// 4. Version history timeline
// 5. Cloud sync (API integration)
// 6. Spell check
// 7. Find & Replace💡 Solution
/**
* CollaborativeTextEditor - Level 5: Production-grade Real-time Text Editor
*
* Tính năng chính:
* - Real-time character/word count + reading time estimation
* - Debounced auto-save to localStorage (3 giây)
* - Undo / Redo (lưu tối đa 10 bước)
* - Dark mode toggle + persist
* - Simulated collaborators status
* - Export to .txt file
* - Keyboard shortcuts (Ctrl+Z / Ctrl+Y)
* - Save status indicator
*/
import { useState, useEffect } from "react";
// Utility functions
const countWords = (text) =>
text.trim() ? text.trim().split(/\s+/).length : 0;
const estimateReadingTime = (text) => {
const words = countWords(text);
return Math.ceil(words / 200); // ~200 từ/phút
};
const saveToStorage = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error("Storage save error:", e);
return false;
}
};
const loadFromStorage = (key) => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (e) {
console.error("Storage load error:", e);
return null;
}
};
function CollaborativeTextEditor() {
// Editor core state
const [content, setContent] = useState("");
const [history, setHistory] = useState([""]);
const [historyIndex, setHistoryIndex] = useState(0);
// UI & feature states
const [isDarkMode, setIsDarkMode] = useState(false);
const [saveStatus, setSaveStatus] = useState("All changes saved");
const [lastSaved, setLastSaved] = useState(null);
const [isSaving, setIsSaving] = useState(false);
// Simulated collaborators
const [collaborators] = useState([
{ id: 1, name: "Alice", color: "#ef5350", active: true },
{ id: 2, name: "Bob", color: "#42a5f5", active: false },
{ id: 3, name: "Emma", color: "#66bb6a", active: true },
]);
// Derived stats (không cần useEffect)
const charCount = content.length;
const wordCount = countWords(content);
const readingTime = estimateReadingTime(content);
// Load saved content & dark mode preference on mount
useEffect(() => {
const savedContent = loadFromStorage("editorContent");
if (savedContent !== null) {
setContent(savedContent);
setHistory([savedContent]);
setHistoryIndex(0);
}
const savedDarkMode = loadFromStorage("editorDarkMode");
if (savedDarkMode !== null) {
setIsDarkMode(savedDarkMode);
}
}, []);
// Auto-save with debounce (3 seconds)
useEffect(() => {
if (!content.trim()) {
setSaveStatus("All changes saved");
return;
}
setIsSaving(true);
setSaveStatus("Saving...");
const timer = setTimeout(() => {
const success = saveToStorage("editorContent", content);
setIsSaving(false);
if (success) {
setLastSaved(new Date());
setSaveStatus("All changes saved");
} else {
setSaveStatus("Save failed – storage may be full");
}
}, 3000);
return () => {
clearTimeout(timer);
setIsSaving(false);
setSaveStatus("Unsaved changes...");
};
}, [content]);
// Sync dark mode to storage & apply class
useEffect(() => {
saveToStorage("editorDarkMode", isDarkMode);
document.body.classList.toggle("dark-mode", isDarkMode);
}, [isDarkMode]);
// Simulate collaborator status changes
useEffect(() => {
const interval = setInterval(() => {
// Randomly toggle active status cho demo
setCollaborators((prev) =>
prev.map((c) => ({
...c,
active: Math.random() > 0.4,
})),
);
}, 8000);
return () => clearInterval(interval);
}, []);
// Undo / Redo keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "z") {
e.preventDefault();
handleUndo();
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "y" || (e.shiftKey && e.key === "z"))
) {
e.preventDefault();
handleRedo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [historyIndex, history]);
const handleContentChange = (e) => {
const newContent = e.target.value;
setContent(newContent);
// Cập nhật history (giới hạn 10 bước)
const newHistory = [...history.slice(0, historyIndex + 1), newContent];
setHistory(newHistory.slice(-10)); // Giữ tối đa 10 items
setHistoryIndex(newHistory.length - 1);
};
const handleUndo = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setContent(history[newIndex]);
}
};
const handleRedo = () => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setContent(history[newIndex]);
}
};
const handleExport = () => {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `document-${new Date().toISOString().slice(0, 10)}.txt`;
link.click();
URL.revokeObjectURL(url);
};
return (
<div
style={{
minHeight: "100vh",
background: isDarkMode ? "#121212" : "#f5f5f5",
color: isDarkMode ? "#e0e0e0" : "#212121",
transition: "all 0.25s ease",
}}
>
<div style={{ maxWidth: "960px", margin: "0 auto", padding: "20px" }}>
{/* Toolbar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
padding: "12px 16px",
background: isDarkMode ? "#1e1e1e" : "#ffffff",
borderRadius: "8px",
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
}}
>
<div style={{ display: "flex", gap: "12px" }}>
<button
onClick={handleUndo}
disabled={historyIndex === 0}
title="Undo (Ctrl+Z)"
>
↶ Undo
</button>
<button
onClick={handleRedo}
disabled={historyIndex === history.length - 1}
title="Redo (Ctrl+Y)"
>
↷ Redo
</button>
<button onClick={handleExport} title="Export to .txt">
Export
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "20px" }}>
<span
style={{
fontSize: "14px",
color: isSaving
? "#f57c00"
: saveStatus.includes("failed")
? "#d32f2f"
: "#388e3c",
}}
>
{isSaving ? "Saving..." : saveStatus}
</span>
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={isDarkMode}
onChange={(e) => setIsDarkMode(e.target.checked)}
/>
Dark Mode
</label>
</div>
</div>
{/* Stats */}
<div
style={{
display: "flex",
gap: "24px",
marginBottom: "16px",
fontSize: "14px",
color: isDarkMode ? "#bbbbbb" : "#555",
}}
>
<span>
Characters: <strong>{charCount}</strong>
</span>
<span>
Words: <strong>{wordCount}</strong>
</span>
<span>~{readingTime} min read</span>
<span>
History: {historyIndex + 1} / {history.length}
</span>
</div>
{/* Collaborators */}
<div
style={{
marginBottom: "20px",
padding: "12px",
background: isDarkMode ? "#1e1e1e" : "#ffffff",
borderRadius: "8px",
boxShadow: "0 1px 6px rgba(0,0,0,0.08)",
}}
>
<strong>Collaborators now:</strong>
<div
style={{
display: "flex",
gap: "12px",
marginTop: "8px",
flexWrap: "wrap",
}}
>
{collaborators.map((collab) => (
<div
key={collab.id}
style={{
padding: "6px 12px",
background: collab.color,
color: "white",
borderRadius: "16px",
fontSize: "13px",
opacity: collab.active ? 1 : 0.45,
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
{collab.name}
<span>{collab.active ? "🟢" : "⚪"}</span>
</div>
))}
</div>
</div>
{/* Editor Area */}
<textarea
value={content}
onChange={handleContentChange}
placeholder="Start writing here..."
style={{
width: "100%",
minHeight: "500px",
padding: "20px",
fontSize: "16px",
lineHeight: 1.6,
border: "none",
borderRadius: "8px",
background: isDarkMode ? "#1e1e1e" : "#ffffff",
color: isDarkMode ? "#e0e0e0" : "#212121",
boxShadow: "0 2px 12px rgba(0,0,0,0.12)",
resize: "vertical",
fontFamily: "inherit",
}}
/>
{/* Footer info */}
<div
style={{
marginTop: "16px",
textAlign: "center",
fontSize: "13px",
color: isDarkMode ? "#888" : "#666",
}}
>
{lastSaved && <>Last saved: {lastSaved.toLocaleTimeString()}</>}
<br />
<small>
Tip: Ctrl+Z to undo • Ctrl+Y to redo • Auto-saves every 3 seconds
</small>
</div>
</div>
{/* Basic dark mode styles */}
<style>{`
body.dark-mode {
background: #121212;
color: #e0e0e0;
}
button {
padding: 8px 16px;
background: #1976d2;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:hover:not(:disabled) {
background: #1565c0;
}
`}</style>
</div>
);
}
export default CollaborativeTextEditor;Kết quả ví dụ khi tương tác:
// Mount → nếu có draft → load nội dung cũ
// Gõ vài câu → sau 3 giây → "All changes saved" + lưu localStorage
// Nhấn Undo (Ctrl+Z) → nội dung quay về bước trước, historyIndex giảm
// Bật Dark Mode → giao diện chuyển tối, lưu preference
// Collaborators → trạng thái active thay đổi ngẫu nhiên mỗi ~8 giây
// Nhấn Export → tải file .txt với nội dung hiện tại
// Refresh trang → nội dung + dark mode preference được khôi phục📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Dependencies Patterns
| Pattern | Syntax | Runs When | Use Case | Performance |
|---|---|---|---|---|
| No Deps | useEffect(() => {}) | Every render | Debug logs | ⚠️ Poor |
| Empty [] | useEffect(() => {}, []) | Once (mount) | Initial fetch, setup | ✅ Best |
| Single [a] | useEffect(() => {}, [a]) | When a changes | Sync with one value | ✅ Good |
| Multiple [a,b] | useEffect(() => {}, [a, b]) | When a OR b changes | Sync with multiple | ✅ Good |
| Object [obj] | useEffect(() => {}, [obj]) | When obj reference changes | ⚠️ Often re-runs | ❌ Poor |
| Array [arr] | useEffect(() => {}, [arr]) | When arr reference changes | ⚠️ Often re-runs | ❌ Poor |
Bảng So Sánh: Stale Closure Solutions
| Solution | Code | Pros | Cons | When to Use |
|---|---|---|---|---|
| Functional Update | setState(prev => prev + 1) | ✅ No stale closure ✅ Simple | ❌ Only for setState | State updates trong effects |
| Add to Deps | useEffect(() => {...}, [value]) | ✅ Always fresh ✅ Clear | ❌ Effect re-runs | Need latest value |
| useRef | ref.current = value | ✅ Mutable ✅ No re-render | ❌ More complex | Persist without re-run (Ngày 21) |
Decision Tree: Chọn Dependencies
Cần dùng useEffect?
│
├─ Effect dùng giá trị nào từ component?
│ │
│ ├─ KHÔNG dùng giá trị nào (pure side effect)
│ │ → Empty deps []
│ │ → Ví dụ: window.addEventListener('resize', ...)
│ │
│ ├─ Dùng giá trị KHÔNG THAY ĐỔI (props, constants)
│ │ → Empty deps [] (nếu truly constant)
│ │ → Hoặc khai báo trong deps cho safety
│ │
│ ├─ Dùng STATE hoặc PROPS có thể thay đổi
│ │ │
│ │ ├─ Effect cần chạy MỖI KHI giá trị thay đổi?
│ │ │ → Add to deps: [value]
│ │ │
│ │ └─ Effect CHỈ cần value ban đầu?
│ │ │
│ │ ├─ Dùng setState?
│ │ │ → Functional update: setState(prev => ...)
│ │ │
│ │ └─ Dùng cho logic khác?
│ │ → useRef (Ngày 21)
│ │
│ └─ Dùng FUNCTION từ props?
│ → Add to deps hoặc useCallback (Ngày 30+)
│
└─ ESLint warning?
→ LUÔN LUÔN thêm vào deps (trừ khi có lý do rõ ràng)
→ Comment giải thích nếu ignore🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug #1: Missing Dependencies 🚨
/**
* 🐛 BUG: ESLint warning - missing dependencies
* 🎯 Nhiệm vụ: Fix theo đúng quy tắc
*/
function BuggyUserGreeting() {
const [user, setUser] = useState({ name: "John", age: 25 });
const [greeting, setGreeting] = useState("");
// ❌ BUG: ESLint warning
// React Hook useEffect has a missing dependency: 'user'
useEffect(() => {
setGreeting(`Hello, ${user.name}! You are ${user.age} years old.`);
}, []); // Empty deps, but uses `user`!
return (
<div>
<p>{greeting}</p>
<button onClick={() => setUser({ name: "Jane", age: 30 })}>
Change User
</button>
</div>
);
}
// 🤔 CÂU HỎI DEBUG:
// 1. Tại sao có ESLint warning?
// 2. Click button → greeting có update không?
// 3. Behavior mong đợi là gì?
// 💡 GIẢI THÍCH:
// - Effect chỉ chạy 1 lần (empty deps)
// - `user` trong effect là giá trị lúc mount: { name: 'John', age: 25 }
// - Click button → user state thay đổi → Effect KHÔNG chạy lại
// - greeting vẫn là "Hello, John! You are 25 years old."
// - ❌ STALE CLOSURE!
// ✅ FIX #1: Derived State (BEST cho case này!)
function FixedV1() {
const [user, setUser] = useState({ name: "John", age: 25 });
// ✅ Tính trực tiếp, không cần effect
const greeting = `Hello, ${user.name}! You are ${user.age} years old.`;
return (
<div>
<p>{greeting}</p>
<button onClick={() => setUser({ name: "Jane", age: 30 })}>
Change User
</button>
</div>
);
}
// ✅ FIX #2: Add user to deps (nếu thực sự cần effect)
function FixedV2() {
const [user, setUser] = useState({ name: "John", age: 25 });
const [greeting, setGreeting] = useState("");
useEffect(() => {
// Effect chạy lại khi user thay đổi
setGreeting(`Hello, ${user.name}! You are ${user.age} years old.`);
}, [user]); // ← Add dependency
return (
<div>
<p>{greeting}</p>
<button onClick={() => setUser({ name: "Jane", age: 30 })}>
Change User
</button>
</div>
);
}
// 🎓 BÀI HỌC:
// - LUÔN khai báo dependencies đầy đủ
// - ESLint exhaustive-deps rule là bạn, không phải kẻ thù
// - Nhiều khi không cần effect → Derived state tốt hơnBug #2: Object/Array Dependencies 🔄
/**
* 🐛 BUG: Effect chạy vô hạn vì object dependency
* 🎯 Nhiệm vụ: Hiểu tại sao và fix
*/
function BuggyFilteredList() {
const [items, setItems] = useState([
{ id: 1, name: "Apple", category: "Fruit" },
{ id: 2, name: "Carrot", category: "Vegetable" },
{ id: 3, name: "Banana", category: "Fruit" },
]);
const [filters, setFilters] = useState({ category: "Fruit" });
const [filteredItems, setFilteredItems] = useState([]);
// ❌ BUG: Infinite loop!
useEffect(() => {
console.log("Effect ran");
const filtered = items.filter((item) => {
// Tạo object MỚI mỗi lần!
return item.category === filters.category;
});
setFilteredItems(filtered); // Array mới → Trigger re-render
}, [items, filters, filteredItems]); // ⚠️ filteredItems trong deps!
return (
<div>
<h3>Filtered Items:</h3>
<ul>
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
// 🤔 CÂU HỎI DEBUG:
// 1. Tại sao effect chạy vô hạn?
// 2. filteredItems có nên nằm trong deps không?
// 3. Array.filter() return gì?
// 💡 GIẢI THÍCH:
// 1. Effect runs → filter() tạo array MỚI
// 2. setFilteredItems(filtered) → State thay đổi
// 3. filteredItems thay đổi → Effect re-runs (trong deps!)
// 4. Loop lặp lại!
//
// Array/Object trong deps:
// - React so sánh bằng Object.is() (=== comparison)
// - filter() LUÔN return array mới → Reference khác
// - Ngay cả khi content giống nhau!
// ✅ FIX #1: Remove filteredItems từ deps
function FixedV1() {
const [items, setItems] = useState([
{ id: 1, name: "Apple", category: "Fruit" },
{ id: 2, name: "Carrot", category: "Vegetable" },
{ id: 3, name: "Banana", category: "Fruit" },
]);
const [filters, setFilters] = useState({ category: "Fruit" });
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
const filtered = items.filter((item) => item.category === filters.category);
setFilteredItems(filtered);
}, [items, filters]); // ← Remove filteredItems!
// ...
}
// ✅ FIX #2: Derived State (BEST!)
function FixedV2() {
const [items, setItems] = useState([
{ id: 1, name: "Apple", category: "Fruit" },
{ id: 2, name: "Carrot", category: "Vegetable" },
{ id: 3, name: "Banana", category: "Fruit" },
]);
const [filters, setFilters] = useState({ category: "Fruit" });
// ✅ Tính trực tiếp, KHÔNG cần effect và state!
const filteredItems = items.filter(
(item) => item.category === filters.category,
);
return (
<div>
<h3>Filtered Items:</h3>
<ul>
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
// 🎓 BÀI HỌC:
// - ĐỪNG bao giờ đặt "output" state của effect vào deps!
// - Object/Array dependencies: Cẩn thận với reference changes
// - Nhiều khi derived state (tính trực tiếp) tốt hơn effect + state
// - useMemo (Ngày 28) sẽ optimize derived state nếu cầnBug #3: Effect với Primitive Wrapper 🎁
/**
* 🐛 BUG: Effect không chạy dù deps thay đổi
* 🎯 Nhiệm vụ: Debug dependency comparison
*/
function BuggyCountDisplay() {
const [count, setCount] = useState(0);
const [displayCount, setDisplayCount] = useState(0);
// Tạo object wrapper (BAD PATTERN!)
const countWrapper = { value: count };
useEffect(() => {
console.log("Effect ran with count:", countWrapper.value);
setDisplayCount(countWrapper.value);
}, [countWrapper]); // ⚠️ Object dependency!
return (
<div>
<p>Display: {displayCount}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// 🤔 CÂU HỎI DEBUG:
// 1. Click button → Effect có chạy không?
// 2. countWrapper có thay đổi không?
// 3. Tại sao effect chạy mỗi render?
// 💡 GIẢI THÍCH:
// - Mỗi render → countWrapper = { value: count } MỚI
// - Object mới → Reference mới → Always different
// - Effect chạy MỖI render (giống no deps!)
// - ❌ KHÔNG kiểm soát được
// ✅ FIX: Dùng primitive value trực tiếp
function Fixed() {
const [count, setCount] = useState(0);
const [displayCount, setDisplayCount] = useState(0);
useEffect(() => {
console.log("Effect ran with count:", count);
setDisplayCount(count);
}, [count]); // ← Primitive value, so sánh bằng ===
return (
<div>
<p>Display: {displayCount}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
// 🎓 BÀI HỌC:
// - Dependencies nên là PRIMITIVE values (string, number, boolean)
// - Avoid wrapping primitives trong objects
// - Nếu cần object deps → useMemo (Ngày 28) hoặc stable reference
// - Keep dependencies simple và predictable✅ 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 3 patterns: no deps, empty [], specific [a, b]
- [ ] Tôi biết empty [] = chỉ chạy 1 lần sau mount
- [ ] Tôi hiểu [a, b] = chạy khi a HOẶC b thay đổi
- [ ] Tôi biết stale closure là gì và tại sao xảy ra
- [ ] Tôi hiểu React so sánh deps bằng Object.is()
Practices:
- [ ] Tôi có thể chọn đúng dependencies cho effect
- [ ] Tôi biết khi nào dùng functional update
- [ ] Tôi tránh được stale closure bugs
- [ ] Tôi biết khi nào nên dùng derived state thay vì effect
- [ ] Tôi hiểu ESLint exhaustive-deps rule
Debugging:
- [ ] Tôi nhận biết được missing dependencies
- [ ] Tôi biết fix infinite loops với deps
- [ ] Tôi hiểu vấn đề với object/array deps
- [ ] Tôi có thể debug deps comparison issues
- [ ] Tôi biết cách trace effect re-runs
Code Review Checklist
Khi review code có useEffect, kiểm tra:
Dependencies:
- [ ] Mọi giá trị dùng trong effect đều nằm trong deps
- [ ] Dependencies là primitive values (tránh objects)
- [ ] Không có "output" state trong deps (gây infinite loop)
- [ ] ESLint warnings được giải quyết (không disable lung tung)
Logic:
- [ ] Effect đúng purpose (side effect, không phải derived state)
- [ ] Stale closure được tránh (functional updates hoặc proper deps)
- [ ] Cleanup function nếu cần (timers, listeners)
Performance:
- [ ] Dependencies tối thiểu cần thiết
- [ ] Empty [] cho setup code (chỉ 1 lần)
- [ ] Specific deps cho sync logic
- [ ] Avoid no-deps pattern trừ khi debug
Best Practices:
- [ ] Comments giải thích WHY effect cần thiết
- [ ] Dependencies được document (nếu unusual)
- [ ] Alternatives considered (derived state? event handler?)
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: Window Size Tracker
/**
* Tạo component track window size:
* - State cho width và height
* - useEffect với window.addEventListener('resize', ...)
* - Update state khi resize
* - Dependencies: []
*
* Requirements:
* - Display current window size
* - Update real-time khi resize
* - Cleanup event listener
*
* Hints:
* - window.innerWidth, window.innerHeight
* - Effect với empty deps [] để add listener 1 lần
* - Return cleanup function
*/💡 Solution - Bài 1: Window Size Tracker
/**
* WindowSizeTracker - Bài tập về nhà 1
* Hiển thị kích thước cửa sổ trình duyệt hiện tại
* Cập nhật real-time khi resize
* Cleanup event listener đúng cách
*/
import { useState, useEffect } from "react";
function WindowSizeTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
// Cleanup: gỡ listener khi component unmount
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // empty deps → chỉ add listener 1 lần khi mount
return (
<div>
<h2>Window Size Tracker</h2>
<p>
Current window size:
<strong>
{" "}
{windowSize.width}px × {windowSize.height}px
</strong>
</p>
<p style={{ color: "#666", fontSize: "14px" }}>
Thử thay đổi kích thước cửa sổ trình duyệt để xem giá trị cập nhật
</p>
</div>
);
}
export default WindowSizeTracker;Kết quả ví dụ:
// Ban đầu (ví dụ màn hình 1440×900)
Current window size: 1440px × 900px
// Thu nhỏ cửa sổ trình duyệt → ví dụ 768×1024 (mobile portrait)
Current window size: 768px × 1024px
// Mở rộng lại full screen
Current window size: 1920px × 1080pxBài 2: Counter với Auto-increment
/**
* Tạo counter tự động tăng:
* - State cho count
* - State cho isRunning (true/false)
* - useEffect để increment mỗi giây khi isRunning = true
* - Buttons: Start, Pause, Reset
*
* Requirements:
* - Auto-increment CHỈ khi isRunning = true
* - Pause → Stop incrementing
* - Resume → Continue from current value
* - Fix stale closure với functional update
*
* Hints:
* - setInterval trong effect
* - Dependencies: [isRunning]
* - setCount(prev => prev + 1)
* - Cleanup: clearInterval
*/💡 Solution - Bài 2: Counter với Auto-increment
/**
* AutoIncrementCounter - Bài tập về nhà 2
* Counter tự động tăng mỗi giây khi đang chạy
* Có nút Start / Pause / Reset
* Sử dụng functional update để tránh stale closure
*/
import { useState, useEffect } from "react";
function AutoIncrementCounter() {
const [count, setCount] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isRunning) return;
const intervalId = setInterval(() => {
// Functional update → luôn dùng giá trị mới nhất
setCount((prevCount) => prevCount + 1);
}, 1000);
// Cleanup: dừng interval khi pause hoặc unmount
return () => clearInterval(intervalId);
}, [isRunning]); // deps chỉ isRunning → effect chạy lại khi start/pause
const handleReset = () => {
setCount(0);
setIsRunning(false); // tự động pause khi reset
};
return (
<div>
<h2>Auto-Increment Counter</h2>
<h1 style={{ fontSize: "3.5rem", margin: "20px 0" }}>{count}</h1>
<div style={{ display: "flex", gap: "12px", justifyContent: "center" }}>
<button onClick={() => setIsRunning(true)} disabled={isRunning}>
Start
</button>
<button onClick={() => setIsRunning(false)} disabled={!isRunning}>
Pause
</button>
<button onClick={handleReset}>Reset</button>
</div>
<p style={{ marginTop: "20px", color: "#555" }}>
{isRunning ? "Đang tăng mỗi giây..." : "Đã tạm dừng"}
</p>
</div>
);
}
export default AutoIncrementCounter;Kết quả ví dụ:
// Ban đầu: count = 0, nút Start enable
Click Start → count tăng: 1 → 2 → 3... mỗi giây
Click Pause → dừng ở ví dụ 7
Click Start lại → tiếp tục từ 8 → 9...
Click Reset → count về 0, tự động pauseNâng cao (60 phút)
Bài 3: Scroll Progress Indicator
/**
* Tạo component hiển thị % trang đã scroll:
* - Progress bar ở top màn hình
* - Update real-time khi scroll
* - Smooth animation
*
* Requirements:
* - Calculate scroll percentage
* - useEffect với scroll listener
* - Throttle updates (mỗi 100ms)
* - Dependencies: []
*
* Challenges:
* - Throttle function implementation
* - Fixed position progress bar
* - Cleanup scroll listener
* - Calculate: (scrollTop / (scrollHeight - clientHeight)) * 100
*/💡 Solution - Bài 3: Scroll Progress Indicator (Nâng cao)
/**
* ScrollProgressBar - Bài tập về nhà 3 (nâng cao)
* Thanh tiến trình scroll ở đầu trang
* Cập nhật mượt mà khi scroll
* Throttle để không cập nhật quá thường xuyên
*/
import { useState, useEffect } from "react";
function ScrollProgressBar() {
const [progress, setProgress] = useState(0);
useEffect(() => {
let timeoutId = null;
const updateProgress = () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const scrollable = docHeight - winHeight;
const percentage =
scrollable > 0 ? Math.min((scrollTop / scrollable) * 100, 100) : 0;
setProgress(percentage);
};
const throttledUpdate = () => {
if (timeoutId) return;
timeoutId = setTimeout(() => {
updateProgress();
timeoutId = null;
}, 100); // throttle 100ms
};
window.addEventListener("scroll", throttledUpdate);
window.addEventListener("resize", throttledUpdate); // cũng update khi resize
// Initial call
updateProgress();
return () => {
window.removeEventListener("scroll", throttledUpdate);
window.removeEventListener("resize", throttledUpdate);
if (timeoutId) clearTimeout(timeoutId);
};
}, []);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
height: "4px",
background: "#1976d2",
transform: `scaleX(${progress / 100})`,
transformOrigin: "left",
transition: "transform 0.15s ease-out",
zIndex: 9999,
}}
/>
);
}
// Để test: thêm nội dung dài vào trang
function ScrollTestPage() {
return (
<div style={{ padding: "20px", minHeight: "300vh" }}>
<ScrollProgressBar />
<h1>Scroll xuống để xem thanh tiến trình</h1>
<p style={{ height: "200vh" }}>Nội dung rất dài để có thể scroll...</p>
<p>Cuối trang</p>
</div>
);
}
export default ScrollTestPage;Kết quả ví dụ:
// Scroll xuống 25% trang → thanh màu xanh kéo dài 25% từ trái sang
// Scroll lên đầu → thanh co về 0%
// Scroll xuống cuối → thanh full 100%
// Di chuyển mượt, không giật lag nhờ throttleBài 4: Form với Validation Dependencies
/**
* Tạo signup form với validation:
* - Fields: username, email, password, confirmPassword
* - useEffect để validate khi specific fields thay đổi
* - Show errors immediately
*
* Requirements:
* - Effect 1: Validate email khi email thay đổi
* - Effect 2: Check passwords match khi password hoặc confirmPassword thay đổi
* - Effect 3: Check username availability (fake async)
* - Dependencies chính xác cho mỗi effect
*
* Challenges:
* - Multiple effects với different deps
* - Async validation (setTimeout)
* - Cleanup để cancel pending checks
* - Debounce username check
*/💡 Solution - Bài 4: Form với Validation Dependencies
/**
* SignupFormWithValidation - Bài tập về nhà 4 (nâng cao)
*
* Yêu cầu:
* - Các field: username, email, password, confirmPassword
* - Validation theo từng field / nhóm field bằng useEffect với dependencies phù hợp
* - Hiển thị lỗi ngay lập tức khi field thay đổi (real-time validation)
* - Username availability check giả lập (async với setTimeout)
* - Password match check khi password hoặc confirmPassword thay đổi
* - Debounce username check để tránh gọi quá nhiều
*/
import { useState, useEffect } from "react";
function SignupFormWithValidation() {
const [form, setForm] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
const [errors, setErrors] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
const [isCheckingUsername, setIsCheckingUsername] = useState(false);
const [usernameAvailable, setUsernameAvailable] = useState(null);
// Helper: cập nhật field và clear lỗi liên quan
const updateField = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
// Clear lỗi của field này ngay khi người dùng gõ
setErrors((prev) => ({ ...prev, [field]: "" }));
// Reset username availability khi username thay đổi
if (field === "username") {
setUsernameAvailable(null);
}
};
// Effect 1: Validate email real-time khi email thay đổi
useEffect(() => {
if (!form.email) {
setErrors((prev) => ({ ...prev, email: "Email là bắt buộc" }));
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(form.email)) {
setErrors((prev) => ({ ...prev, email: "Email không hợp lệ" }));
} else {
setErrors((prev) => ({ ...prev, email: "" }));
}
}, [form.email]);
// Effect 2: Kiểm tra password & confirmPassword match
useEffect(() => {
if (!form.password && !form.confirmPassword) {
setErrors((prev) => ({
...prev,
password: "",
confirmPassword: "",
}));
return;
}
if (form.password && form.password.length < 6) {
setErrors((prev) => ({
...prev,
password: "Mật khẩu phải có ít nhất 6 ký tự",
}));
} else {
setErrors((prev) => ({ ...prev, password: "" }));
}
if (form.confirmPassword && form.password !== form.confirmPassword) {
setErrors((prev) => ({
...prev,
confirmPassword: "Mật khẩu xác nhận không khớp",
}));
} else if (form.confirmPassword) {
setErrors((prev) => ({ ...prev, confirmPassword: "" }));
}
}, [form.password, form.confirmPassword]);
// Effect 3: Debounce + check username availability (giả lập async)
useEffect(() => {
if (!form.username || form.username.length < 3) {
setUsernameAvailable(null);
setErrors((prev) => ({
...prev,
username: form.username
? "Tên người dùng phải ≥ 3 ký tự"
: "Tên người dùng là bắt buộc",
}));
return;
}
// Clear lỗi cũ trước khi check
setErrors((prev) => ({ ...prev, username: "" }));
setIsCheckingUsername(true);
const timer = setTimeout(() => {
// Giả lập API check username (random 70% available)
const isAvailable = Math.random() > 0.3;
setUsernameAvailable(isAvailable);
if (!isAvailable) {
setErrors((prev) => ({
...prev,
username: "Tên người dùng đã được sử dụng",
}));
} else {
setErrors((prev) => ({ ...prev, username: "" }));
}
setIsCheckingUsername(false);
}, 800); // debounce 800ms
return () => {
clearTimeout(timer);
setIsCheckingUsername(false);
};
}, [form.username]);
const isFormValid =
!errors.username &&
!errors.email &&
!errors.password &&
!errors.confirmPassword &&
usernameAvailable === true &&
form.username &&
form.email &&
form.password &&
form.confirmPassword;
const handleSubmit = (e) => {
e.preventDefault();
if (isFormValid) {
alert("Đăng ký thành công!\n" + JSON.stringify(form, null, 2));
// Reset form nếu muốn
setForm({ username: "", email: "", password: "", confirmPassword: "" });
setUsernameAvailable(null);
} else {
alert("Vui lòng sửa các lỗi trước khi gửi.");
}
};
return (
<div style={{ maxWidth: "420px", margin: "40px auto", padding: "20px" }}>
<h2>Đăng ký tài khoản</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "20px" }}>
<label>Tên người dùng</label>
<input
type="text"
value={form.username}
onChange={(e) => updateField("username", e.target.value)}
placeholder="ít nhất 3 ký tự"
style={{ width: "100%", padding: "10px", marginTop: "6px" }}
/>
{errors.username && (
<div
style={{ color: "#d32f2f", fontSize: "14px", marginTop: "4px" }}
>
{errors.username}
</div>
)}
{isCheckingUsername && (
<div
style={{ color: "#1976d2", fontSize: "14px", marginTop: "4px" }}
>
Đang kiểm tra tên người dùng...
</div>
)}
{usernameAvailable === true && (
<div
style={{ color: "#388e3c", fontSize: "14px", marginTop: "4px" }}
>
Tên người dùng có sẵn ✓
</div>
)}
</div>
<div style={{ marginBottom: "20px" }}>
<label>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => updateField("email", e.target.value)}
placeholder="example@domain.com"
style={{ width: "100%", padding: "10px", marginTop: "6px" }}
/>
{errors.email && (
<div
style={{ color: "#d32f2f", fontSize: "14px", marginTop: "4px" }}
>
{errors.email}
</div>
)}
</div>
<div style={{ marginBottom: "20px" }}>
<label>Mật khẩu</label>
<input
type="password"
value={form.password}
onChange={(e) => updateField("password", e.target.value)}
placeholder="Tối thiểu 6 ký tự"
style={{ width: "100%", padding: "10px", marginTop: "6px" }}
/>
{errors.password && (
<div
style={{ color: "#d32f2f", fontSize: "14px", marginTop: "4px" }}
>
{errors.password}
</div>
)}
</div>
<div style={{ marginBottom: "28px" }}>
<label>Xác nhận mật khẩu</label>
<input
type="password"
value={form.confirmPassword}
onChange={(e) => updateField("confirmPassword", e.target.value)}
placeholder="Nhập lại mật khẩu"
style={{ width: "100%", padding: "10px", marginTop: "6px" }}
/>
{errors.confirmPassword && (
<div
style={{ color: "#d32f2f", fontSize: "14px", marginTop: "4px" }}
>
{errors.confirmPassword}
</div>
)}
</div>
<button
type="submit"
disabled={!isFormValid}
style={{
width: "100%",
padding: "12px",
background: isFormValid ? "#1976d2" : "#90caf9",
color: "white",
border: "none",
borderRadius: "6px",
fontSize: "16px",
cursor: isFormValid ? "pointer" : "not-allowed",
}}
>
Đăng ký
</button>
</form>
<div style={{ marginTop: "24px", fontSize: "13px", color: "#555" }}>
<strong>Validation theo dependencies:</strong>
<br />
• Email: useEffect([email])
<br />
• Password match: useEffect([password, confirmPassword])
<br />• Username check (debounced): useEffect([username])
</div>
</div>
);
}
export default SignupFormWithValidation;Kết quả ví dụ khi tương tác:
// Gõ username "abc" → sau ~800ms: "Tên người dùng có sẵn ✓" (hoặc "đã được sử dụng")
// Gõ username < 3 ký tự → lỗi ngay lập tức
// Gõ email không hợp lệ → lỗi "Email không hợp lệ"
// Gõ password 123 → lỗi "Mật khẩu phải có ít nhất 6 ký tự"
// Gõ confirmPassword khác password → lỗi "Mật khẩu xác nhận không khớp"
// Khi tất cả hợp lệ + username available → nút Đăng ký sáng lên, có thể submit📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - useEffect Dependencies
- https://react.dev/reference/react/useEffect#specifying-reactive-dependencies
- Đọc kỹ phần Dependencies
- Hiểu Object.is() comparison
Separating Events from Effects
- https://react.dev/learn/separating-events-from-effects
- Khi nào dùng effect vs event handler
- Dependency optimization
Đọc thêm
A Complete Guide to useEffect (Dan Abramov) - Part 2 -https://overreacted.io/a-complete-guide-to-useeffect/
- Đọc phần về Dependencies
- Stale Closure explained
ESLint Plugin React Hooks
- https://www.npmjs.com/package/eslint-plugin-react-hooks
- Hiểu exhaustive-deps rule
- When to disable (rarely!)
🔗 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, timing, no deps
- Kết nối: Hôm nay học dependencies để kiểm soát
Ngày 11-12: useState patterns
- Đã học: Functional updates
- Kết nối: Dùng để fix stale closures
Hướng tới (sẽ học):
Ngày 18: Cleanup & Memory Leaks
- Sẽ học: Return cleanup function chi tiết
- Sẽ học: Event listeners, subscriptions cleanup
- Dependencies + Cleanup = Complete picture
Ngày 28: useMemo & useCallback
- Sẽ học: Optimize object/array dependencies
- Sẽ học: Memoization for performance
- Sẽ học: Stable references
Ngày 24: Custom Hooks
- Sẽ học: Extract effect logic
- Sẽ học: Reusable patterns (useDebounce, useThrottle)
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. ESLint Rule - Your Best Friend:
// ❌ ĐỪNG disable rule lightly
useEffect(() => {
doSomething(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // ← Dangerous!
// ✅ Nếu thực sự cần, explain WHY
useEffect(() => {
// We only want this to run once on mount,
// even though it uses `value`.
// `value` is guaranteed to be stable from props.
doSomething(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally empty - value is stable2. Dependencies Anti-patterns:
// ❌ BAD: Inline object
useEffect(() => {
api.fetch({ userId: user.id });
}, [{ userId: user.id }]); // New object every render!
// ✅ GOOD: Primitive values
useEffect(() => {
api.fetch({ userId: user.id });
}, [user.id]); // Primitive comparison
// ❌ BAD: Inline function
useEffect(() => {
handler();
}, [() => handler()]); // New function every render!
// ✅ GOOD: Stable function (useCallback - Ngày 30)
// Hoặc define outside component nếu pure function3. Debugging Dependencies:
useEffect(() => {
console.log("Effect ran");
console.log("Dependencies:", { a, b, c });
// Log previous values (advanced - needs useRef)
// Ngày 21 sẽ học cách implement
}, [a, b, c]);Câu Hỏi Phỏng Vấn
Junior Level:
Q: Dependencies array làm gì? A: Cho React biết KHI NÀO cần chạy lại effect. Effect chỉ re-run khi một trong các dependencies thay đổi (so sánh bằng Object.is).
Q: Khác biệt giữa
[]và không có deps? A:- Không có deps: Effect chạy sau MỖI render
- Empty
[]: Effect chỉ chạy 1 LẦN sau mount [a, b]: Effect chạy khi a hoặc b thay đổi
Q: Stale closure là gì? A: Khi effect capture giá trị cũ từ closure và không update khi state thay đổi. Fix bằng cách: (1) Add to deps, (2) Functional updates, (3) useRef.
Mid Level:
Q: Tại sao object trong deps gây re-run liên tục? A: React so sánh deps bằng Object.is() (===). Objects/arrays luôn có reference mới mỗi render, nên luôn "khác" → Effect re-runs.
Q: Khi nào dùng derived state vs useEffect? A:
- Derived state: Khi value có thể tính trực tiếp từ state/props
- useEffect: Khi cần side effect (DOM, API, timers, etc.)
- Rule: Nếu không có side effect → Đừng dùng effect!
Senior Level:
Q: Làm sao optimize effect với object dependencies? A:
- Extract primitive values:
[user.id]thay vì[user] - useMemo để stabilize objects (Ngày 28)
- Custom comparison với useRef (advanced)
- Restructure state để avoid objects
- Extract primitive values:
Q: Debug strategy cho effect dependency issues? A:
- Log deps trong effect để track changes
- React DevTools Profiler để see re-renders
- Check ESLint warnings carefully
- Use strict mode để catch issues
- Implement useWhyDidYouUpdate custom hook
War Stories
Story #1: The Infinite Loop Production Bug 🔥
"Launch ngày đầu, user complain app freeze. Debug thấy effect với
[filteredData]deps, mà trong effect lại setFilteredData. Filter tạo array mới → Infinite loop. Fix bằng cách remove filteredData khỏi deps và dùng derived state. Bài học: Output của effect KHÔNG BAO GIỜ nằm trong deps!"
Story #2: Stale Closure in Chat App 💬
"Chat app có interval send 'typing...' indicator. Dùng
useEffect(() => setInterval(...), [])với empty deps. Bug: Indicator luôn hiển thị user cũ, không update khi switch chat. Stale closure! Username được capture lúc mount. Fix: Dùng[chatId]deps để recreate interval, hoặc useRef. Trade-off: Performance vs correctness."
Story #3: ESLint Disabled = Technical Debt 📉
"Inherited codebase với 50+
eslint-disablecho exhaustive-deps. Mỗi effect đều có subtle bugs. Spend 2 weeks refactor, fix tất cả deps properly. Discover 10+ bugs chưa report. Lesson: ESLint rule exists for a reason - respect it!"
🎯 NGÀY MAI: Cleanup & Memory Leaks
Preview những gì bạn sẽ học:
Cleanup Function
- Return function trong effect
- Khi nào cleanup chạy
- Cleanup timers, listeners, subscriptions
Memory Leaks Prevention
- Identify memory leaks
- Cleanup patterns
- Async operations cleanup
- AbortController preview
Advanced Cleanup
- Multiple effects cleanup
- Cleanup dependencies
- Race conditions
🔥 Chuẩn bị:
- Ôn lại event listeners (addEventListener, removeEventListener)
- Hiểu setInterval/setTimeout cleanup
- Làm xong bài tập hôm nay (especially window resize)
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 17!
Bạn đã:
- ✅ Master được Dependencies Array
- ✅ Hiểu 3 patterns: no deps, empty [], specific deps
- ✅ Fix được Stale Closure bugs
- ✅ Biết optimize dependencies
- ✅ Áp dụng được vào real-world scenarios (debounce, auto-save, validation)
Dependencies là chìa khóa để làm chủ useEffect. Bạn đã mở khóa thành công! 🚀
Ngày 18 sẽ complete bức tranh với Cleanup - missing piece cuối cùng!