Skip to content

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

  1. 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)
  2. 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 [])
  3. Câu 3: Nếu bạn muốn effect chỉ chạy khi count thay đổi, không phải khi name thay đổi, làm sao?

    • Đáp án: Cũng chưa biết! (Hôm nay sẽ học: [count])

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

jsx
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âm name
  • Nhưng effect vẫn chạy khi name thay đổ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:

jsx
useEffect(
  () => {
    // Effect logic
  },
  [dependencies], // ← Dependencies Array
);

3 Patterns Cơ Bản:

jsx
// 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 values

1.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"

jsx
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"

jsx
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à đủ"

jsx
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 ⭐

jsx
/**
 * 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 ⭐⭐

jsx
/**
 * 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 renderEffect chỉ chạy khi deps thay đổi
Type name → Effect runsType name → Effect KHÔNG chạy ✅
Click count → Effect runsClick count → Effect chạy ✅
Không kiểm soátKiểm soát chính xác

Demo 3: Edge Cases - Stale Closure Problem ⭐⭐⭐

jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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ạy

Kế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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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)

jsx
/**
 * 🎯 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
jsx
/**
 * 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

PatternSyntaxRuns WhenUse CasePerformance
No DepsuseEffect(() => {})Every renderDebug logs⚠️ Poor
Empty []useEffect(() => {}, [])Once (mount)Initial fetch, setup✅ Best
Single [a]useEffect(() => {}, [a])When a changesSync with one value✅ Good
Multiple [a,b]useEffect(() => {}, [a, b])When a OR b changesSync 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

SolutionCodeProsConsWhen to Use
Functional UpdatesetState(prev => prev + 1)✅ No stale closure
✅ Simple
❌ Only for setStateState updates trong effects
Add to DepsuseEffect(() => {...}, [value])✅ Always fresh
✅ Clear
❌ Effect re-runsNeed latest value
useRefref.current = value✅ Mutable
✅ No re-render
❌ More complexPersist 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 🚨

jsx
/**
 * 🐛 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ơn

Bug #2: Object/Array Dependencies 🔄

jsx
/**
 * 🐛 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ần

Bug #3: Effect với Primitive Wrapper 🎁

jsx
/**
 * 🐛 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

jsx
/**
 * 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
jsx
/**
 * 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 × 1080px

Bài 2: Counter với Auto-increment

jsx
/**
 * 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
jsx
/**
 * 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 pause

Nâng cao (60 phút)

Bài 3: Scroll Progress Indicator

jsx
/**
 * 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)
jsx
/**
 * 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ờ throttle

Bài 4: Form với Validation Dependencies

jsx
/**
 * 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
jsx
/**
 * 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

  1. React Docs - useEffect Dependencies

  2. Separating Events from Effects

Đọc thêm

  1. 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
  2. ESLint Plugin React Hooks


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

jsx
// ❌ ĐỪ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 stable

2. Dependencies Anti-patterns:

jsx
// ❌ 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 function

3. Debugging Dependencies:

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

  1. 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).

  2. 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
  3. 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:

  1. 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.

  2. 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:

  1. 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
  2. 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-disable cho 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!

Personal tech knowledge base