📅 NGÀY 14: Lifting State Up
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Hiểu khi nào cần lift state up vs khi nào giữ state local
- [ ] Share state giữa sibling components thông qua parent
- [ ] Implement inverse data flow (child → parent communication via callbacks)
- [ ] Nhận biết và giải quyết props drilling một cách hợp lý
- [ ] Thiết kế component hierarchy với state placement tối ưu
🤔 Kiểm tra đầu vào (5 phút)
Trả lời 3 câu hỏi sau để kích hoạt kiến thức từ Ngày 11-13:
Câu 1: State được define trong component A. Component B (con của A) có thể access state đó không? Làm thế nào?
Câu 2: Hai sibling components cần share data. Nên đặt state ở đâu?
Câu 3: Code này có vấn đề gì?
function Parent() {
return <Child />;
}
function Child() {
const [count, setCount] = useState(0);
// Parent muốn biết count value - làm thế nào?
}💡 Xem đáp án
- Component B có thể access qua props. Parent (A) pass state xuống:
<B value={state} /> - State nên đặt ở parent chung gần nhất (closest common ancestor)
- Data flow một chiều: Child không thể tự send data lên Parent. Cần callback prop:
<Child onCountChange={handleChange} />
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy xem tình huống này:
// ❌ PROBLEM: Hai components cần cùng data nhưng state isolated
function ProductList() {
const [selectedProduct, setSelectedProduct] = useState(null);
return (
<div>
<h2>Products</h2>
{/* List of products, click to select */}
</div>
);
}
function ProductDetails() {
// ❌ Làm sao biết product nào đang selected???
// selectedProduct ở ProductList, không access được!
return (
<div>
<h2>Details</h2>
{/* Show selected product details */}
</div>
);
}
function App() {
return (
<div>
<ProductList />
<ProductDetails />
</div>
);
}Problems:
- ProductList và ProductDetails là siblings (cùng level)
- Không component nào pass props cho nhau được
- State trong ProductList không thể access từ ProductDetails
- Cần share state giữa siblings!
1.2 Giải Pháp: Lifting State Up
Core Principle:
"Khi 2+ components cần share state, lift state lên parent chung gần nhất của chúng"
┌─────────────────────────────────────────┐
│ LIFTING STATE UP │
├─────────────────────────────────────────┤
│ │
│ BEFORE: │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Component A │ │ Component B │ │
│ │ [state] ❌ │ │ needs state │ │
│ └──────────────┘ └──────────────┘ │
│ ↑ ↑ │
│ └────────┬───────┘ │
│ Parent │
│ │
│ AFTER: │
│ Parent │
│ [state] ✅ │
│ ↓ ↓ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Component A │ │ Component B │ │
│ │ gets props │ │ gets props │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ State "lifted up" to parent │
│ Both children receive via props │
└─────────────────────────────────────────┘1.3 Mental Model
Analogy: Shared Whiteboard
❌ BAD: Mỗi người có whiteboard riêng
Person A (whiteboard A) → Person B không thấy
Person B (whiteboard B) → Person A không thấy
[Không sync được!]
✅ GOOD: Dùng chung 1 whiteboard ở giữa
Shared Whiteboard
↙ ↘
Person A Person B
[Cả 2 đều thấy và update cùng data!]React Flow:
Parent Component (state owner)
↓ ↓
Child A Child B
(reads via props) (reads via props)
↓ ↓
onChange callback → update parent state
↓ ↓
Parent state changes
↓ ↓
Both children re-render with new props1.4 Hiểu Lầm Phổ Biến
❌ Myth 1: "Lifting state = always lift to App component"
✅ Truth: Chỉ lift đến parent gần nhất, không cao hơn cần thiết
❌ Myth 2: "State càng cao càng tốt"
✅ Truth: State càng gần nơi dùng càng tốt. Chỉ lift khi CẦN share
❌ Myth 3: "Child không thể update parent state"
✅ Truth: Child CÓ THỂ update qua callback props
❌ Myth 4: "Lifting state làm app chậm"
✅ Truth: Nếu lift đúng chỗ thì OK. Lift quá cao mới chậm (re-render không cần thiết components)
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Basic Lifting - Chuyển đổi Độ C sang Độ F ⭐
❌ CÁCH SAI: State Isolated
// ❌ PROBLEM: Celsius và Fahrenheit không sync
function CelsiusInput() {
const [temperature, setTemperature] = useState('');
return (
<div>
<label>Celsius:</label>
<input
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
/>
</div>
);
}
function FahrenheitInput() {
const [temperature, setTemperature] = useState('');
return (
<div>
<label>Fahrenheit:</label>
<input
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
/>
</div>
);
}
function TemperatureConverter() {
return (
<div>
<CelsiusInput />
<FahrenheitInput />
{/* ❌ Gõ vào Celsius, Fahrenheit không update */}
{/* ❌ 2 states riêng biệt, không sync! */}
</div>
);
}Problems:
- Mỗi input có state riêng
- Không cách nào sync giữa chúng
- User gõ vào 1 field, field kia không update
✅ CÁCH ĐÚNG: Lift State Up
// Helper functions
function toCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9) / 5 + 32;
}
// ✅ Child components nhận props thay vì có state riêng
function TemperatureInput({ scale, temperature, onTemperatureChange }) {
return (
<div style={{ marginBottom: '10px' }}>
<label style={{ display: 'inline-block', width: '100px' }}>
{scale === 'c' ? 'Celsius:' : 'Fahrenheit:'}
</label>
<input
value={temperature}
onChange={(e) => onTemperatureChange(e.target.value)}
style={{ padding: '5px', width: '200px' }}
/>
</div>
);
}
// ✅ Parent owns the state
function TemperatureConverter() {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c'); // 'c' or 'f'
const handleCelsiusChange = (temp) => {
setTemperature(temp);
setScale('c');
};
const handleFahrenheitChange = (temp) => {
setTemperature(temp);
setScale('f');
};
// ✅ Calculate derived values
const celsius =
scale === 'f'
? toCelsius(parseFloat(temperature))
: parseFloat(temperature);
const fahrenheit =
scale === 'c'
? toFahrenheit(parseFloat(temperature))
: parseFloat(temperature);
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h2>🌡️ Temperature Converter</h2>
<TemperatureInput
scale='c'
temperature={scale === 'c' ? temperature : celsius.toFixed(1)}
onTemperatureChange={handleCelsiusChange}
/>
<TemperatureInput
scale='f'
temperature={scale === 'f' ? temperature : fahrenheit.toFixed(1)}
onTemperatureChange={handleFahrenheitChange}
/>
<div
style={{
marginTop: '20px',
padding: '15px',
background: '#f0f0f0',
borderRadius: '4px',
}}
>
{temperature && !isNaN(celsius) ? (
<p>
<strong>Result:</strong> {celsius.toFixed(2)}°C ={' '}
{fahrenheit.toFixed(2)}°F
</p>
) : (
<p>Enter a temperature</p>
)}
</div>
</div>
);
}🔥 KEY PATTERNS:
- State Lifted to Parent:
// Parent owns state
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c');- Children Are "Controlled":
// Child receives value via props (controlled component pattern)
<TemperatureInput
temperature={temperature} // ✅ Props down
onTemperatureChange={handleChange} // ✅ Callbacks up
/>- Inverse Data Flow:
// Child calls callback when user types
<input onChange={(e) => onTemperatureChange(e.target.value)} />;
// Parent updates state
const handleCelsiusChange = (temp) => {
setTemperature(temp); // ✅ Parent controls state
};- Single Source of Truth:
// ✅ Chỉ 1 state temperature for nhiệt độ
// Cả hai đầu vào đều lấy giá trị từ state này
const celsius = scale === 'f' ? toCelsius(temperature) : temperature;
const fahrenheit = scale === 'c' ? toFahrenheit(temperature) : temperature;Demo 2: Shopping Cart - Kịch Bản Thực Tế ⭐⭐
// ✅ Product Card (presentational - no state)
function ProductCard({ product, onAddToCart }) {
return (
<div
style={{
border: '1px solid #ddd',
padding: '15px',
borderRadius: '8px',
marginBottom: '10px',
}}
>
<h3>{product.name}</h3>
<p style={{ color: '#666' }}>${product.price}</p>
<button
onClick={() => onAddToCart(product)}
style={{
padding: '8px 16px',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Add to Cart
</button>
</div>
);
}
// ✅ Product List (receives data + callbacks)
function ProductList({ products, onAddToCart }) {
return (
<div style={{ flex: 1, marginRight: '20px' }}>
<h2>📦 Products</h2>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
);
}
// ✅ Cart Summary (receives cart data + callbacks)
function CartSummary({ cartItems, onRemoveFromCart, onClearCart }) {
const total = cartItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return (
<div
style={{
flex: 1,
background: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
height: 'fit-content',
}}
>
<h2>🛒 Cart ({cartItems.length})</h2>
{cartItems.length === 0 ? (
<p style={{ color: '#666' }}>Cart is empty</p>
) : (
<>
{cartItems.map((item) => (
<div
key={item.id}
style={{
marginBottom: '10px',
paddingBottom: '10px',
borderBottom: '1px solid #ddd',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<strong>{item.name}</strong>
<p style={{ margin: '5px 0', color: '#666' }}>
${item.price} × {item.quantity} = $
{item.price * item.quantity}
</p>
</div>
<button
onClick={() => onRemoveFromCart(item.id)}
style={{
padding: '5px 10px',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Remove
</button>
</div>
</div>
))}
<div
style={{
marginTop: '20px',
paddingTop: '20px',
borderTop: '2px solid #333',
}}
>
<h3>Total: ${total.toFixed(2)}</h3>
<button
onClick={onClearCart}
style={{
width: '100%',
padding: '10px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginTop: '10px',
}}
>
Clear Cart
</button>
</div>
</>
)}
</div>
);
}
// ✅ Parent Component - Owns State
function ShoppingApp() {
// ✅ State lifted to parent (shared by ProductList and CartSummary)
const [cart, setCart] = useState([]);
// Sample products
const products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 },
{ id: 3, name: 'Keyboard', price: 79 },
{ id: 4, name: 'Monitor', price: 299 },
];
// ✅ Handler: Add to cart
const handleAddToCart = (product) => {
setCart((prev) => {
// Check if product already in cart
const existingItem = prev.find((item) => item.id === product.id);
if (existingItem) {
// Increase quantity
return prev.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item,
);
} else {
// Add new item
return [...prev, { ...product, quantity: 1 }];
}
});
};
// ✅ Handler: Remove from cart
const handleRemoveFromCart = (productId) => {
setCart((prev) => prev.filter((item) => item.id !== productId));
};
// ✅ Handler: Clear cart
const handleClearCart = () => {
setCart([]);
};
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h1>🛍️ Shopping Cart App</h1>
<div style={{ display: 'flex', gap: '20px', marginTop: '20px' }}>
{/* ✅ Pass state + callbacks down */}
<ProductList
products={products}
onAddToCart={handleAddToCart}
/>
<CartSummary
cartItems={cart}
onRemoveFromCart={handleRemoveFromCart}
onClearCart={handleClearCart}
/>
</div>
</div>
);
}🔥 KEY LEARNINGS:
- State Placement:
// ✅ State in parent (ShoppingApp)
const [cart, setCart] = useState([]);
// ❌ WRONG: State in ProductList
// CartSummary wouldn't be able to access it!- Data Flow:
ShoppingApp (state owner)
↓ ↓
ProductList CartSummary
(receives callbacks) (receives cart data)
↓
ProductCard
(calls callback)
↓
User clicks "Add to Cart"
↓
Callback → Parent updates state
↓
Cart state changes
↓
CartSummary re-renders with new cart- Props Down, Callbacks Up:
// ✅ Data flows down via props
<CartSummary cartItems={cart} />
// ✅ Events flow up via callbacks
<ProductList onAddToCart={handleAddToCart} />Demo 3: Filter + List Pattern - Edge Cases ⭐⭐⭐
// ✅ Search Bar Component
function SearchBar({ searchTerm, onSearchChange }) {
return (
<div style={{ marginBottom: '20px' }}>
<input
type='text'
placeholder='Search users...'
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '2px solid #ddd',
borderRadius: '4px',
}}
/>
</div>
);
}
// ✅ Filter Buttons
function FilterButtons({ activeFilter, onFilterChange }) {
const filters = ['all', 'active', 'inactive'];
return (
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
{filters.map((filter) => (
<button
key={filter}
onClick={() => onFilterChange(filter)}
style={{
padding: '8px 16px',
background: activeFilter === filter ? '#007bff' : '#e0e0e0',
color: activeFilter === filter ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textTransform: 'capitalize',
}}
>
{filter}
</button>
))}
</div>
);
}
// ✅ User List Component
function UserList({ users }) {
if (users.length === 0) {
return (
<p style={{ textAlign: 'center', color: '#666', padding: '40px' }}>
No users found
</p>
);
}
return (
<div>
{users.map((user) => (
<div
key={user.id}
style={{
padding: '15px',
marginBottom: '10px',
border: '1px solid #ddd',
borderRadius: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<h3 style={{ margin: '0 0 5px 0' }}>{user.name}</h3>
<p style={{ margin: 0, color: '#666' }}>{user.email}</p>
</div>
<span
style={{
padding: '4px 12px',
background: user.isActive ? '#28a745' : '#6c757d',
color: 'white',
borderRadius: '12px',
fontSize: '0.85em',
}}
>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
))}
</div>
);
}
// ✅ Parent Component - Orchestrates Everything
function UserManagement() {
// ✅ All filter state lifted here
const [searchTerm, setSearchTerm] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
// Sample data
const allUsers = [
{
id: 1,
name: 'Alice Johnson',
email: 'alice@example.com',
isActive: true,
},
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', isActive: false },
{
id: 3,
name: 'Charlie Brown',
email: 'charlie@example.com',
isActive: true,
},
{ id: 4, name: 'David Lee', email: 'david@example.com', isActive: false },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', isActive: true },
];
// ✅ Derived state: filtered users
const filteredUsers = allUsers.filter((user) => {
// Filter by search term
const matchesSearch =
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase());
// Filter by active status
const matchesFilter =
activeFilter === 'all' ||
(activeFilter === 'active' && user.isActive) ||
(activeFilter === 'inactive' && !user.isActive);
return matchesSearch && matchesFilter;
});
return (
<div
style={{
maxWidth: '800px',
margin: '0 auto',
padding: '20px',
fontFamily: 'system-ui',
}}
>
<h1>👥 User Management</h1>
{/* ✅ All components controlled by parent */}
<SearchBar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<FilterButtons
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
<div style={{ marginBottom: '10px', color: '#666' }}>
Showing {filteredUsers.length} of {allUsers.length} users
</div>
<UserList users={filteredUsers} />
{/* Debug info */}
<details style={{ marginTop: '20px' }}>
<summary>🔍 Debug: Filter State</summary>
<pre
style={{
background: '#f5f5f5',
padding: '10px',
borderRadius: '4px',
}}
>
{JSON.stringify(
{ searchTerm, activeFilter, resultCount: filteredUsers.length },
null,
2,
)}
</pre>
</details>
</div>
);
}🔥 Advanced Patterns:
- Multiple Filter States:
// ✅ Parent manages multiple filter criteria
const [searchTerm, setSearchTerm] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
// Could add more: sortBy, dateRange, etc.- Derived Data (Don't Store Filtered List!):
// ✅ GOOD: Compute filtered list
const filteredUsers = allUsers.filter((user) => {
return matchesSearch && matchesFilter;
});
// ❌ BAD: Store filtered list in state
// const [filteredUsers, setFilteredUsers] = useState([]);
// This is derived state anti-pattern!- Presentational Components:
// ✅ Components are "dumb" - just display what they're given
function UserList({ users }) {
// No state, no logic, just render
return users.map((user) => <UserCard user={user} />);
}🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Parent-Child Communication (15 phút)
/**
* 🎯 Mục tiêu: Implement inverse data flow
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useEffect, useRef, Context
*
* Requirements:
* 1. Counter component với buttons +/-
* 2. Parent cần biết count value để display
* 3. Parent có button "Reset" để set count về 0
* 4. Counter component nhận initial value từ parent
*
* 💡 Gợi ý:
* - State ở đâu? (Parent hay Child?)
* - Counter là controlled component
*/
// ❌ Starter code (cần sửa):
function Counter() {
// TODO: Should this have state?
return (
<div>
<button>-</button>
<span>Count: ???</span>
<button>+</button>
</div>
);
}
function App() {
// TODO: Add state here?
return (
<div>
<h2>Parent knows count: ???</h2>
<Counter />
<button>Reset to 0</button>
</div>
);
}
// ✅ NHIỆM VỤ CỦA BẠN:
// TODO: Lift state to parent
// TODO: Make Counter controlled
// TODO: Implement reset functionality💡 Solution
// ✅ Counter is controlled component (no internal state)
function Counter({ count, onIncrement, onDecrement }) {
return (
<div
style={{
display: 'flex',
gap: '10px',
alignItems: 'center',
padding: '20px',
background: '#f0f0f0',
borderRadius: '8px',
width: 'fit-content',
}}
>
<button
onClick={onDecrement}
style={{
padding: '10px 20px',
fontSize: '20px',
cursor: 'pointer',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
-
</button>
<span
style={{
fontSize: '24px',
fontWeight: 'bold',
minWidth: '50px',
textAlign: 'center',
}}
>
{count}
</span>
<button
onClick={onIncrement}
style={{
padding: '10px 20px',
fontSize: '20px',
cursor: 'pointer',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
+
</button>
</div>
);
}
// ✅ Parent owns state
function App() {
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount((prev) => prev + 1);
};
const handleDecrement = () => {
setCount((prev) => prev - 1);
};
const handleReset = () => {
setCount(0);
};
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h2>Parent knows count: {count}</h2>
<Counter
count={count}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
/>
<button
onClick={handleReset}
style={{
marginTop: '20px',
padding: '10px 20px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Reset to 0
</button>
{/* Extra: Show if count is even/odd */}
<p style={{ marginTop: '20px', color: '#666' }}>
Count is {count % 2 === 0 ? 'even' : 'odd'}
</p>
</div>
);
}Key Learnings:
- State lifted to parent (App owns count)
- Counter is controlled via props
- Callbacks flow up (onIncrement, onDecrement)
- Parent can control counter (reset button)
⭐⭐ Exercise 2: Todo List with Filter (25 phút)
/**
* 🎯 Mục tiêu: Share state giữa multiple siblings
* ⏱️ Thời gian: 25 phút
*
* Scenario: Todo app với separate components
*
* Components:
* - AddTodoForm: Input + button để add todo
* - FilterButtons: All / Active / Completed
* - TodoList: Display filtered todos
* - TodoStats: Show counts (total, active, completed)
*
* 🤔 QUESTIONS:
* 1. State nên ở đâu?
* 2. Component nào cần callbacks?
* 3. Derived state gì?
*/
// TODO: Implement these components
function AddTodoForm({ onAddTodo }) {
// TODO: Local state cho input
// TODO: Call onAddTodo callback
return <div>Add Todo Form</div>;
}
function FilterButtons({ activeFilter, onFilterChange }) {
// TODO: Render filter buttons
return <div>Filter Buttons</div>;
}
function TodoList({ todos, onToggleTodo, onDeleteTodo }) {
// TODO: Render todo items
return <div>Todo List</div>;
}
function TodoStats({ total, active, completed }) {
// TODO: Display statistics
return <div>Stats</div>;
}
function TodoApp() {
// TODO: Design state structure
// - todos array
// - filter ('all', 'active', 'completed')
// TODO: Implement handlers
// TODO: Compute filtered todos (derived state)
// TODO: Compute stats (derived state)
return (
<div>
<h1>Todo App</h1>
{/* TODO: Compose components */}
</div>
);
}💡 Solution
// ✅ AddTodoForm - has local state for input
function AddTodoForm({ onAddTodo }) {
const [input, setInput] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
onAddTodo(input.trim());
setInput(''); // Clear input
}
};
return (
<form
onSubmit={handleSubmit}
style={{ marginBottom: '20px' }}
>
<input
type='text'
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='What needs to be done?'
style={{ padding: '10px', width: '300px', fontSize: '16px' }}
/>
<button
type='submit'
style={{ padding: '10px 20px', marginLeft: '10px', cursor: 'pointer' }}
>
Add
</button>
</form>
);
}
// ✅ FilterButtons
function FilterButtons({ activeFilter, onFilterChange }) {
const filters = ['all', 'active', 'completed'];
return (
<div style={{ marginBottom: '20px' }}>
{filters.map((filter) => (
<button
key={filter}
onClick={() => onFilterChange(filter)}
style={{
padding: '8px 16px',
marginRight: '10px',
background: activeFilter === filter ? '#007bff' : '#e0e0e0',
color: activeFilter === filter ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textTransform: 'capitalize',
}}
>
{filter}
</button>
))}
</div>
);
}
// ✅ TodoList
function TodoList({ todos, onToggleTodo, onDeleteTodo }) {
if (todos.length === 0) {
return <p style={{ color: '#666' }}>No todos to show</p>;
}
return (
<div>
{todos.map((todo) => (
<div
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px',
marginBottom: '8px',
background: '#f8f9fa',
borderRadius: '4px',
}}
>
<input
type='checkbox'
checked={todo.completed}
onChange={() => onToggleTodo(todo.id)}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#000',
}}
>
{todo.text}
</span>
<button
onClick={() => onDeleteTodo(todo.id)}
style={{
padding: '5px 10px',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Delete
</button>
</div>
))}
</div>
);
}
// ✅ TodoStats
function TodoStats({ total, active, completed }) {
return (
<div
style={{
marginTop: '20px',
padding: '15px',
background: '#e3f2fd',
borderRadius: '4px',
}}
>
<h3>Statistics</h3>
<p>Total: {total}</p>
<p>Active: {active}</p>
<p>Completed: {completed}</p>
</div>
);
}
// ✅ TodoApp - Parent that owns all state
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build a project', completed: false },
]);
const [filter, setFilter] = useState('all');
// ✅ Handler: Add todo
const handleAddTodo = (text) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
};
setTodos((prev) => [...prev, newTodo]);
};
// ✅ Handler: Toggle todo
const handleToggleTodo = (id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
);
};
// ✅ Handler: Delete todo
const handleDeleteTodo = (id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
// ✅ Derived: Filtered todos
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true; // 'all'
});
// ✅ Derived: Stats
const stats = {
total: todos.length,
active: todos.filter((t) => !t.completed).length,
completed: todos.filter((t) => t.completed).length,
};
return (
<div
style={{
maxWidth: '600px',
margin: '0 auto',
padding: '20px',
fontFamily: 'system-ui',
}}
>
<h1>📝 Todo App</h1>
<AddTodoForm onAddTodo={handleAddTodo} />
<FilterButtons
activeFilter={filter}
onFilterChange={setFilter}
/>
<TodoList
todos={filteredTodos}
onToggleTodo={handleToggleTodo}
onDeleteTodo={handleDeleteTodo}
/>
<TodoStats {...stats} />
</div>
);
}Architecture Decisions:
- State in Parent: todos + filter
- Local State in Child: AddTodoForm input (doesn't need sharing)
- Derived State: filteredTodos, stats (computed, not stored)
- 4 Siblings: All controlled by parent via props/callbacks
⭐⭐⭐ Exercise 3: Multi-Select List with Actions (40 phút)
/**
* 🎯 Mục tiêu: Complex state sharing với selection
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là admin, tôi muốn select nhiều users để thực hiện bulk actions"
*
* ✅ Acceptance Criteria:
* - [ ] User list với checkboxes
* - [ ] "Select All" checkbox
* - [ ] Selection count hiển thị
* - [ ] Bulk actions: Delete Selected, Mark as Active/Inactive
* - [ ] Actions chỉ available khi có selection
*
* Components:
* - UserListItem: Single user với checkbox
* - UserList: List of users
* - SelectionControls: Select All + count
* - BulkActions: Action buttons
* - UserManagementApp: Parent orchestrator
*
* 🎨 Technical Constraints:
* - selectedIds tracked in parent (array of IDs)
* - Users data in parent
* - All mutations through parent handlers
*
* 🚨 Edge Cases:
* - Select all / deselect all
* - Delete selected removes from selection
* - Individual checkbox toggle
*/
// Sample data
const INITIAL_USERS = [
{ id: 1, name: 'Alice', email: 'alice@example.com', isActive: true },
{ id: 2, name: 'Bob', email: 'bob@example.com', isActive: false },
{ id: 3, name: 'Charlie', email: 'charlie@example.com', isActive: true },
{ id: 4, name: 'David', email: 'david@example.com', isActive: false },
];
// ✅ NHIỆM VỤ CỦA BẠN:
function UserListItem({ user, isSelected, onToggleSelect }) {
// TODO: Render user với checkbox
return <div>User Item</div>;
}
function UserList({ users, selectedIds, onToggleSelect }) {
// TODO: Render list of UserListItem
return <div>User List</div>;
}
function SelectionControls({
totalUsers,
selectedCount,
onSelectAll,
onDeselectAll,
}) {
// TODO: Select All checkbox + count display
return <div>Selection Controls</div>;
}
function BulkActions({
selectedCount,
onDelete,
onMarkActive,
onMarkInactive,
}) {
// TODO: Action buttons (disabled if no selection)
return <div>Bulk Actions</div>;
}
function UserManagementApp() {
// TODO: State design
// - users array
// - selectedIds array
// TODO: Handlers
// - handleToggleSelect(id)
// - handleSelectAll()
// - handleDeselectAll()
// - handleDeleteSelected()
// - handleMarkActive()
// - handleMarkInactive()
return (
<div>
<h1>User Management</h1>
{/* TODO: Compose components */}
</div>
);
}💡 Full Solution
const INITIAL_USERS = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', isActive: true },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', isActive: false },
{
id: 3,
name: 'Charlie Brown',
email: 'charlie@example.com',
isActive: true,
},
{ id: 4, name: 'David Lee', email: 'david@example.com', isActive: false },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', isActive: true },
];
// ✅ UserListItem
function UserListItem({ user, isSelected, onToggleSelect }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px',
marginBottom: '8px',
background: isSelected ? '#e3f2fd' : 'white',
border: `2px solid ${isSelected ? '#007bff' : '#ddd'}`,
borderRadius: '4px',
}}
>
<input
type='checkbox'
checked={isSelected}
onChange={() => onToggleSelect(user.id)}
style={{
marginRight: '15px',
width: '18px',
height: '18px',
cursor: 'pointer',
}}
/>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 5px 0' }}>{user.name}</h4>
<p style={{ margin: 0, color: '#666', fontSize: '0.9em' }}>
{user.email}
</p>
</div>
<span
style={{
padding: '4px 12px',
background: user.isActive ? '#28a745' : '#6c757d',
color: 'white',
borderRadius: '12px',
fontSize: '0.85em',
}}
>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</div>
);
}
// ✅ UserList
function UserList({ users, selectedIds, onToggleSelect }) {
return (
<div style={{ marginBottom: '20px' }}>
{users.map((user) => (
<UserListItem
key={user.id}
user={user}
isSelected={selectedIds.includes(user.id)}
onToggleSelect={onToggleSelect}
/>
))}
</div>
);
}
// ✅ SelectionControls
function SelectionControls({
totalUsers,
selectedCount,
onSelectAll,
onDeselectAll,
}) {
const allSelected = selectedCount === totalUsers && totalUsers > 0;
return (
<div
style={{
marginBottom: '20px',
padding: '15px',
background: '#f8f9fa',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
<label
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<input
type='checkbox'
checked={allSelected}
onChange={allSelected ? onDeselectAll : onSelectAll}
style={{
marginRight: '8px',
width: '18px',
height: '18px',
cursor: 'pointer',
}}
/>
<span style={{ fontWeight: 'bold' }}>Select All</span>
</label>
<span style={{ color: '#666' }}>
{selectedCount} of {totalUsers} selected
</span>
</div>
{selectedCount > 0 && (
<button
onClick={onDeselectAll}
style={{
padding: '6px 12px',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Clear Selection
</button>
)}
</div>
);
}
// ✅ BulkActions
function BulkActions({
selectedCount,
onDelete,
onMarkActive,
onMarkInactive,
}) {
const disabled = selectedCount === 0;
const buttonStyle = (color) => ({
padding: '10px 20px',
background: disabled ? '#ccc' : color,
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: disabled ? 'not-allowed' : 'pointer',
marginRight: '10px',
opacity: disabled ? 0.6 : 1,
});
return (
<div style={{ marginBottom: '20px' }}>
<h3>Bulk Actions {selectedCount > 0 && `(${selectedCount} selected)`}</h3>
<div>
<button
onClick={onDelete}
disabled={disabled}
style={buttonStyle('#dc3545')}
>
🗑️ Delete Selected
</button>
<button
onClick={onMarkActive}
disabled={disabled}
style={buttonStyle('#28a745')}
>
✅ Mark as Active
</button>
<button
onClick={onMarkInactive}
disabled={disabled}
style={buttonStyle('#6c757d')}
>
❌ Mark as Inactive
</button>
</div>
</div>
);
}
// ✅ UserManagementApp - Parent orchestrator
function UserManagementApp() {
const [users, setUsers] = useState(INITIAL_USERS);
const [selectedIds, setSelectedIds] = useState([]);
// ✅ Toggle single selection
const handleToggleSelect = (id) => {
setSelectedIds((prev) => {
if (prev.includes(id)) {
return prev.filter((selectedId) => selectedId !== id);
} else {
return [...prev, id];
}
});
};
// ✅ Select all
const handleSelectAll = () => {
setSelectedIds(users.map((user) => user.id));
};
// ✅ Deselect all
const handleDeselectAll = () => {
setSelectedIds([]);
};
// ✅ Delete selected
const handleDeleteSelected = () => {
if (window.confirm(`Delete ${selectedIds.length} users?`)) {
setUsers((prev) => prev.filter((user) => !selectedIds.includes(user.id)));
setSelectedIds([]);
}
};
// ✅ Mark selected as active
const handleMarkActive = () => {
setUsers((prev) =>
prev.map((user) =>
selectedIds.includes(user.id) ? { ...user, isActive: true } : user,
),
);
setSelectedIds([]);
};
// ✅ Mark selected as inactive
const handleMarkInactive = () => {
setUsers((prev) =>
prev.map((user) =>
selectedIds.includes(user.id) ? { ...user, isActive: false } : user,
),
);
setSelectedIds([]);
};
return (
<div
style={{
maxWidth: '800px',
margin: '0 auto',
padding: '20px',
fontFamily: 'system-ui',
}}
>
<h1>👥 User Management</h1>
<SelectionControls
totalUsers={users.length}
selectedCount={selectedIds.length}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<BulkActions
selectedCount={selectedIds.length}
onDelete={handleDeleteSelected}
onMarkActive={handleMarkActive}
onMarkInactive={handleMarkInactive}
/>
<UserList
users={users}
selectedIds={selectedIds}
onToggleSelect={handleToggleSelect}
/>
{users.length === 0 && (
<p style={{ textAlign: 'center', color: '#666', padding: '40px' }}>
No users available
</p>
)}
{/* Debug */}
<details style={{ marginTop: '30px' }}>
<summary>🔍 Debug Info</summary>
<div style={{ fontSize: '0.9em' }}>
<p>
<strong>Total Users:</strong> {users.length}
</p>
<p>
<strong>Selected IDs:</strong> [{selectedIds.join(', ')}]
</p>
<pre
style={{ background: '#f5f5f5', padding: '10px', overflow: 'auto' }}
>
{JSON.stringify(users, null, 2)}
</pre>
</div>
</details>
</div>
);
}Key Patterns:
- Selection state lifted to parent:
selectedIdsarray - Toggle logic in parent: Add/remove from selectedIds
- Bulk operations: Filter/map users based on selectedIds
- Clear selection after action: Good UX
- Confirmation for destructive actions: Delete prompt
⭐⭐⭐⭐ Exercise 4: Accordion with Controlled Expansion (60 phút)
/**
* 🎯 Mục tiêu: Implement accordion với controlled expansion state
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Requirements:
* - Accordion với multiple panels
* - Modes: single (only 1 open) vs multiple (many open)
* - Expand/collapse animations (CSS transition)
* - Expand All / Collapse All buttons
* - Keyboard navigation (Enter to toggle)
*
* Design Questions:
* 1. State structure: array of open IDs vs object?
* 2. Where to track mode (single vs multiple)?
* 3. How to prevent multiple open in single mode?
* 4. How to handle Expand All in single mode?
*
* 💻 PHASE 2: Implementation (30 phút)
* 🧪 PHASE 3: Testing (10 phút)
*/
const FAQ_DATA = [
{
id: 1,
question: 'What is lifting state up?',
answer:
'Lifting state up means moving state to the closest common ancestor when multiple components need to share that state.',
},
{
id: 2,
question: 'When should I lift state?',
answer:
'Lift state when two or more sibling components need to access or modify the same data.',
},
{
id: 3,
question: 'What are the trade-offs?',
answer:
'Lifting state can cause more components to re-render, but it enables proper data sharing and maintains a single source of truth.',
},
];
// Implement AccordionPanel and Accordion components!💡 Hint & Starter Code
/**
* State Design Recommendation:
* - openIds: array of panel IDs that are currently open
* - mode: 'single' or 'multiple'
*
* Single mode: openIds.length <= 1 (enforce in toggle logic)
* Multiple mode: openIds can have any length
*/
function AccordionPanel({ id, question, answer, isOpen, onToggle }) {
// TODO: Implement panel with expand/collapse
}
function Accordion() {
// TODO: State for openIds and mode
// TODO: Handlers for toggle, expandAll, collapseAll
// TODO: Render panels
}💡 Solution
/**
* AccordionPanel - một panel đơn trong accordion
* @param {Object} props
* @param {number} props.id - ID duy nhất của panel
* @param {string} props.question - Tiêu đề câu hỏi
* @param {string} props.answer - Nội dung trả lời
* @param {boolean} props.isOpen - Trạng thái mở/đóng
* @param {Function} props.onToggle - Callback khi click để toggle
*/
function AccordionPanel({ id, question, answer, isOpen, onToggle }) {
return (
<div
style={{
border: '1px solid #ddd',
borderRadius: '6px',
marginBottom: '12px',
overflow: 'hidden',
}}
>
<button
onClick={() => onToggle(id)}
style={{
width: '100%',
padding: '16px 20px',
textAlign: 'left',
background: isOpen ? '#f0f7ff' : '#f8f9fa',
border: 'none',
fontSize: '1.1rem',
fontWeight: isOpen ? '600' : '500',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background 0.2s',
}}
>
<span>{question}</span>
<span
style={{
fontSize: '1.4rem',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.3s',
}}
>
▼
</span>
</button>
<div
style={{
maxHeight: isOpen ? '500px' : '0',
overflow: 'hidden',
transition: 'max-height 0.4s ease-out, padding 0.4s ease-out',
padding: isOpen ? '0 20px 20px' : '0 20px',
background: 'white',
}}
>
<p style={{ margin: '16px 0 0 0', lineHeight: '1.6' }}>{answer}</p>
</div>
</div>
);
}
/**
* Accordion - component chính điều khiển nhiều panel
* Hỗ trợ hai chế độ: single (chỉ mở 1) và multiple (mở nhiều cùng lúc)
*/
function Accordion() {
const [openIds, setOpenIds] = React.useState([]);
const [mode, setMode] = React.useState('single'); // 'single' hoặc 'multiple'
const togglePanel = (id) => {
setOpenIds((prev) => {
if (prev.includes(id)) {
// Đóng panel đang mở
return prev.filter((panelId) => panelId !== id);
} else {
// Mở panel
if (mode === 'single') {
// Chỉ cho phép mở 1 panel
return [id];
}
// Multiple mode: thêm vào danh sách
return [...prev, id];
}
});
};
const expandAll = () => {
if (mode === 'single') {
// Trong single mode, expand all sẽ mở panel đầu tiên
setOpenIds([FAQ_DATA[0]?.id]);
} else {
setOpenIds(FAQ_DATA.map((item) => item.id));
}
};
const collapseAll = () => {
setOpenIds([]);
};
const isAllExpanded =
openIds.length === FAQ_DATA.length && FAQ_DATA.length > 0;
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1>FAQ Accordion</h1>
<div
style={{
marginBottom: '24px',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
}}
>
<div>
<strong>Mode: </strong>
<select
value={mode}
onChange={(e) => {
setMode(e.target.value);
// Khi chuyển sang single mode → chỉ giữ lại 1 panel (hoặc đóng hết)
if (e.target.value === 'single' && openIds.length > 1) {
setOpenIds(openIds.slice(0, 1));
}
}}
style={{ padding: '6px 10px', fontSize: '1rem' }}
>
<option value='single'>Single (chỉ 1 mở)</option>
<option value='multiple'>Multiple (mở nhiều)</option>
</select>
</div>
<button
onClick={expandAll}
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
Expand All
</button>
<button
onClick={collapseAll}
style={{ padding: '8px 16px', cursor: 'pointer' }}
>
Collapse All
</button>
<span style={{ alignSelf: 'center', color: '#555' }}>
{openIds.length} / {FAQ_DATA.length} mở
</span>
</div>
{FAQ_DATA.map((item) => (
<AccordionPanel
key={item.id}
id={item.id}
question={item.question}
answer={item.answer}
isOpen={openIds.includes(item.id)}
onToggle={togglePanel}
/>
))}
</div>
);
}
// Dữ liệu mẫu (đã có trong đề bài)
const FAQ_DATA = [
{
id: 1,
question: 'What is lifting state up?',
answer:
'Lifting state up means moving state to the closest common ancestor when multiple components need to share that state.',
},
{
id: 2,
question: 'When should I lift state?',
answer:
'Lift state when two or more sibling components need to access or modify the same data.',
},
{
id: 3,
question: 'What are the trade-offs?',
answer:
'Lifting state can cause more components to re-render, but it enables proper data sharing and maintains a single source of truth.',
},
];
// Để chạy thử: <Accordion />Kết quả ví dụ:
- Chế độ single: chỉ có tối đa 1 panel mở cùng lúc
- Chế độ multiple: có thể mở tất cả các panel
- Nút Expand All / Collapse All hoạt động theo mode hiện tại
- Có animation mượt khi mở/đóng (dùng max-height + transition)
- Hiển thị số lượng panel đang mở
- Chuyển mode tự động điều chỉnh trạng thái hợp lý
⭐⭐⭐⭐⭐ Exercise 5: Kanban Board (90 phút)
/**
* 🎯 Mục tiêu: Production-ready Kanban board với drag simulation
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* - 3 columns: Todo, In Progress, Done
* - Add task to any column
* - Move tasks between columns (buttons - no actual drag & drop)
* - Delete tasks
* - Task count per column
* - Filter: show all columns or single column
*
* 🏗️ Technical Design:
* 1. Tasks array với column property
* 2. Columns config (id, title, color)
* 3. Move task = update column property
* 4. All operations through parent
*
* Components:
* - Task: Single task card với move buttons
* - Column: Task container với add form
* - Board: Parent orchestrator
* - Stats: Summary statistics
*
* ✅ Production Checklist:
* - [ ] State structure documented
* - [ ] Immutable updates
* - [ ] Proper error handling (empty task text)
* - [ ] Keyboard shortcuts (optional)
* - [ ] Responsive layout
*/
const COLUMNS = [
{ id: 'todo', title: 'To Do', color: '#6c757d' },
{ id: 'inprogress', title: 'In Progress', color: '#ffc107' },
{ id: 'done', title: 'Done', color: '#28a745' },
];
// Implement the Kanban board!💡 Solution
/**
* Task - Single task card hiển thị thông tin task và nút di chuyển
* @param {Object} props
* @param {Object} props.task - Object task {id, title, description?, column}
* @param {Function} props.onMove - Callback khi di chuyển task (taskId, newColumn)
* @param {Function} props.onDelete - Callback khi xóa task (taskId)
*/
function Task({ task, onMove, onDelete }) {
const columns = [
{ id: 'todo', title: 'To Do' },
{ id: 'inprogress', title: 'In Progress' },
{ id: 'done', title: 'Done' },
];
const otherColumns = columns.filter((c) => c.id !== task.column);
return (
<div
style={{
background: 'white',
border: '1px solid #ddd',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.08)',
}}
>
<h4 style={{ margin: '0 0 8px 0', fontSize: '1.1rem' }}>{task.title}</h4>
{task.description && (
<p style={{ margin: '0 0 12px 0', color: '#555', fontSize: '0.95rem' }}>
{task.description}
</p>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{otherColumns.map((col) => (
<button
key={col.id}
onClick={() => onMove(task.id, col.id)}
style={{
padding: '6px 12px',
fontSize: '0.85rem',
background: '#f0f0f0',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
}}
>
→ {col.title}
</button>
))}
<button
onClick={() => onDelete(task.id)}
style={{
padding: '6px 12px',
fontSize: '0.85rem',
background: '#ffebee',
color: '#c62828',
border: '1px solid #ef9a9a',
borderRadius: '4px',
cursor: 'pointer',
marginLeft: 'auto',
}}
>
Delete
</button>
</div>
</div>
);
}
/**
* Column - Một cột trong Kanban board
* @param {Object} props
* @param {string} props.id - id của column ('todo' | 'inprogress' | 'done')
* @param {string} props.title - Tiêu đề hiển thị
* @param {string} props.color - Màu chủ đạo
* @param {Array} props.tasks - Danh sách tasks thuộc column này
* @param {Function} props.onMove - Callback di chuyển task
* @param {Function} props.onDelete - Callback xóa task
* @param {Function} props.onAddTask - Callback thêm task mới vào column
*/
function Column({ id, title, color, tasks, onMove, onDelete, onAddTask }) {
const [newTitle, setNewTitle] = React.useState('');
const handleAdd = (e) => {
e.preventDefault();
if (newTitle.trim()) {
onAddTask(id, newTitle.trim());
setNewTitle('');
}
};
return (
<div
style={{
background: '#f8f9fa',
borderRadius: '8px',
padding: '16px',
flex: '1',
minWidth: '280px',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
background: color,
color: 'white',
padding: '12px 16px',
borderRadius: '6px',
marginBottom: '16px',
fontWeight: 'bold',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>{title}</span>
<span style={{ fontSize: '0.9rem', opacity: 0.9 }}>{tasks.length}</span>
</div>
<div style={{ flex: 1, minHeight: '200px' }}>
{tasks.map((task) => (
<Task
key={task.id}
task={task}
onMove={onMove}
onDelete={onDelete}
/>
))}
</div>
<form
onSubmit={handleAdd}
style={{ marginTop: '16px' }}
>
<input
type='text'
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder='Add a task...'
style={{
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '6px',
marginBottom: '8px',
fontSize: '0.95rem',
}}
/>
<button
type='submit'
disabled={!newTitle.trim()}
style={{
width: '100%',
padding: '10px',
background: color,
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: newTitle.trim() ? 'pointer' : 'not-allowed',
opacity: newTitle.trim() ? 1 : 0.6,
}}
>
+ Add Task
</button>
</form>
</div>
);
}
/**
* Kanban Board chính - điều phối toàn bộ state và logic
*/
function KanbanBoard() {
const COLUMNS = [
{ id: 'todo', title: 'To Do', color: '#6c757d' },
{ id: 'inprogress', title: 'In Progress', color: '#ffc107' },
{ id: 'done', title: 'Done', color: '#28a745' },
];
const [tasks, setTasks] = React.useState([
{ id: 1, title: 'Setup project structure', column: 'todo' },
{ id: 2, title: 'Design database schema', column: 'todo' },
{ id: 3, title: 'Implement authentication', column: 'inprogress' },
{ id: 4, title: 'Create landing page', column: 'done' },
]);
const [filter, setFilter] = React.useState('all'); // 'all' | columnId
const addTask = (columnId, title) => {
const newTask = {
id: Date.now(),
title,
column: columnId,
};
setTasks((prev) => [...prev, newTask]);
};
const moveTask = (taskId, newColumn) => {
setTasks((prev) =>
prev.map((task) =>
task.id === taskId ? { ...task, column: newColumn } : task,
),
);
};
const deleteTask = (taskId) => {
if (window.confirm('Delete this task?')) {
setTasks((prev) => prev.filter((t) => t.id !== taskId));
}
};
// Nhóm tasks theo column
const tasksByColumn = COLUMNS.reduce((acc, col) => {
acc[col.id] = tasks.filter((t) => t.column === col.id);
return acc;
}, {});
// Lọc theo filter
const visibleColumns =
filter === 'all' ? COLUMNS : COLUMNS.filter((c) => c.id === filter);
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h1 style={{ marginTop: 0 }}>Kanban Board</h1>
<div
style={{
marginBottom: '24px',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
}}
>
<button
onClick={() => setFilter('all')}
style={{
padding: '8px 16px',
background: filter === 'all' ? '#007bff' : '#e9ecef',
color: filter === 'all' ? 'white' : '#333',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Show All Columns
</button>
{COLUMNS.map((col) => (
<button
key={col.id}
onClick={() => setFilter(col.id)}
style={{
padding: '8px 16px',
background: filter === col.id ? col.color : '#e9ecef',
color: filter === col.id ? 'white' : '#333',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
{col.title} only
</button>
))}
</div>
<div
style={{
display: 'flex',
gap: '20px',
overflowX: 'auto',
paddingBottom: '16px',
}}
>
{visibleColumns.map((col) => (
<Column
key={col.id}
id={col.id}
title={col.title}
color={col.color}
tasks={tasksByColumn[col.id]}
onMove={moveTask}
onDelete={deleteTask}
onAddTask={addTask}
/>
))}
</div>
{/* Debug info */}
<details style={{ marginTop: '40px' }}>
<summary>Debug: {tasks.length} tasks total</summary>
<pre
style={{
background: '#f5f5f5',
padding: '12px',
borderRadius: '6px',
fontSize: '0.9rem',
}}
>
{JSON.stringify(tasks, null, 2)}
</pre>
</details>
</div>
);
}
// Để chạy: <KanbanBoard />Kết quả ví dụ:
- 3 cột: To Do, In Progress, Done với màu sắc phân biệt
- Mỗi cột hiển thị số task hiện tại
- Thêm task mới ngay trong cột (form inline)
- Di chuyển task giữa các cột bằng nút →
- Xóa task với confirm dialog
- Filter xem tất cả hoặc chỉ 1 cột
- Responsive ngang (scroll ngang trên mobile)
- State được quản lý tập trung ở KanbanBoard (lifting state up)
📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh Trade-offs
| Pattern | Ưu điểm ✅ | Nhược điểm ❌ | Khi nào dùng 🎯 |
|---|---|---|---|
| Lift to Parent | • Chia sẻ state giữa các component siblings • Một nguồn dữ liệu duy nhất • Dễ debug hơn | • Truyền nhiều props hơn • Parent re-render khi state thay đổi • Props drilling | • Các siblings cần chia sẻ dữ liệu • Cách tiếp cận mặc định |
| Keep State Local | • Code đơn giản hơn • Ít re-render hơn • Hiệu năng tốt hơn | • Không chia sẻ được • Có thể bị lặp logic | • State chỉ dùng cho component • Không cần chia sẻ • Buffer input form |
| Callbacks Up, Data Down | • Luồng dữ liệu rõ ràng • Dễ dự đoán • Dễ lần theo | • Dài dòng khi có nhiều callback • Props drilling | • Pattern React tiêu chuẩn • Luôn dùng cho việc cập nhật state |
| Derived State | • Luôn đồng bộ • Không trùng lặp dữ liệu • Ít bug | • Tính toán lại mỗi lần render • Có thể tốn kém hiệu năng | • Danh sách đã filter • Giá trị được tính toán • Ưu tiên hơn việc lưu state |
| Lift to Grandparent | • Chia sẻ cho nhiều component hơn | • Props drilling sâu • Nhiều component trung gian • Khó bảo trì | • ❌ Tránh nếu có thể • Dùng Context thay thế (sẽ học sau) |
Decision Tree
Q1: Có component nào khác cần dữ liệu này không?
├─ NO → Giữ state local trong component
└─ YES → Tiếp tục Q2
Q2: Các component có cùng parent không?
├─ YES → Lift state lên parent trực tiếp
└─ NO → Tiếp tục Q3
Q3: Có closest common ancestor đủ gần không?
├─ YES → Lift state lên closest common ancestor
└─ NO → Cân nhắc Context API (Ngày 29) hoặc global state (Ngày 10+)
Q4: Dữ liệu có thể derive từ state khác không?
├─ YES → ĐỪNG lưu state! Tính toán nó
└─ NO → Lưu vào state
Q5: Props drilling có quá sâu không (>3 levels)?
├─ YES → Cân nhắc:
│ - Component composition
│ - Context API
│ - Thư viện quản lý state
└─ NO → Props drilling chấp nhận được
NGUYÊN TẮC VÀNG:
✅ State càng gần nơi sử dụng càng tốt
✅ Chỉ lift khi cần chia sẻ
✅ Derived tốt hơn Stored
✅ Dùng callback cho cập nhật, props cho dữ liệu🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: State Not Sharing ⭐
// 🐛 BUG: ComponentB không nhận được data từ ComponentA
function ComponentA() {
const [data, setData] = useState('Hello');
return (
<div>
<p>Component A: {data}</p>
<button onClick={() => setData('Updated!')}>Update</button>
</div>
);
}
function ComponentB() {
return (
<div>
<p>Component B: ??? {/* Muốn hiển thị data từ A */}</p>
</div>
);
}
function App() {
return (
<div>
<ComponentA />
<ComponentB />
</div>
);
}
/**
* 🔍 DEBUG QUESTIONS:
* 1. Tại sao ComponentB không thấy data?
* 2. State nên di chuyển đến đâu?
* 3. Fix như thế nào?
*/💡 Solution
Vấn đề:
- State trong ComponentA → chỉ ComponentA access được
- ComponentB là sibling → không thể access state của ComponentA
- Cần lift state up to parent (App)
Fix:
// ✅ Lift state to parent
function App() {
const [data, setData] = useState('Hello');
return (
<div>
<ComponentA
data={data}
onUpdate={setData}
/>
<ComponentB data={data} />
</div>
);
}
function ComponentA({ data, onUpdate }) {
return (
<div>
<p>Component A: {data}</p>
<button onClick={() => onUpdate('Updated!')}>Update</button>
</div>
);
}
function ComponentB({ data }) {
return (
<div>
<p>Component B: {data}</p>
</div>
);
}Bài học: Các component siblings không thể chia sẻ state trực tiếp. Hãy lift lên parent chung!
Bug 2: Child Can't Update Parent ⭐⭐
// 🐛 BUG: Counter update không work
function Counter({ count }) {
// BUG: Child trying to update parent state directly
const increment = () => {
count = count + 1; // ❌ Won't work!
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
</div>
);
}
function App() {
const [count, setCount] = useState(0);
return <Counter count={count} />;
}
/**
* 🔍 DEBUG QUESTIONS:
* 1. Tại sao button click không update count?
* 2. `count = count + 1` có vấn đề gì?
* 3. Child cần gì từ parent?
*/💡 Solution
Vấn đề:
countlà prop (read-only)- Reassigning prop không trigger re-render
- Child không thể directly update parent state
- Cần callback từ parent!
Fix:
// ✅ Parent provides callback
function App() {
const [count, setCount] = useState(0);
return (
<Counter
count={count}
onIncrement={() => setCount((prev) => prev + 1)} // ✅ Callback!
/>
);
}
// ✅ Child calls callback
function Counter({ count, onIncrement }) {
return (
<div>
<p>Count: {count}</p>
<button onClick={onIncrement}>+1</button>
</div>
);
}Bài học: Props truyền xuống, callback truyền lên! Child giao tiếp thông qua callback.
Bug 3: Lift Quá Cao ⭐⭐⭐
// 🐛 VẤN ĐỀ HIỆU NĂNG: Mọi thứ bị re-render không cần thiết
function App() {
const [searchTerm, setSearchTerm] = useState('');
const [users, setUsers] = useState([
/* 1000 users */
]);
const filteredUsers = users.filter((u) =>
u.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div>
<Header /> {/* Re-render mỗi lần gõ phím! */}
<Sidebar /> {/* Re-render mỗi lần gõ phím! */}
<SearchBar
searchTerm={searchTerm}
onChange={setSearchTerm}
/>
<UserList users={filteredUsers} />
<Footer /> {/* Re-render mỗi lần gõ phím! */}
</div>
);
}🔍 DEBUG QUESTIONS:
- Tại sao Header/Sidebar/Footer re-render khi gõ search?
- Component nào thực sự cần searchTerm?
- Làm thế nào optimize?
💡 Solution
Vấn đề:
searchTermstate in App- Mỗi keystroke → App re-render → ALL children re-render
- Header/Sidebar/Footer không cần searchTerm → unnecessary re-renders
Cách sửa 1: Di chuyển state xuống dưới
// ✅ Tạo component SearchSection
function SearchSection() {
const [searchTerm, setSearchTerm] = useState('');
const [users] = useState([
/* users */
]);
const filteredUsers = users.filter((u) =>
u.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<>
<SearchBar
searchTerm={searchTerm}
onChange={setSearchTerm}
/>
<UserList users={filteredUsers} />
</>
);
}
function App() {
return (
<div>
<Header /> {/* ✅ Không bị re-render */}
<Sidebar /> {/* ✅ Không bị re-render */}
<SearchSection />
<Footer /> {/* ✅ Không bị re-render */}
</div>
);
}Cách sửa 2: Dùng React.memo (sẽ học ở Ngày 23)
// Ngăn re-render không cần thiết
const Header = React.memo(() => <header>Header</header>);Bài học:
- Chỉ lift state cao tới mức CẦN THIẾT
- State càng thấp = ít re-render = hiệu năng tốt hơn
- Vị trí đặt state ảnh hưởng trực tiếp tới hiệu năng!
✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
- [ ] Tôi hiểu khi nào cần lift state up
- [ ] Tôi biết cách identify closest common ancestor
- [ ] Tôi có thể implement inverse data flow (callbacks)
- [ ] Tôi hiểu "props down, callbacks up" pattern
- [ ] Tôi nhận biết được props drilling
- [ ] Tôi biết khi nào state nên local vs lifted
- [ ] Tôi có thể refactor isolated state thành shared state
- [ ] Tôi hiểu trade-offs của lifting state
- [ ] Tôi biết cách avoid lifting quá cao
- [ ] Tôi có thể design component hierarchy với state placement tối ưu
Code Review Checklist
Khi review code về state management:
State Placement:
- [ ] State ở level thấp nhất có thể
- [ ] Chỉ lift khi CẦN share
- [ ] Không lift quá cao (performance)
- [ ] Local state cho component-specific data
Data Flow:
- [ ] Data flows down via props
- [ ] Events flow up via callbacks
- [ ] Single source of truth
- [ ] No duplicate state
Component Design:
- [ ] Parent owns shared state
- [ ] Children are controlled components
- [ ] Clear props interface
- [ ] Derived state computed, not stored
Performance:
- [ ] Minimal re-renders
- [ ] State not higher than needed
- [ ] Consider memoization if many re-renders
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Exercise: Refactor Isolated State
Cho code với state isolated. Refactor thành shared state:
// Start: Each component has its own count
function CounterA() {
const [count, setCount] = useState(0);
return (
<div>
A: {count} <button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
function CounterB() {
const [count, setCount] = useState(0);
return (
<div>
B: {count} <button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
// Goal: Share count between A and B, show total💡 Solution
/**
* Counter - Component hiển thị và điều khiển một counter
* Được thiết kế controlled: không giữ state riêng, nhận giá trị và callbacks từ parent
* @param {Object} props
* @param {number} props.count - Giá trị hiện tại của counter
* @param {Function} props.onIncrement - Callback tăng giá trị
* @param {Function} props.onDecrement - Callback giảm giá trị
*/
function Counter({ count, onIncrement, onDecrement }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '16px',
background: '#f8f9fa',
borderRadius: '8px',
margin: '12px 0',
}}
>
<button
onClick={onDecrement}
style={{
padding: '10px 18px',
fontSize: '1.2rem',
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
-
</button>
<span
style={{
fontSize: '1.8rem',
fontWeight: 'bold',
minWidth: '60px',
textAlign: 'center',
}}
>
{count}
</span>
<button
onClick={onIncrement}
style={{
padding: '10px 18px',
fontSize: '1.2rem',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
+
</button>
</div>
);
}
/**
* App - Parent component quản lý shared state giữa hai counters
* Lifting state up để cả hai Counter dùng chung một giá trị count
*/
function App() {
const [count, setCount] = React.useState(0);
const handleIncrement = () => {
setCount((prev) => prev + 1);
};
const handleDecrement = () => {
setCount((prev) => prev - 1);
};
return (
<div style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
<h1>Shared Counter Demo</h1>
<p style={{ fontSize: '1.2rem', marginBottom: '24px' }}>
Total count: <strong>{count}</strong> (được chia sẻ giữa cả hai counter)
</p>
<div>
<h3>Counter A</h3>
<Counter
count={count}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
/>
</div>
<div>
<h3>Counter B</h3>
<Counter
count={count}
onIncrement={handleIncrement}
onDecrement={handleDecrement}
/>
</div>
<button
onClick={() => setCount(0)}
style={{
marginTop: '24px',
padding: '12px 24px',
fontSize: '1rem',
background: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Reset to 0
</button>
</div>
);
}
// Để chạy: <App />Kết quả ví dụ:
- Cả Counter A và Counter B đều hiển thị cùng một giá trị count
- Bấm + hoặc - ở bất kỳ counter nào cũng làm thay đổi giá trị ở cả hai
- Total count ở trên cùng luôn đồng bộ
- Nút Reset đưa cả hai counter về 0
- State được lift lên App → đảm bảo single source of truth, không còn trạng thái riêng lẻ bị lệch nhau
Nâng cao (60 phút)
Exercise: Movie Watchlist Manager
Tạo app quản lý watchlist với:
Features:
- Movie list với buttons "Add to Watchlist" / "Mark as Watched"
- Separate tabs: All Movies / Watchlist / Watched
- Statistics: Total movies, watchlist count, watched count
- Filter by genre
- Search by title
Components:
- MovieCard
- MovieList
- Tabs
- SearchBar
- GenreFilter
- Stats
- MovieApp (parent)
Requirements:
- ✅ Proper state lifting
- ✅ All operations through parent
- ✅ Derived state for filtered lists
- ✅ No duplicate state
💡 Solution
/**
* MovieCard - Hiển thị thông tin một bộ phim với các nút hành động
* @param {Object} props
* @param {Object} props.movie - Thông tin phim {id, title, genre, year?}
* @param {boolean} props.inWatchlist - Có trong watchlist không
* @param {boolean} props.watched - Đã xem chưa
* @param {Function} props.onAddToWatchlist - Thêm vào watchlist
* @param {Function} props.onMarkWatched - Đánh dấu đã xem
* @param {Function} props.onRemoveFromWatchlist - Xóa khỏi watchlist
*/
function MovieCard({
movie,
inWatchlist,
watched,
onAddToWatchlist,
onMarkWatched,
onRemoveFromWatchlist,
}) {
return (
<div
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '16px',
marginBottom: '16px',
background: watched ? '#e8f5e9' : inWatchlist ? '#fff3e0' : 'white',
transition: 'all 0.2s',
}}
>
<h3 style={{ margin: '0 0 8px 0' }}>{movie.title}</h3>
<p style={{ margin: '0 0 12px 0', color: '#555' }}>
{movie.genre} • {movie.year || 'N/A'}
</p>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{!inWatchlist && !watched && (
<button
onClick={() => onAddToWatchlist(movie.id)}
style={{
padding: '8px 16px',
background: '#1976d2',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
+ Watchlist
</button>
)}
{inWatchlist && !watched && (
<>
<button
onClick={() => onMarkWatched(movie.id)}
style={{
padding: '8px 16px',
background: '#388e3c',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
✓ Watched
</button>
<button
onClick={() => onRemoveFromWatchlist(movie.id)}
style={{
padding: '8px 16px',
background: '#d32f2f',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Remove
</button>
</>
)}
{watched && (
<button
onClick={() => onRemoveFromWatchlist(movie.id)}
style={{
padding: '8px 16px',
background: '#757575',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Remove from Watched
</button>
)}
</div>
</div>
);
}
/**
* MovieList - Hiển thị danh sách phim (tất cả / watchlist / watched)
*/
function MovieList({
movies,
watchlist,
watched,
onAddToWatchlist,
onMarkWatched,
onRemoveFromWatchlist,
}) {
if (movies.length === 0) {
return (
<p style={{ color: '#777', textAlign: 'center', padding: '40px 0' }}>
No movies found
</p>
);
}
return (
<div>
{movies.map((movie) => (
<MovieCard
key={movie.id}
movie={movie}
inWatchlist={watchlist.includes(movie.id)}
watched={watched.includes(movie.id)}
onAddToWatchlist={onAddToWatchlist}
onMarkWatched={onMarkWatched}
onRemoveFromWatchlist={onRemoveFromWatchlist}
/>
))}
</div>
);
}
/**
* Tabs - Chuyển đổi giữa các view: All / Watchlist / Watched
*/
function Tabs({ activeTab, onTabChange }) {
const tabs = [
{ id: 'all', label: 'All Movies' },
{ id: 'watchlist', label: 'Watchlist' },
{ id: 'watched', label: 'Watched' },
];
return (
<div
style={{
marginBottom: '24px',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
style={{
padding: '10px 20px',
background: activeTab === tab.id ? '#1976d2' : '#e0e0e0',
color: activeTab === tab.id ? 'white' : '#333',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: activeTab === tab.id ? 'bold' : 'normal',
}}
>
{tab.label}
</button>
))}
</div>
);
}
/**
* SearchBar + Genre Filter
*/
function Filters({ search, onSearchChange, genre, onGenreChange, genres }) {
return (
<div
style={{
marginBottom: '24px',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
}}
>
<input
type='text'
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder='Search by title...'
style={{
flex: 1,
minWidth: '220px',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '6px',
fontSize: '1rem',
}}
/>
<select
value={genre}
onChange={(e) => onGenreChange(e.target.value)}
style={{
padding: '10px',
border: '1px solid #ccc',
borderRadius: '6px',
minWidth: '160px',
}}
>
<option value='all'>All Genres</option>
{genres.map((g) => (
<option
key={g}
value={g}
>
{g}
</option>
))}
</select>
</div>
);
}
/**
* Stats - Thống kê tổng quan
*/
function Stats({ total, inWatchlist, watched }) {
return (
<div
style={{
background: '#f5f5f5',
padding: '16px',
borderRadius: '8px',
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-around',
flexWrap: 'wrap',
gap: '16px',
}}
>
<div style={{ textAlign: 'center' }}>
<strong style={{ fontSize: '1.6rem' }}>{total}</strong>
<p style={{ margin: '4px 0 0', color: '#555' }}>Total Movies</p>
</div>
<div style={{ textAlign: 'center' }}>
<strong style={{ fontSize: '1.6rem', color: '#1976d2' }}>
{inWatchlist}
</strong>
<p style={{ margin: '4px 0 0', color: '#555' }}>In Watchlist</p>
</div>
<div style={{ textAlign: 'center' }}>
<strong style={{ fontSize: '1.6rem', color: '#388e3c' }}>
{watched}
</strong>
<p style={{ margin: '4px 0 0', color: '#555' }}>Watched</p>
</div>
</div>
);
}
/**
* MovieWatchlistManager - Component chính quản lý toàn bộ state
*/
function MovieWatchlistManager() {
const allMovies = [
{ id: 1, title: 'Dune: Part Two', genre: 'Sci-Fi', year: 2024 },
{ id: 2, title: 'Oppenheimer', genre: 'Biography', year: 2023 },
{
id: 3,
title: 'Everything Everywhere All at Once',
genre: 'Comedy',
year: 2022,
},
{ id: 4, title: 'Parasite', genre: 'Thriller', year: 2019 },
{ id: 5, title: 'Inception', genre: 'Sci-Fi', year: 2010 },
{ id: 6, title: 'The Shawshank Redemption', genre: 'Drama', year: 1994 },
{ id: 7, title: 'Interstellar', genre: 'Sci-Fi', year: 2014 },
{ id: 8, title: 'Whiplash', genre: 'Drama', year: 2014 },
];
const [watchlist, setWatchlist] = React.useState([]);
const [watched, setWatched] = React.useState([]);
const [tab, setTab] = React.useState('all');
const [search, setSearch] = React.useState('');
const [genre, setGenre] = React.useState('all');
const genres = [...new Set(allMovies.map((m) => m.genre))];
// Handlers
const addToWatchlist = (movieId) => {
if (!watchlist.includes(movieId) && !watched.includes(movieId)) {
setWatchlist((prev) => [...prev, movieId]);
}
};
const markAsWatched = (movieId) => {
setWatched((prev) => [...prev, movieId]);
setWatchlist((prev) => prev.filter((id) => id !== movieId));
};
const removeFromList = (movieId) => {
setWatchlist((prev) => prev.filter((id) => id !== movieId));
setWatched((prev) => prev.filter((id) => id !== movieId));
};
// Filtered & searched movies
const displayedMovies = allMovies.filter((movie) => {
const matchesSearch = movie.title
.toLowerCase()
.includes(search.toLowerCase());
const matchesGenre = genre === 'all' || movie.genre === genre;
return matchesSearch && matchesGenre;
});
const visibleMovies =
tab === 'all'
? displayedMovies
: tab === 'watchlist'
? displayedMovies.filter((m) => watchlist.includes(m.id))
: displayedMovies.filter((m) => watched.includes(m.id));
const stats = {
total: allMovies.length,
inWatchlist: watchlist.length,
watched: watched.length,
};
return (
<div style={{ maxWidth: '900px', margin: '0 auto', padding: '24px' }}>
<h1>Movie Watchlist Manager</h1>
<Stats {...stats} />
<Filters
search={search}
onSearchChange={setSearch}
genre={genre}
onGenreChange={setGenre}
genres={genres}
/>
<Tabs
activeTab={tab}
onTabChange={setTab}
/>
<MovieList
movies={visibleMovies}
watchlist={watchlist}
watched={watched}
onAddToWatchlist={addToWatchlist}
onMarkWatched={markAsWatched}
onRemoveFromWatchlist={removeFromList}
/>
</div>
);
}Kết quả ví dụ:
- Tab All Movies: hiển thị toàn bộ danh sách, có thể search + lọc theo thể loại
- Tab Watchlist: chỉ phim đã thêm vào danh sách muốn xem
- Tab Watched: chỉ phim đã đánh dấu đã xem
- Màu nền card thay đổi theo trạng thái (xanh nhạt → đã xem, cam nhạt → trong watchlist)
- Thống kê realtime: tổng phim, số phim trong watchlist, số phim đã xem
- State được lift lên component cha → đảm bảo đồng bộ và single source of truth
- Không có trạng thái trùng lặp, mọi thay đổi đều thông qua parent callbacks
📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - Sharing State Between Components
- https://react.dev/learn/sharing-state-between-components
- Chính thức từ React team
React Docs - Passing Data Deeply with Context
- https://react.dev/learn/passing-data-deeply-with-context
- Preview alternative to props drilling (sẽ học Ngày 29)
Đọc thêm
Thinking in React
- https://react.dev/learn/thinking-in-react
- How to design component hierarchy
Component Composition vs Inheritance
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (đã học)
- Ngày 13: Forms với State - Controlled components
- Ngày 12: useState Patterns - Immutability, functional updates
- Ngày 4: Props - Data flow parent → child
Hướng tới (sẽ học)
- Ngày 15: Project 2 - Todo App (apply lifting state)
- Ngày 29: Context API - Solution cho deep props drilling
- Ngày 30: useReducer - Complex state logic alternative
💡 SENIOR INSIGHTS
Cân Nhắc Production
When to Lift vs Context:
// ✅ Lift state: 1-2 levels
Parent → Child → Grandchild (OK)
// ⚠️ Props drilling: 3+ levels
Parent → A → B → C → D (Consider Context)
// Rule of Thumb:
// - Lift for shallow trees
// - Context for deep trees
// - State management for global statePerformance Considerations:
// ⚠️ Expensive re-renders
function App() {
const [search, setSearch] = useState('');
return (
<>
<ExpensiveComponent /> {/* Re-renders on search change! */}
<SearchBar
search={search}
onChange={setSearch}
/>
</>
);
}
// ✅ Optimize với composition
function App() {
return (
<>
<ExpensiveComponent /> {/* Doesn't re-render */}
<SearchSection /> {/* search state local here */}
</>
);
}Câu Hỏi Phỏng Vấn
Junior: Q: "Lifting state up là gì?" A: Di chuyển state từ child lên parent khi nhiều components cần share state đó. Parent pass state down via props và nhận updates qua callbacks.
Mid: Q: "Khi nào nên lift state và khi nào không?" A: Lift khi: (1) Siblings cần share data, (2) Parent cần control child state. Không lift khi: (1) Chỉ 1 component dùng, (2) Component-specific UI state (hover, focus).
Senior: Q: "Design state architecture cho feature X. Justify placement decisions." A: Phải analyze:
- Component hierarchy (siblings? parent-child?)
- Data flow requirements (who reads? who writes?)
- Re-render impact (will lifting cause unnecessary renders?)
- Scalability (will this grow?)
- Document: State location + rationale + trade-offs
War Stories
Story 1: The Props Drilling Nightmare
Một project lift state lên App component cho "easier sharing". Kết quả: 8 levels props drilling, mỗi component pass 10+ props. Refactoring nightmare! Lesson: Chỉ lift đến closest common ancestor, không cao hơn. Consider Context nếu > 3 levels.
Story 2: The Performance Bug
Form input lag 200ms mỗi keystroke. Root cause: Form state lifted to App, app có 50 child components không liên quan nhưng re-render mỗi keystroke. Fix: Move form state down to FormSection component. Performance từ 200ms → <1ms. Lesson: State placement = performance!
Story 3: The Duplicate State Sync Issue
Bug: Filtered list và stats không sync. Code store cả filteredData và stats in state, manually sync. Miss 1 chỗ update = out of sync. Fix: Chỉ store raw data + filters, derive filtered list và stats. Luôn sync vì computed. Lesson: Derived state > Duplicate state!
🎯 PREVIEW NGÀY MAI
Ngày 15: Project 2 - Interactive Todo App
Hôm nay đã master state sharing. Ngày mai sẽ áp dụng vào full project:
- Todo app với multiple components
- Lift state up trong thực tế
- Filter, search, stats - all derived
- Production-ready architecture
- Review code với senior mindset
Hôm nay: Theory + Patterns ✅
Ngày mai: Real project application 🎯
🎊 CHÚC MỪNG! Bạn đã hoàn thành Ngày 14!
Hôm nay bạn đã master:
- ✅ Lifting State Up concept
- ✅ Props down, Callbacks up pattern
- ✅ Closest common ancestor principle
- ✅ State placement decisions
- ✅ Avoiding props drilling
- ✅ Performance implications
Lifting State Up là foundation của React architecture!
Mọi app phức tạp đều dựa trên principle này. Master nó = master React data flow!
💪 Tomorrow: Put it all together in a real project!