📅 NGÀY 27: useReducer Advanced Patterns - Normalize State & Action Creators
📍 Vị trí: Phase 3, Tuần 6, Ngày 27/45
⏱️ Thời lượng: 3-4 giờ
🎯 Mục tiêu học tập (5 phút)
Sau bài học này, bạn sẽ:
- [ ] Thiết kế được normalized state structure cho complex data
- [ ] Sử dụng được action creators và action type constants
- [ ] Tổ chức được large reducers với reducer composition
- [ ] Xử lý được deeply nested state updates immutably
- [ ] Quyết định được khi nào normalize vs nest state structure
🤔 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:
Vấn đề gì xảy ra khi update nested object trong reducer?
- Gợi ý:
state.user.profile.settings.theme = 'dark'- sao sai?
- Gợi ý:
Tại sao action type nên là constant thay vì string literal?
- Gợi ý:
dispatch({ type: 'INCREMET' })- lỗi gì?
- Gợi ý:
Bạn đã gặp trường hợp reducer quá dài (>200 lines) chưa?
- Làm sao organize code tốt hơn?
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Hãy tưởng tượng bạn đang build Social Media App với state sau:
// ❌ VẤN ĐỀ: Deeply Nested State
const initialState = {
users: [
{
id: 1,
name: 'Alice',
posts: [
{
id: 101,
title: 'Hello World',
comments: [
{ id: 1001, text: 'Nice post!', author: { id: 2, name: 'Bob' } },
{ id: 1002, text: 'Thanks!', author: { id: 1, name: 'Alice' } },
],
},
],
},
{
id: 2,
name: 'Bob',
posts: [...],
},
],
};Thử update 1 comment:
// 😱 NIGHTMARE: Deeply nested immutable update
function reducer(state, action) {
switch (action.type) {
case 'EDIT_COMMENT':
return {
...state,
users: state.users.map((user) =>
user.id === action.userId
? {
...user,
posts: user.posts.map((post) =>
post.id === action.postId
? {
...post,
comments: post.comments.map((comment) =>
comment.id === action.commentId
? { ...comment, text: action.text }
: comment,
),
}
: post,
),
}
: user,
),
};
}
}Vấn đề:
- 🔴 Nested hell - 4 levels deep!
- 🔴 Performance - Clone entire tree mỗi lần update 1 comment
- 🔴 Error-prone - Dễ quên spread operator
- 🔴 Hard to read - Không ai muốn maintain code này
- 🔴 Duplication - Data duplicated (author object)
1.2 Giải Pháp: State Normalization
Normalized State = Flat structure, data referenced by IDs
// ✅ GIẢI PHÁP: Normalized State
const normalizedState = {
users: {
1: { id: 1, name: 'Alice', postIds: [101] },
2: { id: 2, name: 'Bob', postIds: [102] },
},
posts: {
101: {
id: 101,
title: 'Hello World',
authorId: 1,
commentIds: [1001, 1002],
},
102: { id: 102, title: 'My post', authorId: 2, commentIds: [] },
},
comments: {
1001: { id: 1001, text: 'Nice post!', authorId: 2, postId: 101 },
1002: { id: 1002, text: 'Thanks!', authorId: 1, postId: 101 },
},
};Bây giờ update comment:
// ✅ SIMPLE: Flat update
function reducer(state, action) {
switch (action.type) {
case 'EDIT_COMMENT':
return {
...state,
comments: {
...state.comments,
[action.commentId]: {
...state.comments[action.commentId],
text: action.text,
},
},
};
}
}Lợi ích:
- ✅ Flat structure - Không có nested hell
- ✅ Fast updates - Chỉ clone relevant parts
- ✅ No duplication - Reference by ID
- ✅ Easy queries - Direct access:
state.comments[id]
1.3 Mental Model
Nested State:
┌─────────────────────────────────────┐
│ DATABASE (Nested) │
│ │
│ User { │
│ name: "Alice" │
│ posts: [ │
│ { │
│ title: "Hello" │
│ comments: [ │
│ { text: "Nice!" } ← Update này
│ ] │
│ } │
│ ] │
│ } │
│ │
│ → Phải clone: User → Posts → Comments
│ → Slow, error-prone │
└─────────────────────────────────────┘Normalized State:
┌─────────────────────────────────────┐
│ DATABASE (Normalized) │
│ │
│ users: { 1: { name: "Alice" } } │
│ │
│ posts: { 101: { title: "Hello" } } │
│ │
│ comments: { │
│ 1001: { text: "Nice!" } ← Update │
│ } │
│ │
│ → Chỉ clone: comments object │
│ → Fast, predictable │
└─────────────────────────────────────┘Analogy: Normalized state giống Database tables với foreign keys
- Users table → có user IDs
- Posts table → có post IDs + authorId (foreign key)
- Comments table → có comment IDs + authorId, postId
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Normalization luôn tốt hơn nesting"
- ✅ Sự thật: Trade-off! Normalized = fast updates, nhưng queries phức tạp hơn
❌ Hiểu lầm 2: "Phải normalize mọi thứ"
- ✅ Sự thật: Chỉ normalize khi cần. Simple nested data (2 levels) OK!
❌ Hiểu lầm 3: "Action creators là boilerplate không cần thiết"
- ✅ Sự thật: Action creators prevent typos, easy refactoring, type-safe
❌ Hiểu lầm 4: "Reducer composition phức tạp"
- ✅ Sự thật: Pattern đơn giản, giúp code maintainable hơn
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Action Type Constants & Action Creators ⭐
// 🎯 PATTERN 1: Action Type Constants
// ❌ CÁCH SAI: Magic strings
function TodoApp() {
const [state, dispatch] = useReducer(reducer, initialState);
// 🐛 Typo: 'ADD_TDOO' → Silent fail!
dispatch({ type: 'ADD_TDOO', payload: { text: 'Learn React' } });
}
// ✅ CÁCH ĐÚNG: Constants
// 1️⃣ Định nghĩa constants
const ActionTypes = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO',
SET_FILTER: 'SET_FILTER',
};
// 2️⃣ Dùng trong reducer
function todoReducer(state, action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
return {
...state,
todos: [...state.todos, action.payload],
};
case ActionTypes.TOGGLE_TODO:
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo,
),
};
case ActionTypes.DELETE_TODO:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
case ActionTypes.SET_FILTER:
return {
...state,
filter: action.payload.filter,
};
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 3️⃣ Dùng trong component
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// ✅ Autocomplete, no typos!
dispatch({ type: ActionTypes.ADD_TODO, payload: { text: 'Learn React' } });
// ↑ IDE sẽ suggest
}
// ------------------------------------------------------------------
// 🎯 PATTERN 2: Action Creators
// ❌ CÁCH SAI: Inline action objects
function TodoApp() {
const handleAddTodo = (text) => {
// 🐛 Dễ quên field, typo key names
dispatch({
type: ActionTypes.ADD_TODO,
payload: {
id: Date.now(),
text: text,
completed: false,
createdAt: new Date().toISOString(),
},
});
};
// Duplicate logic everywhere!
const handleAddAnotherTodo = (text) => {
dispatch({
type: ActionTypes.ADD_TODO,
payload: {
id: Date.now(), // Same logic
text: text,
completed: false,
createdAt: new Date().toISOString(),
},
});
};
}
// ✅ CÁCH ĐÚNG: Action Creators
// 1️⃣ Tạo action creator functions
const actionCreators = {
addTodo: (text) => ({
type: ActionTypes.ADD_TODO,
payload: {
id: Date.now(),
text,
completed: false,
createdAt: new Date().toISOString(),
},
}),
toggleTodo: (id) => ({
type: ActionTypes.TOGGLE_TODO,
payload: { id },
}),
deleteTodo: (id) => ({
type: ActionTypes.DELETE_TODO,
payload: { id },
}),
setFilter: (filter) => ({
type: ActionTypes.SET_FILTER,
payload: { filter },
}),
};
// 2️⃣ Dùng trong component
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const handleAddTodo = (text) => {
// ✅ Clean, consistent, no duplication
dispatch(actionCreators.addTodo(text));
};
const handleToggle = (id) => {
dispatch(actionCreators.toggleTodo(id));
};
const handleDelete = (id) => {
dispatch(actionCreators.deleteTodo(id));
};
return (
<div>
{/* ... */}
<button onClick={() => handleAddTodo('New todo')}>Add Todo</button>
{state.todos.map((todo) => (
<div key={todo.id}>
<input
type='checkbox'
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => handleDelete(todo.id)}>Delete</button>
</div>
))}
</div>
);
}🎯 Lợi ích:
Action Type Constants:
- ✅ Autocomplete trong IDE
- ✅ Typo = compile error (với TS) hoặc runtime error ngay
- ✅ Easy refactoring (rename 1 chỗ)
Action Creators:
- ✅ Centralized action logic
- ✅ Consistent payload structure
- ✅ Easy to test
- ✅ Reusable across components
Demo 2: State Normalization - Blog App ⭐⭐
// 🎯 KỊCH BẢN: Blog với Users, Posts, Comments
// ❌ NESTED STATE (Anti-pattern cho large data)
const nestedState = {
users: [
{
id: 1,
name: 'Alice',
posts: [
{
id: 101,
title: 'First Post',
comments: [
{ id: 1001, text: 'Great!', author: { id: 2, name: 'Bob' } },
],
},
],
},
],
};
// ✅ NORMALIZED STATE
const normalizedState = {
entities: {
users: {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' },
},
posts: {
101: { id: 101, title: 'First Post', authorId: 1 },
102: { id: 102, title: 'Second Post', authorId: 2 },
},
comments: {
1001: { id: 1001, text: 'Great!', authorId: 2, postId: 101 },
1002: { id: 1002, text: 'Nice!', authorId: 1, postId: 102 },
},
},
// IDs arrays cho ordering
allUserIds: [1, 2],
allPostIds: [101, 102],
postCommentIds: {
101: [1001],
102: [1002],
},
};
// ------------------------------------------------------------------
// 🏗️ ACTION TYPES
const ActionTypes = {
ADD_POST: 'ADD_POST',
EDIT_POST: 'EDIT_POST',
DELETE_POST: 'DELETE_POST',
ADD_COMMENT: 'ADD_COMMENT',
EDIT_COMMENT: 'EDIT_COMMENT',
DELETE_COMMENT: 'DELETE_COMMENT',
};
// 🏭 ACTION CREATORS
const actions = {
addPost: (authorId, title, content) => ({
type: ActionTypes.ADD_POST,
payload: {
id: Date.now(),
authorId,
title,
content,
createdAt: new Date().toISOString(),
},
}),
editPost: (postId, updates) => ({
type: ActionTypes.EDIT_POST,
payload: { postId, updates },
}),
deletePost: (postId) => ({
type: ActionTypes.DELETE_POST,
payload: { postId },
}),
addComment: (postId, authorId, text) => ({
type: ActionTypes.ADD_COMMENT,
payload: {
id: Date.now(),
postId,
authorId,
text,
createdAt: new Date().toISOString(),
},
}),
editComment: (commentId, text) => ({
type: ActionTypes.EDIT_COMMENT,
payload: { commentId, text },
}),
deleteComment: (commentId, postId) => ({
type: ActionTypes.DELETE_COMMENT,
payload: { commentId, postId },
}),
};
// 🔧 REDUCER
function blogReducer(state, action) {
switch (action.type) {
case ActionTypes.ADD_POST: {
const { id, authorId, title, content, createdAt } = action.payload;
return {
...state,
entities: {
...state.entities,
posts: {
...state.entities.posts,
[id]: { id, authorId, title, content, createdAt },
},
},
allPostIds: [...state.allPostIds, id],
postCommentIds: {
...state.postCommentIds,
[id]: [],
},
};
}
case ActionTypes.EDIT_POST: {
const { postId, updates } = action.payload;
return {
...state,
entities: {
...state.entities,
posts: {
...state.entities.posts,
[postId]: {
...state.entities.posts[postId],
...updates,
updatedAt: new Date().toISOString(),
},
},
},
};
}
case ActionTypes.DELETE_POST: {
const { postId } = action.payload;
// 1️⃣ Remove post
const { [postId]: deletedPost, ...remainingPosts } = state.entities.posts;
// 2️⃣ Remove from allPostIds
const newAllPostIds = state.allPostIds.filter((id) => id !== postId);
// 3️⃣ Remove associated comments
const commentIdsToDelete = state.postCommentIds[postId] || [];
const newComments = { ...state.entities.comments };
commentIdsToDelete.forEach((commentId) => {
delete newComments[commentId];
});
// 4️⃣ Remove postCommentIds entry
const { [postId]: deletedCommentIds, ...remainingPostCommentIds } =
state.postCommentIds;
return {
...state,
entities: {
...state.entities,
posts: remainingPosts,
comments: newComments,
},
allPostIds: newAllPostIds,
postCommentIds: remainingPostCommentIds,
};
}
case ActionTypes.ADD_COMMENT: {
const { id, postId, authorId, text, createdAt } = action.payload;
return {
...state,
entities: {
...state.entities,
comments: {
...state.entities.comments,
[id]: { id, postId, authorId, text, createdAt },
},
},
postCommentIds: {
...state.postCommentIds,
[postId]: [...(state.postCommentIds[postId] || []), id],
},
};
}
case ActionTypes.EDIT_COMMENT: {
const { commentId, text } = action.payload;
return {
...state,
entities: {
...state.entities,
comments: {
...state.entities.comments,
[commentId]: {
...state.entities.comments[commentId],
text,
updatedAt: new Date().toISOString(),
},
},
},
};
}
case ActionTypes.DELETE_COMMENT: {
const { commentId, postId } = action.payload;
// Remove comment from entities
const { [commentId]: deleted, ...remainingComments } =
state.entities.comments;
// Remove from postCommentIds
const newPostCommentIds = state.postCommentIds[postId].filter(
(id) => id !== commentId,
);
return {
...state,
entities: {
...state.entities,
comments: remainingComments,
},
postCommentIds: {
...state.postCommentIds,
[postId]: newPostCommentIds,
},
};
}
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// 🎯 COMPONENT với Denormalization
function BlogApp() {
const initialState = {
entities: {
users: {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' },
},
posts: {},
comments: {},
},
allUserIds: [1, 2],
allPostIds: [],
postCommentIds: {},
};
const [state, dispatch] = useReducer(blogReducer, initialState);
// ✅ Helper: Denormalize data for display
const getPostWithAuthor = (postId) => {
const post = state.entities.posts[postId];
if (!post) return null;
const author = state.entities.users[post.authorId];
return {
...post,
author,
};
};
const getCommentsForPost = (postId) => {
const commentIds = state.postCommentIds[postId] || [];
return commentIds.map((commentId) => {
const comment = state.entities.comments[commentId];
const author = state.entities.users[comment.authorId];
return {
...comment,
author,
};
});
};
const handleAddPost = (authorId, title, content) => {
dispatch(actions.addPost(authorId, title, content));
};
const handleAddComment = (postId, authorId, text) => {
dispatch(actions.addComment(postId, authorId, text));
};
return (
<div>
<h1>Blog Posts</h1>
{/* Add Post Form */}
<button onClick={() => handleAddPost(1, 'New Post', 'Content here')}>
Add Post
</button>
{/* Posts List */}
{state.allPostIds.map((postId) => {
const post = getPostWithAuthor(postId);
const comments = getCommentsForPost(postId);
return (
<article key={postId}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
<p>{post.content}</p>
{/* Comments */}
<div>
<h3>Comments ({comments.length})</h3>
{comments.map((comment) => (
<div key={comment.id}>
<strong>{comment.author.name}:</strong> {comment.text}
<button
onClick={() =>
dispatch(actions.editComment(comment.id, 'Updated!'))
}
>
Edit
</button>
<button
onClick={() =>
dispatch(actions.deleteComment(comment.id, postId))
}
>
Delete
</button>
</div>
))}
<button onClick={() => handleAddComment(postId, 2, 'Nice post!')}>
Add Comment
</button>
</div>
</article>
);
})}
</div>
);
}🎯 Key Takeaways:
Normalized benefits:
- ✅ Edit comment: O(1) access, không cần traverse tree
- ✅ Delete post: Xóa post + cascade delete comments
- ✅ No data duplication (author referenced by ID)
Trade-off:
- ➖ Phải denormalize khi display (helper functions)
- ➕ But update operations fast & simple
Demo 3: Reducer Composition ⭐⭐⭐
// 🎯 PATTERN: Split large reducer thành smaller reducers
// ❌ VẤN ĐỀ: Reducer quá lớn (300+ lines)
function monolithicReducer(state, action) {
switch (action.type) {
case 'USER_LOGIN': /* ... */
case 'USER_LOGOUT': /* ... */
case 'UPDATE_PROFILE': /* ... */
case 'ADD_POST': /* ... */
case 'EDIT_POST': /* ... */
case 'DELETE_POST': /* ... */
case 'ADD_COMMENT': /* ... */
case 'EDIT_COMMENT': /* ... */
case 'DELETE_COMMENT': /* ... */
case 'SET_THEME': /* ... */
case 'SET_LANGUAGE': /* ... */
// ... 50 more cases
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// ✅ GIẢI PHÁP: Reducer Composition
// 1️⃣ Split theo domain (users, posts, comments, settings)
// Users Reducer
function usersReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.USER_LOGIN:
return {
...state,
[action.payload.id]: action.payload,
};
case ActionTypes.USER_LOGOUT:
const { [action.payload.id]: removed, ...remaining } = state;
return remaining;
case ActionTypes.UPDATE_PROFILE:
return {
...state,
[action.payload.id]: {
...state[action.payload.id],
...action.payload.updates,
},
};
default:
return state;
}
}
// Posts Reducer
function postsReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.ADD_POST:
return {
...state,
[action.payload.id]: action.payload,
};
case ActionTypes.EDIT_POST:
return {
...state,
[action.payload.postId]: {
...state[action.payload.postId],
...action.payload.updates,
},
};
case ActionTypes.DELETE_POST:
const { [action.payload.postId]: deleted, ...remaining } = state;
return remaining;
default:
return state;
}
}
// Comments Reducer
function commentsReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.ADD_COMMENT:
return {
...state,
[action.payload.id]: action.payload,
};
case ActionTypes.EDIT_COMMENT:
return {
...state,
[action.payload.commentId]: {
...state[action.payload.commentId],
text: action.payload.text,
},
};
case ActionTypes.DELETE_COMMENT:
const { [action.payload.commentId]: deleted, ...remaining } = state;
return remaining;
// ✅ Cross-domain logic: Delete post → Delete comments
case ActionTypes.DELETE_POST: {
const commentIdsToDelete = action.payload.commentIds || [];
const newState = { ...state };
commentIdsToDelete.forEach((id) => {
delete newState[id];
});
return newState;
}
default:
return state;
}
}
// Settings Reducer
function settingsReducer(state = { theme: 'light', language: 'en' }, action) {
switch (action.type) {
case ActionTypes.SET_THEME:
return {
...state,
theme: action.payload.theme,
};
case ActionTypes.SET_LANGUAGE:
return {
...state,
language: action.payload.language,
};
default:
return state;
}
}
// 2️⃣ Combine reducers manually (NO Redux!)
function rootReducer(state, action) {
return {
users: usersReducer(state.users, action),
posts: postsReducer(state.posts, action),
comments: commentsReducer(state.comments, action),
settings: settingsReducer(state.settings, action),
};
}
// 3️⃣ Sử dụng
function App() {
const initialState = {
users: {},
posts: {},
comments: {},
settings: { theme: 'light', language: 'en' },
};
const [state, dispatch] = useReducer(rootReducer, initialState);
// ✅ Now dispatch works with composed reducer
dispatch(actions.addPost(1, 'Title', 'Content'));
dispatch(actions.setTheme('dark'));
return (
<div>
<p>Theme: {state.settings.theme}</p>
<p>Posts: {Object.keys(state.posts).length}</p>
</div>
);
}🎯 Lợi ích Reducer Composition:
Maintainability:
- ✅ Mỗi reducer < 100 lines
- ✅ Easy to find logic (users logic → usersReducer)
- ✅ Test mỗi reducer independently
Separation of Concerns:
- ✅ Each domain isolated
- ✅ Clear boundaries
Reusability:
- ✅ Có thể reuse sub-reducers
- ✅ Share reducers across projects
🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Level 1: Áp Dụng Concept (15 phút)
/**
* 🎯 Mục tiêu: Convert magic strings → constants + action creators
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: Redux, Context
*
* Requirements:
* 1. Tạo ActionTypes object với constants
* 2. Tạo action creators cho tất cả actions
* 3. Refactor component dùng action creators
*
* 💡 Gợi ý:
* - ActionTypes = { INCREMENT: 'INCREMENT', ... }
* - actionCreators = { increment: () => ({ type: ... }), ... }
*/
// ❌ CODE HIỆN TẠI: Magic strings everywhere
function Counter() {
const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'INCREMENT_BY':
return { count: state.count + action.payload };
case 'RESET':
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<h1>{state.count}</h1>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'INCREMENT_BY', payload: 5 })}>
+5
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
// 🎯 NHIỆM VỤ: Refactor với constants + action creators
// TODO: Viết ActionTypes
// TODO: Viết action creators
// TODO: Update component/**
* Counter Component with Action Types & Action Creators
* @returns {JSX.Element}
*/
function Counter() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
INCREMENT: 'INCREMENT',
DECREMENT: 'DECREMENT',
INCREMENT_BY: 'INCREMENT_BY',
RESET: 'RESET',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
increment: () => ({
type: ActionTypes.INCREMENT,
}),
decrement: () => ({
type: ActionTypes.DECREMENT,
}),
incrementBy: (amount) => ({
type: ActionTypes.INCREMENT_BY,
payload: amount,
}),
reset: () => ({
type: ActionTypes.RESET,
}),
};
// ── Reducer ──────────────────────────────────────────────────────
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.INCREMENT:
return { count: state.count + 1 };
case ActionTypes.DECREMENT:
return { count: state.count - 1 };
case ActionTypes.INCREMENT_BY:
return { count: state.count + action.payload };
case ActionTypes.RESET:
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
const [state, dispatch] = React.useReducer(reducer, { count: 0 });
return (
<div>
<h1>{state.count}</h1>
<button onClick={() => dispatch(actions.increment())}>+1</button>
<button onClick={() => dispatch(actions.decrement())}>-1</button>
<button onClick={() => dispatch(actions.incrementBy(5))}>+5</button>
<button onClick={() => dispatch(actions.reset())}>Reset</button>
</div>
);
}
/*
Kết quả ví dụ:
Ban đầu: 0
Nhấn +1 → 1
Nhấn +5 → 6
Nhấn -1 → 5
Nhấn Reset → 0
*/💡 Solution
// Đáp án đầy đủ (đã bao gồm cả phần trên)
const ActionTypes = {
INCREMENT: 'INCREMENT',
DECREMENT: 'DECREMENT',
INCREMENT_BY: 'INCREMENT_BY',
RESET: 'RESET',
};
const actions = {
increment: () => ({ type: ActionTypes.INCREMENT }),
decrement: () => ({ type: ActionTypes.DECREMENT }),
incrementBy: (amount) => ({
type: ActionTypes.INCREMENT_BY,
payload: amount,
}),
reset: () => ({ type: ActionTypes.RESET }),
};
function reducer(state, action) {
switch (action.type) {
case ActionTypes.INCREMENT:
return { count: state.count + 1 };
case ActionTypes.DECREMENT:
return { count: state.count - 1 };
case ActionTypes.INCREMENT_BY:
return { count: state.count + action.payload };
case ActionTypes.RESET:
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Trong component:
const [state, dispatch] = useReducer(reducer, { count: 0 });
// Sử dụng:
<button onClick={() => dispatch(actions.increment())}>+1</button>
<button onClick={() => dispatch(actions.decrement())}>-1</button>
<button onClick={() => dispatch(actions.incrementBy(5))}>+5</button>
<button onClick={() => dispatch(actions.reset())}>Reset</button>⭐⭐ Level 2: Nhận Biết Pattern (25 phút)
/**
* 🎯 Mục tiêu: Quyết định Nested vs Normalized State
* ⏱️ Thời gian: 25 phút
*
* Scenario: E-commerce Shopping Cart
*
* Products catalog:
* - 100+ products
* - Each product có categories, reviews
* - User có thể add product to cart
* - Cart hiển thị product details (name, price, image)
*
* 🤔 PHÂN TÍCH:
*
* Approach A: Nested State
* {
* cart: [
* {
* product: {
* id: 1,
* name: 'iPhone',
* price: 999,
* categories: [...],
* reviews: [...]
* },
* quantity: 2
* }
* ]
* }
*
* Pros:
* - Đơn giản, dễ hiểu
* - Query dễ (cart items có đầy đủ info)
*
* Cons:
* - Duplicate product data (nếu add 2 lần)
* - Update product price → phải update tất cả cart items
* - Large payload (categories, reviews không cần trong cart)
*
* Approach B: Normalized State
* {
* products: {
* 1: { id: 1, name: 'iPhone', price: 999, ... }
* },
* cart: {
* items: {
* 1: { productId: 1, quantity: 2 }
* }
* }
* }
*
* Pros:
* - No duplication
* - Update product → auto reflect in cart
* - Small cart payload
*
* Cons:
* - Denormalize khi display cart
* - More complex queries
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
* (Viết 5-7 câu, consider:)
* - Number of products (100+)
* - Update frequency (prices change?)
* - Display requirements (cart UI cần gì?)
*
* Sau đó implement approach đã chọn:
* - State shape
* - Reducer với actions: ADD_TO_CART, REMOVE_FROM_CART, UPDATE_QUANTITY
* - Component display cart
*/
// TODO: Viết analysis + implementation💡 Solution
/**
* E-commerce Shopping Cart với Normalized State
*
* Phân tích: Với 100+ products, updates thường xuyên (giá thay đổi),
* và cần hiển thị cart với info đầy đủ nhưng không muốn duplicate data,
* normalized state là lựa chọn tốt hơn nested state.
*
* Lợi ích:
* - Không duplicate product info
* - Update giá product tự động reflect trong cart
* - Cart state nhẹ (chỉ chứa productId + quantity)
* - Dễ quản lý khi remove product từ catalog
*
* @returns {JSX.Element}
*/
function ShoppingCart() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
ADD_TO_CART: 'ADD_TO_CART',
REMOVE_FROM_CART: 'REMOVE_FROM_CART',
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
addToCart: (productId, quantity = 1) => ({
type: ActionTypes.ADD_TO_CART,
payload: { productId, quantity },
}),
removeFromCart: (productId) => ({
type: ActionTypes.REMOVE_FROM_CART,
payload: { productId },
}),
updateQuantity: (productId, quantity) => ({
type: ActionTypes.UPDATE_QUANTITY,
payload: { productId, quantity },
}),
};
// ── Initial State (Normalized) ───────────────────────────────────
const initialState = {
products: {
1: { id: 1, name: 'iPhone 15', price: 999, category: 'Electronics' },
2: { id: 2, name: 'MacBook Pro', price: 2499, category: 'Electronics' },
3: { id: 3, name: 'AirPods', price: 199, category: 'Electronics' },
4: { id: 4, name: 'React Book', price: 49, category: 'Books' },
},
cart: {
items: {}, // { productId: { productId, quantity } }
},
};
// ── Reducer ──────────────────────────────────────────────────────
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.ADD_TO_CART: {
const { productId, quantity } = action.payload;
// Nếu đã có trong cart, tăng quantity, nếu chưa thì thêm mới
const currentItem = state.cart.items[productId];
const newItem = currentItem
? { ...currentItem, quantity: currentItem.quantity + quantity }
: { productId, quantity };
return {
...state,
cart: {
...state.cart,
items: {
...state.cart.items,
[productId]: newItem,
},
},
};
}
case ActionTypes.REMOVE_FROM_CART: {
const { productId } = action.payload;
const { [productId]: removed, ...remainingItems } = state.cart.items;
return {
...state,
cart: {
...state.cart,
items: remainingItems,
},
};
}
case ActionTypes.UPDATE_QUANTITY: {
const { productId, quantity } = action.payload;
if (quantity <= 0) {
// Nếu quantity <= 0 thì remove khỏi cart
const { [productId]: removed, ...remainingItems } = state.cart.items;
return {
...state,
cart: {
...state.cart,
items: remainingItems,
},
};
}
return {
...state,
cart: {
...state.cart,
items: {
...state.cart.items,
[productId]: {
...state.cart.items[productId],
quantity,
},
},
},
};
}
default:
return state;
}
};
const [state, dispatch] = React.useReducer(reducer, initialState);
// ── Helper: Lấy cart items với product details ───────────────────
const getCartItemsWithDetails = () => {
return Object.values(state.cart.items).map((cartItem) => {
const product = state.products[cartItem.productId];
return {
...cartItem,
...product, // denormalize: thêm product info vào cart item
totalPrice: product.price * cartItem.quantity,
};
});
};
const cartItems = getCartItemsWithDetails();
const total = cartItems.reduce((sum, item) => sum + item.totalPrice, 0);
return (
<div>
<h2>🛒 Shopping Cart</h2>
{cartItems.length === 0 ? (
<p>Cart is empty</p>
) : (
<>
<ul>
{cartItems.map((item) => (
<li key={item.productId}>
<strong>{item.name}</strong>
<span>
{' '}
- ${item.price} x {item.quantity}
</span>
<span> = ${item.totalPrice}</span>
<br />
<button
onClick={() =>
dispatch(
actions.updateQuantity(item.productId, item.quantity + 1),
)
}
>
+
</button>
<button
onClick={() =>
dispatch(
actions.updateQuantity(item.productId, item.quantity - 1),
)
}
>
-
</button>
<button
onClick={() =>
dispatch(actions.removeFromCart(item.productId))
}
>
Remove
</button>
</li>
))}
</ul>
<p>
<strong>Total: ${total}</strong>
</p>
</>
)}
{/* Demo: Add items */}
<div style={{ marginTop: '20px' }}>
<button onClick={() => dispatch(actions.addToCart(1))}>
Add iPhone
</button>
<button onClick={() => dispatch(actions.addToCart(2))}>
Add MacBook
</button>
<button onClick={() => dispatch(actions.addToCart(3))}>
Add AirPods
</button>
<button onClick={() => dispatch(actions.addToCart(4))}>
Add React Book
</button>
</div>
</div>
);
}
/*
Kết quả ví dụ:
1. Nhấn "Add iPhone" → Cart: iPhone (1) = $999
2. Nhấn "Add iPhone" lần nữa → Cart: iPhone (2) = $1998
3. Nhấn "Add React Book" → Cart: iPhone (2) = $1998, React Book (1) = $49
4. Nhấn "-" trên iPhone → Cart: iPhone (1) = $999, React Book (1) = $49
5. Nhấn "Remove" trên React Book → Cart: iPhone (1) = $999
6. Nhấn "-" trên iPhone → Cart: empty
*/⭐⭐⭐ Level 3: Kịch Bản Thực Tế (40 phút)
/**
* 🎯 Mục tiêu: Build Normalized State cho Messaging App
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn chat với friends trong app"
*
* ✅ Acceptance Criteria:
* - [ ] Hiển thị list conversations
* - [ ] Click conversation → hiển thị messages
* - [ ] Send message trong conversation
* - [ ] Delete message
* - [ ] Mark conversation as read/unread
*
* 🎨 State Structure (Normalized):
* {
* users: { [id]: { id, name, avatar } },
* conversations: { [id]: { id, participantIds: [], lastMessageId } },
* messages: { [id]: { id, conversationId, senderId, text, timestamp } },
* conversationMessageIds: { [conversationId]: [messageId, ...] }
* }
*
* 🚨 Edge Cases:
* - Delete message → update lastMessageId nếu là message cuối
* - Delete conversation → cascade delete messages
* - Send message → update lastMessageId trong conversation
*
* 📝 Implementation Checklist:
* - [ ] ActionTypes constants (8+ actions)
* - [ ] Action creators
* - [ ] Reducer với normalized updates
* - [ ] Helper functions (getConversationWithMessages, etc.)
* - [ ] Component display conversations + messages
*/
// TODO: Full implementation
// Gợi ý Actions:
// - ADD_CONVERSATION
// - DELETE_CONVERSATION
// - SEND_MESSAGE
// - DELETE_MESSAGE
// - MARK_READ
// - MARK_UNREAD
// Gợi ý Initial State:
const initialState = {
users: {
1: { id: 1, name: 'Alice', avatar: '👩' },
2: { id: 2, name: 'Bob', avatar: '👨' },
},
conversations: {},
messages: {},
conversationMessageIds: {},
allConversationIds: [],
};💡 Solution
/**
* Messaging App - Normalized State with Conversations & Messages
*
* Features:
* - List conversations
* - View messages in selected conversation
* - Send new message
* - Delete message (with lastMessageId update)
* - Mark conversation as read/unread
*
* @returns {JSX.Element}
*/
function MessagingApp() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
ADD_CONVERSATION: 'ADD_CONVERSATION',
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SEND_MESSAGE: 'SEND_MESSAGE',
DELETE_MESSAGE: 'DELETE_MESSAGE',
MARK_READ: 'MARK_READ',
MARK_UNREAD: 'MARK_UNREAD',
SELECT_CONVERSATION: 'SELECT_CONVERSATION',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
addConversation: (participantIds, title = '') => ({
type: ActionTypes.ADD_CONVERSATION,
payload: {
id: Date.now().toString(),
participantIds,
title,
createdAt: new Date().toISOString(),
},
}),
deleteConversation: (conversationId) => ({
type: ActionTypes.DELETE_CONVERSATION,
payload: { conversationId },
}),
sendMessage: (conversationId, senderId, text) => ({
type: ActionTypes.SEND_MESSAGE,
payload: {
id: Date.now().toString(),
conversationId,
senderId,
text,
timestamp: new Date().toISOString(),
},
}),
deleteMessage: (conversationId, messageId) => ({
type: ActionTypes.DELETE_MESSAGE,
payload: { conversationId, messageId },
}),
markRead: (conversationId) => ({
type: ActionTypes.MARK_READ,
payload: { conversationId },
}),
markUnread: (conversationId) => ({
type: ActionTypes.MARK_UNREAD,
payload: { conversationId },
}),
selectConversation: (conversationId) => ({
type: ActionTypes.SELECT_CONVERSATION,
payload: { conversationId },
}),
};
// ── Initial State ────────────────────────────────────────────────
const initialState = {
users: {
1: { id: '1', name: 'Alice', avatar: '👩' },
2: { id: '2', name: 'Bob', avatar: '👨' },
3: { id: '3', name: 'Charlie', avatar: '🧑' },
},
conversations: {}, // { convId: { id, participantIds, lastMessageId?, unread: boolean, title?, createdAt } }
messages: {}, // { msgId: { id, conversationId, senderId, text, timestamp } }
conversationMessageIds: {}, // { convId: [msgId, msgId, ...] } – ordered
selectedConversationId: null,
allConversationIds: [],
};
// ── Reducer ──────────────────────────────────────────────────────
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.ADD_CONVERSATION: {
const { id, participantIds, title, createdAt } = action.payload;
return {
...state,
conversations: {
...state.conversations,
[id]: { id, participantIds, unread: false, title, createdAt },
},
conversationMessageIds: {
...state.conversationMessageIds,
[id]: [],
},
allConversationIds: [...state.allConversationIds, id],
};
}
case ActionTypes.DELETE_CONVERSATION: {
const { conversationId } = action.payload;
const { [conversationId]: removedConv, ...restConvs } =
state.conversations;
const { [conversationId]: removedIds, ...restMsgIds } =
state.conversationMessageIds;
// Xóa tất cả messages liên quan
const msgIdsToDelete = removedIds || [];
const newMessages = { ...state.messages };
msgIdsToDelete.forEach((msgId) => delete newMessages[msgId]);
return {
...state,
conversations: restConvs,
messages: newMessages,
conversationMessageIds: restMsgIds,
allConversationIds: state.allConversationIds.filter(
(id) => id !== conversationId,
),
selectedConversationId:
state.selectedConversationId === conversationId
? null
: state.selectedConversationId,
};
}
case ActionTypes.SEND_MESSAGE: {
const { id, conversationId, senderId, text, timestamp } =
action.payload;
return {
...state,
messages: {
...state.messages,
[id]: { id, conversationId, senderId, text, timestamp },
},
conversationMessageIds: {
...state.conversationMessageIds,
[conversationId]: [
...(state.conversationMessageIds[conversationId] || []),
id,
],
},
conversations: {
...state.conversations,
[conversationId]: {
...state.conversations[conversationId],
lastMessageId: id,
unread: senderId !== '1', // giả sử current user là id '1'
},
},
};
}
case ActionTypes.DELETE_MESSAGE: {
const { conversationId, messageId } = action.payload;
const { [messageId]: removedMsg, ...restMessages } = state.messages;
const currentMsgIds =
state.conversationMessageIds[conversationId] || [];
const newMsgIds = currentMsgIds.filter((id) => id !== messageId);
let newLastMessageId =
state.conversations[conversationId]?.lastMessageId;
if (newLastMessageId === messageId) {
newLastMessageId =
newMsgIds.length > 0 ? newMsgIds[newMsgIds.length - 1] : null;
}
return {
...state,
messages: restMessages,
conversationMessageIds: {
...state.conversationMessageIds,
[conversationId]: newMsgIds,
},
conversations: {
...state.conversations,
[conversationId]: {
...state.conversations[conversationId],
lastMessageId: newLastMessageId,
},
},
};
}
case ActionTypes.MARK_READ:
case ActionTypes.MARK_UNREAD: {
const { conversationId } = action.payload;
return {
...state,
conversations: {
...state.conversations,
[conversationId]: {
...state.conversations[conversationId],
unread: action.type === ActionTypes.MARK_UNREAD,
},
},
};
}
case ActionTypes.SELECT_CONVERSATION: {
const { conversationId } = action.payload;
return {
...state,
selectedConversationId: conversationId,
// Tự động mark read khi mở conversation (tùy chọn)
conversations: {
...state.conversations,
[conversationId]: {
...state.conversations[conversationId],
unread: false,
},
},
};
}
default:
return state;
}
};
const [state, dispatch] = React.useReducer(reducer, initialState);
// ── Helpers ──────────────────────────────────────────────────────
const getConversationWithParticipants = (convId) => {
const conv = state.conversations[convId];
if (!conv) return null;
return {
...conv,
participants: conv.participantIds.map((id) => state.users[id]),
};
};
const getMessagesForConversation = (convId) => {
const msgIds = state.conversationMessageIds[convId] || [];
return msgIds.map((id) => state.messages[id]);
};
const currentConv = state.selectedConversationId
? getConversationWithParticipants(state.selectedConversationId)
: null;
const currentMessages = state.selectedConversationId
? getMessagesForConversation(state.selectedConversationId)
: [];
// ── UI ───────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', height: '80vh', fontFamily: 'system-ui' }}>
{/* Sidebar - Conversations */}
<div
style={{
width: '280px',
borderRight: '1px solid #ddd',
padding: '16px',
}}
>
<h3>Chats</h3>
<button
onClick={() =>
dispatch(actions.addConversation(['1', '2'], 'Alice & You'))
}
style={{ marginBottom: '12px' }}
>
New Chat with Alice
</button>
<button
onClick={() =>
dispatch(actions.addConversation(['1', '3'], 'Charlie Group'))
}
>
New Chat with Charlie
</button>
<ul style={{ listStyle: 'none', padding: 0, marginTop: '16px' }}>
{state.allConversationIds.map((convId) => {
const conv = getConversationWithParticipants(convId);
if (!conv) return null;
const lastMsgId = conv.lastMessageId;
const lastMsg = lastMsgId ? state.messages[lastMsgId] : null;
const lastSender = lastMsg
? state.users[lastMsg.senderId]?.name
: '';
return (
<li
key={convId}
onClick={() => dispatch(actions.selectConversation(convId))}
style={{
padding: '12px',
marginBottom: '8px',
background:
state.selectedConversationId === convId
? '#e0f2fe'
: '#f8f9fa',
borderRadius: '8px',
cursor: 'pointer',
border: conv.unread
? '1px solid #3b82f6'
: '1px solid transparent',
}}
>
<strong>
{conv.title ||
conv.participants.map((p) => p.name).join(', ')}
</strong>
{lastMsg && (
<div
style={{
fontSize: '0.9em',
color: '#666',
marginTop: '4px',
}}
>
{lastSender}: {lastMsg.text.slice(0, 40)}
{lastMsg.text.length > 40 ? '...' : ''}
</div>
)}
{conv.unread && (
<span style={{ color: '#3b82f6', fontWeight: 'bold' }}>
{' '}
•
</span>
)}
</li>
);
})}
</ul>
</div>
{/* Main Chat Area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{currentConv ? (
<>
<div
style={{
padding: '16px',
borderBottom: '1px solid #ddd',
background: '#f8f9fa',
}}
>
<h2>
{currentConv.title ||
currentConv.participants.map((p) => p.name).join(', ')}
</h2>
{currentConv.unread ? (
<button
onClick={() => dispatch(actions.markRead(currentConv.id))}
>
Mark as Read
</button>
) : (
<button
onClick={() => dispatch(actions.markUnread(currentConv.id))}
>
Mark as Unread
</button>
)}
<button
onClick={() =>
dispatch(actions.deleteConversation(currentConv.id))
}
style={{ marginLeft: '12px', color: 'red' }}
>
Delete Conversation
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{currentMessages.map((msg) => {
const isMe = msg.senderId === '1';
const sender = state.users[msg.senderId];
return (
<div
key={msg.id}
style={{
marginBottom: '16px',
textAlign: isMe ? 'right' : 'left',
display: 'flex',
flexDirection: 'column',
alignItems: isMe ? 'flex-end' : 'flex-start',
}}
>
<small style={{ color: '#666', marginBottom: '4px' }}>
{sender.name} •{' '}
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</small>
<div
style={{
maxWidth: '70%',
padding: '10px 14px',
borderRadius: '18px',
background: isMe ? '#3b82f6' : '#e5e7eb',
color: isMe ? 'white' : 'black',
position: 'relative',
}}
>
{msg.text}
<button
onClick={() =>
dispatch(
actions.deleteMessage(currentConv.id, msg.id),
)
}
style={{
fontSize: '0.7em',
marginLeft: '8px',
background: 'none',
border: 'none',
color: isMe ? '#bfdbfe' : '#9ca3af',
cursor: 'pointer',
}}
>
×
</button>
</div>
</div>
);
})}
</div>
<div
style={{
padding: '16px',
borderTop: '1px solid #ddd',
display: 'flex',
}}
>
<input
type='text'
placeholder='Type a message...'
id='messageInput'
style={{
flex: 1,
padding: '10px',
borderRadius: '20px',
border: '1px solid #ddd',
}}
/>
<button
onClick={() => {
const input = document.getElementById('messageInput');
if (input.value.trim()) {
dispatch(
actions.sendMessage(
currentConv.id,
'1', // current user
input.value.trim(),
),
);
input.value = '';
}
}}
style={{
marginLeft: '12px',
padding: '10px 20px',
borderRadius: '20px',
background: '#3b82f6',
color: 'white',
border: 'none',
}}
>
Send
</button>
</div>
</>
) : (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
}}
>
Select a conversation to start chatting
</div>
)}
</div>
</div>
);
}
/*
Kết quả ví dụ:
1. Nhấn "New Chat with Alice" → xuất hiện conversation mới
2. Chọn conversation → thấy giao diện chat trống
3. Gõ "Hey Alice!" → nhấn Send → tin nhắn hiện bên phải (màu xanh)
4. Gõ "How are you?" → tin nhắn thứ hai
5. Nhấn × bên cạnh tin nhắn đầu → tin nhắn bị xóa, lastMessageId cập nhật
6. Nhấn "Mark as Unread" → conversation có dấu chấm xanh
7. Nhấn "Delete Conversation" → conversation và tất cả messages biến mất
*/⭐⭐⭐⭐ Level 4: Quyết Định Kiến Trúc (60 phút)
/**
* 🎯 Mục tiêu: Design State Architecture cho Large App
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Context: Spotify-like Music Player App
*
* Features:
* - Browse playlists (user's + public)
* - Each playlist có songs
* - Play song → update currently playing
* - Queue management (add to queue, reorder)
* - Playback state (playing, paused, volume, progress)
* - User can create/edit/delete playlists
* - User can favorite songs
*
* State Architecture Options:
*
* Option A: Single Normalized State
* {
* playlists: { [id]: {...} },
* songs: { [id]: {...} },
* playlistSongIds: { [playlistId]: [songId, ...] },
* player: { currentSongId, isPlaying, volume, ... },
* queue: [songId, ...],
* favorites: [songId, ...]
* }
*
* Option B: Domain-Separated State
* {
* library: {
* playlists: {...},
* songs: {...},
* playlistSongIds: {...}
* },
* player: { currentSongId, isPlaying, ... },
* queue: [...],
* user: { favorites: [...] }
* }
*
* Option C: Reducer Composition
* - libraryReducer (playlists, songs)
* - playerReducer (playback state)
* - queueReducer (queue management)
* - userReducer (favorites, settings)
* - Combine với rootReducer
*
* Nhiệm vụ:
* 1. So sánh 3 options
* 2. Consider:
* - Update frequency (player state updates often)
* - Cross-domain logic (play song from playlist → update player + queue)
* - Testability
* - Code organization
* 3. Viết ADR
*
* 📝 ADR Template:
*
* ## Context
* Music player cần quản lý library (playlists, songs), player state,
* queue, và user preferences. Player state updates rất thường xuyên
* (mỗi giây), trong khi library data ít thay đổi.
*
* ## Decision
* Chọn Option C: Reducer Composition
*
* ## Rationale
* - Player updates không trigger re-render library data
* - Each domain isolated, easy to test
* - Clear code organization (player logic ở playerReducer)
* - Scalable (dễ add features)
*
* ## Consequences
* Trade-offs:
* + Better performance (isolated updates)
* + Maintainable (clear boundaries)
* + Testable (test each reducer)
* - More boilerplate (4 reducers + root)
* - Cross-domain actions cần handle carefully
*
* ## Alternatives Considered
* - Option A: Simple nhưng player updates slow
* - Option B: Better nhưng không có clear boundaries
*
* 💻 PHASE 2: Implementation (30 phút)
* Implement architecture đã chọn:
* - Define state shape
* - Create sub-reducers
* - Create rootReducer
* - Action types + creators (at least 10)
* - 2-3 actions demonstrate cross-domain logic
*
* Example cross-domain action:
* PLAY_SONG:
* - playerReducer: Update currentSongId, isPlaying
* - queueReducer: Add to queue nếu chưa có
*
* 🧪 PHASE 3: Testing (10 phút)
* Write pseudo-code tests:
* - Test playerReducer isolated
* - Test PLAY_SONG updates both player & queue
* - Test DELETE_PLAYLIST cascade deletes
*/
// TODO: Viết ADR + Implementation + Tests💡 Solution
/**
* Level 4: Quyết Định Kiến Trúc - Spotify-like Music Player App
*
* Architecture Decision Record (ADR)
*
* ## Context
* Ứng dụng music player cần quản lý:
* - Thư viện (playlists, songs)
* - Trạng thái phát nhạc (current song, playing/paused, volume, progress)
* - Queue (danh sách phát tiếp theo, có thể reorder)
* - User preferences (favorites, settings)
*
* Đặc điểm quan trọng:
* - Player state cập nhật rất thường xuyên (progress mỗi giây, play/pause, volume...)
* - Library data thay đổi ít hơn (thêm playlist, favorite song...)
* - Có cross-domain actions: PLAY_SONG → update player + queue
* - Cần testability cao và tổ chức code rõ ràng khi scale
*
* ## Decision
* Chọn **Option C: Reducer Composition** với 4 sub-reducers riêng biệt
*
* State shape:
* {
* library: { playlists, songs, playlistSongIds },
* player: { currentSongId, isPlaying, volume, progress, ... },
* queue: { songIds: [], currentIndex },
* user: { favorites: [songId,...], settings }
* }
*
* ## Rationale
* - Player updates (progress, volume) không làm re-render toàn bộ library → performance tốt
* - Mỗi domain có reducer riêng → code ngắn gọn, dễ maintain, dễ test
* - Cross-domain actions dễ xử lý bằng cách để rootReducer gọi nhiều sub-reducer
* - Queue và player tách biệt → dễ implement shuffle, repeat, reorder
* - Scalable: dễ thêm domain mới (ví dụ: recommendations, offline mode)
*
* ## Consequences
* + Performance tốt hơn (isolated updates)
* + Code organization rõ ràng (player logic chỉ nằm trong playerReducer)
* + Test mỗi reducer độc lập
* - Boilerplate nhiều hơn (4 reducers + rootReducer)
* - Cross-domain actions cần cẩn thận (phải pass action cho nhiều reducer)
*
* ## Alternatives Considered
* - Option A (Single Normalized State): đơn giản nhưng player updates gây re-render không cần thiết
* - Option B (Domain-Separated State): tốt hơn A nhưng không có boundary rõ ràng giữa các domain
*
* @returns {JSX.Element}
*/
function MusicPlayerApp() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
// Library
ADD_PLAYLIST: 'ADD_PLAYLIST',
ADD_SONG_TO_PLAYLIST: 'ADD_SONG_TO_PLAYLIST',
REMOVE_SONG_FROM_PLAYLIST: 'REMOVE_SONG_FROM_PLAYLIST',
TOGGLE_FAVORITE: 'TOGGLE_FAVORITE',
// Player
PLAY_SONG: 'PLAY_SONG',
PAUSE: 'PAUSE',
PLAY: 'PLAY',
NEXT: 'NEXT',
PREVIOUS: 'PREVIOUS',
SET_VOLUME: 'SET_VOLUME',
SET_PROGRESS: 'SET_PROGRESS',
// Queue
ADD_TO_QUEUE: 'ADD_TO_QUEUE',
CLEAR_QUEUE: 'CLEAR_QUEUE',
REORDER_QUEUE: 'REORDER_QUEUE',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
// Library
addPlaylist: (title) => ({
type: ActionTypes.ADD_PLAYLIST,
payload: { id: Date.now().toString(), title },
}),
addSongToPlaylist: (playlistId, songId) => ({
type: ActionTypes.ADD_SONG_TO_PLAYLIST,
payload: { playlistId, songId },
}),
toggleFavorite: (songId) => ({
type: ActionTypes.TOGGLE_FAVORITE,
payload: { songId },
}),
// Player + Queue cross-domain
playSong: (songId, fromQueue = false) => ({
type: ActionTypes.PLAY_SONG,
payload: { songId, fromQueue },
}),
play: () => ({ type: ActionTypes.PLAY }),
pause: () => ({ type: ActionTypes.PAUSE }),
next: () => ({ type: ActionTypes.NEXT }),
previous: () => ({ type: ActionTypes.PREVIOUS }),
setVolume: (volume) => ({
type: ActionTypes.SET_VOLUME,
payload: { volume },
}),
setProgress: (progress) => ({
type: ActionTypes.SET_PROGRESS,
payload: { progress },
}),
// Queue
addToQueue: (songId) => ({
type: ActionTypes.ADD_TO_QUEUE,
payload: { songId },
}),
};
// ── Initial State ────────────────────────────────────────────────
const initialState = {
library: {
songs: {
s1: {
id: 's1',
title: 'Blinding Lights',
artist: 'The Weeknd',
duration: 200,
},
s2: {
id: 's2',
title: 'Levitating',
artist: 'Dua Lipa',
duration: 203,
},
s3: {
id: 's3',
title: 'Save Your Tears',
artist: 'The Weeknd',
duration: 215,
},
},
playlists: {},
playlistSongIds: {},
},
player: {
currentSongId: null,
isPlaying: false,
volume: 80,
progress: 0, // 0-100
},
queue: {
songIds: [],
currentIndex: -1,
},
user: {
favorites: [],
},
};
// ── Sub-reducers ─────────────────────────────────────────────────
function libraryReducer(state = initialState.library, action) {
switch (action.type) {
case ActionTypes.ADD_PLAYLIST: {
const { id, title } = action.payload;
return {
...state,
playlists: {
...state.playlists,
[id]: { id, title, createdAt: new Date().toISOString() },
},
playlistSongIds: {
...state.playlistSongIds,
[id]: [],
},
};
}
case ActionTypes.ADD_SONG_TO_PLAYLIST: {
const { playlistId, songId } = action.payload;
return {
...state,
playlistSongIds: {
...state.playlistSongIds,
[playlistId]: [
...(state.playlistSongIds[playlistId] || []),
songId,
],
},
};
}
default:
return state;
}
}
function playerReducer(state = initialState.player, action, queueState) {
switch (action.type) {
case ActionTypes.PLAY_SONG: {
const { songId, fromQueue } = action.payload;
return {
...state,
currentSongId: songId,
isPlaying: true,
progress: 0,
};
}
case ActionTypes.PLAY:
return { ...state, isPlaying: true };
case ActionTypes.PAUSE:
return { ...state, isPlaying: false };
case ActionTypes.SET_VOLUME:
return {
...state,
volume: Math.max(0, Math.min(100, action.payload.volume)),
};
case ActionTypes.SET_PROGRESS:
return { ...state, progress: action.payload.progress };
// NEXT / PREVIOUS sẽ được xử lý ở rootReducer
default:
return state;
}
}
function queueReducer(state = initialState.queue, action) {
switch (action.type) {
case ActionTypes.PLAY_SONG: {
const { songId, fromQueue } = action.payload;
if (fromQueue) {
const index = state.songIds.indexOf(songId);
return index !== -1 ? { ...state, currentIndex: index } : state;
}
// Add to queue if not already playing from queue
if (!state.songIds.includes(songId)) {
return {
...state,
songIds: [...state.songIds, songId],
currentIndex: state.songIds.length,
};
}
return { ...state, currentIndex: state.songIds.indexOf(songId) };
}
case ActionTypes.ADD_TO_QUEUE: {
const { songId } = action.payload;
if (state.songIds.includes(songId)) return state;
return { ...state, songIds: [...state.songIds, songId] };
}
case ActionTypes.NEXT: {
const nextIndex = state.currentIndex + 1;
if (nextIndex >= state.songIds.length) return state;
return { ...state, currentIndex: nextIndex };
}
case ActionTypes.PREVIOUS: {
const prevIndex = state.currentIndex - 1;
if (prevIndex < 0) return state;
return { ...state, currentIndex: prevIndex };
}
default:
return state;
}
}
function userReducer(state = initialState.user, action) {
switch (action.type) {
case ActionTypes.TOGGLE_FAVORITE: {
const { songId } = action.payload;
const isFavorite = state.favorites.includes(songId);
return {
...state,
favorites: isFavorite
? state.favorites.filter((id) => id !== songId)
: [...state.favorites, songId],
};
}
default:
return state;
}
}
// ── Root Reducer (Composition) ───────────────────────────────────
function rootReducer(state, action) {
const library = libraryReducer(state.library, action);
const queue = queueReducer(state.queue, action);
const player = playerReducer(state.player, action, queue);
const user = userReducer(state.user, action);
// Cross-domain: khi NEXT/PREVIOUS → update player.currentSongId
let finalPlayer = player;
if (
action.type === ActionTypes.NEXT ||
action.type === ActionTypes.PREVIOUS
) {
const newSongId = queue.songIds[queue.currentIndex] || null;
finalPlayer = {
...player,
currentSongId: newSongId,
isPlaying: newSongId ? player.isPlaying : false,
};
}
// PLAY_SONG từ playlist → tự động add vào queue nếu cần
if (action.type === ActionTypes.PLAY_SONG && !action.payload.fromQueue) {
// Logic add to queue đã xử lý trong queueReducer
}
return {
...state,
library,
player: finalPlayer,
queue,
user,
};
}
const [state, dispatch] = React.useReducer(rootReducer, initialState);
// ── Helpers ──────────────────────────────────────────────────────
const currentSong = state.player.currentSongId
? state.library.songs[state.player.currentSongId]
: null;
// ── UI (Simplified) ──────────────────────────────────────────────
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h1>Music Player</h1>
{/* Now Playing */}
<div
style={{
marginBottom: '24px',
padding: '16px',
background: '#f8f9fa',
borderRadius: '12px',
}}
>
<h3>Now Playing</h3>
{currentSong ? (
<div>
<strong>{currentSong.title}</strong> — {currentSong.artist}
<div style={{ marginTop: '12px' }}>
<button onClick={() => dispatch(actions.play())}>Play</button>
<button onClick={() => dispatch(actions.pause())}>Pause</button>
<button onClick={() => dispatch(actions.previous())}>
◀ Previous
</button>
<button onClick={() => dispatch(actions.next())}>Next ▶</button>
</div>
<div>
Volume:
<input
type='range'
min='0'
max='100'
value={state.player.volume}
onChange={(e) =>
dispatch(actions.setVolume(Number(e.target.value)))
}
/>
</div>
<div>Progress: {state.player.progress}%</div>
</div>
) : (
<p>Select a song to play</p>
)}
</div>
{/* Controls */}
<div style={{ marginBottom: '24px' }}>
<button onClick={() => dispatch(actions.addPlaylist('Chill Vibes'))}>
Create Playlist
</button>
<button onClick={() => dispatch(actions.playSong('s1'))}>
Play Blinding Lights
</button>
<button onClick={() => dispatch(actions.playSong('s2'))}>
Play Levitating
</button>
<button onClick={() => dispatch(actions.addToQueue('s3'))}>
Add Save Your Tears to Queue
</button>
<button onClick={() => dispatch(actions.toggleFavorite('s1'))}>
{state.user.favorites.includes('s1') ? 'Unfavorite' : 'Favorite'}{' '}
Blinding Lights
</button>
</div>
{/* Queue */}
<div>
<h3>Queue ({state.queue.songIds.length})</h3>
<ul>
{state.queue.songIds.map((songId, index) => {
const song = state.library.songs[songId];
return (
<li
key={songId}
style={{
fontWeight:
index === state.queue.currentIndex ? 'bold' : 'normal',
color:
index === state.queue.currentIndex ? '#3b82f6' : 'inherit',
}}
>
{song.title} — {song.artist}
</li>
);
})}
</ul>
</div>
</div>
);
}
/*
Kết quả ví dụ:
1. Nhấn "Play Blinding Lights" → player chơi bài s1, queue = ['s1'], currentIndex = 0
2. Nhấn "Add Save Your Tears to Queue" → queue = ['s1', 's3']
3. Nhấn "Next" → chuyển sang s3, currentSongId = 's3'
4. Nhấn "Favorite Blinding Lights" → favorites = ['s1']
5. Nhấn "Create Playlist" → playlists có thêm một playlist mới
6. Nhấn "Play" / "Pause" / thay đổi volume → chỉ player state thay đổi, không re-render queue/library
*/⭐⭐⭐⭐⭐ Level 5: Production Challenge (90 phút)
/**
* 🎯 Mục tiêu: Build Production-Ready Kanban Board
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
*
* Trello-like Kanban Board với:
* - Multiple boards
* - Each board có columns (Todo, In Progress, Done)
* - Each column có cards
* - Drag & drop cards giữa columns
* - Add/Edit/Delete cards
* - Add/Edit/Delete columns
* - Card tags/labels
* - Filter cards by tag
* - Search cards
* - Card details (title, description, due date, assignee)
*
* 🏗️ Technical Design Doc:
*
* 1. State Architecture:
* Normalized state với:
* - boards: { [id]: { id, title, columnIds } }
* - columns: { [id]: { id, title, boardId, cardIds } }
* - cards: { [id]: { id, title, description, columnId, tags, ... } }
* - tags: { [id]: { id, name, color } }
*
* 2. Reducer Composition:
* - boardsReducer
* - columnsReducer
* - cardsReducer
* - tagsReducer
* - filterReducer
* - rootReducer (combine all)
*
* 3. Action Types (20+ actions):
* Boards: ADD_BOARD, EDIT_BOARD, DELETE_BOARD
* Columns: ADD_COLUMN, EDIT_COLUMN, DELETE_COLUMN, REORDER_COLUMNS
* Cards: ADD_CARD, EDIT_CARD, DELETE_CARD, MOVE_CARD
* Tags: ADD_TAG, EDIT_TAG, DELETE_TAG
* Filters: SET_TAG_FILTER, SET_SEARCH_FILTER, CLEAR_FILTERS
*
* 4. Cross-Domain Logic:
* - DELETE_BOARD: cascade delete columns & cards
* - DELETE_COLUMN: move cards to "Unassigned" hoặc delete
* - MOVE_CARD: update cardIds trong 2 columns
* - DELETE_TAG: remove tag từ tất cả cards
*
* 5. Performance:
* - useMemo for filtered/searched cards
* - Denormalize helpers cached
*
* ✅ Production Checklist:
* - [ ] State shape documented (comments)
* - [ ] All ActionTypes constants defined
* - [ ] All action creators implemented
* - [ ] All reducers implemented (4-5 reducers)
* - [ ] rootReducer combines all
* - [ ] Cross-domain logic handled correctly
* - [ ] Helper functions:
* - [ ] getBoardWithColumns
* - [ ] getColumnWithCards
* - [ ] getFilteredCards
* - [ ] Edge cases handled:
* - [ ] Delete board with columns/cards
* - [ ] Move card updates both columns
* - [ ] Filter by multiple tags
* - [ ] Code quality:
* - [ ] Clear comments
* - [ ] Consistent naming
* - [ ] DRY (no duplication)
*
* 📝 Documentation:
* - README với state architecture diagram
* - Action types reference
* - Reducer responsibilities
*
* 🔍 Self-Review Checklist:
* - [ ] Immutable updates everywhere
* - [ ] Default cases in all reducers
* - [ ] Action creators type-safe (payload structure consistent)
* - [ ] No magic strings (all constants)
* - [ ] Normalized state (no nested arrays/objects)
*/
// TODO: Full implementation
// Starter: State Shape
const initialState = {
boards: {
// '1': { id: '1', title: 'My Board', columnIds: ['col1', 'col2'] }
},
columns: {
// 'col1': { id: 'col1', title: 'Todo', boardId: '1', cardIds: ['card1'] }
},
cards: {
// 'card1': { id: 'card1', title: 'Task', description: '...', columnId: 'col1', tagIds: ['tag1'] }
},
tags: {
// 'tag1': { id: 'tag1', name: 'Bug', color: 'red' }
},
filters: {
tagIds: [],
searchText: '',
},
allBoardIds: [],
allTagIds: [],
};
// Starter: ActionTypes
const ActionTypes = {
// Boards
ADD_BOARD: 'ADD_BOARD',
EDIT_BOARD: 'EDIT_BOARD',
DELETE_BOARD: 'DELETE_BOARD',
// Columns
ADD_COLUMN: 'ADD_COLUMN',
EDIT_COLUMN: 'EDIT_COLUMN',
DELETE_COLUMN: 'DELETE_COLUMN',
REORDER_COLUMNS: 'REORDER_COLUMNS',
// Cards
ADD_CARD: 'ADD_CARD',
EDIT_CARD: 'EDIT_CARD',
DELETE_CARD: 'DELETE_CARD',
MOVE_CARD: 'MOVE_CARD',
// Tags
ADD_TAG: 'ADD_TAG',
EDIT_TAG: 'EDIT_TAG',
DELETE_TAG: 'DELETE_TAG',
// Filters
SET_TAG_FILTER: 'SET_TAG_FILTER',
SET_SEARCH_FILTER: 'SET_SEARCH_FILTER',
CLEAR_FILTERS: 'CLEAR_FILTERS',
};
// TODO: Implement action creators
// TODO: Implement reducers
// TODO: Implement component💡 Solution
/**
* Production-Ready Kanban Board (Trello-like)
* Level 5 Challenge - Normalized State + Reducer Composition
*
* Features implemented:
* - Multiple boards
* - Columns per board
* - Cards with title, description, dueDate, assignee, tags
* - Drag & drop simulation (click to move)
* - Add/Edit/Delete board/column/card
* - Tag management
* - Basic tag filter + search
*
* @returns {JSX.Element}
*/
function KanbanBoardApp() {
// ── ACTION TYPES ─────────────────────────────────────────────────
const ActionTypes = {
// Boards
ADD_BOARD: 'ADD_BOARD',
EDIT_BOARD: 'EDIT_BOARD',
DELETE_BOARD: 'DELETE_BOARD',
// Columns
ADD_COLUMN: 'ADD_COLUMN',
EDIT_COLUMN: 'EDIT_COLUMN',
DELETE_COLUMN: 'DELETE_COLUMN',
REORDER_COLUMNS: 'REORDER_COLUMNS',
// Cards
ADD_CARD: 'ADD_CARD',
EDIT_CARD: 'EDIT_CARD',
DELETE_CARD: 'DELETE_CARD',
MOVE_CARD: 'MOVE_CARD',
// Tags
ADD_TAG: 'ADD_TAG',
EDIT_TAG: 'EDIT_TAG',
DELETE_TAG: 'DELETE_TAG',
// Filters
SET_TAG_FILTER: 'SET_TAG_FILTER',
SET_SEARCH_FILTER: 'SET_SEARCH_FILTER',
CLEAR_FILTERS: 'CLEAR_FILTERS',
// UI
SELECT_BOARD: 'SELECT_BOARD',
};
// ── ACTION CREATORS ──────────────────────────────────────────────
const actions = {
addBoard: (title) => ({
type: ActionTypes.ADD_BOARD,
payload: { id: 'b_' + Date.now(), title },
}),
deleteBoard: (boardId) => ({
type: ActionTypes.DELETE_BOARD,
payload: { boardId },
}),
selectBoard: (boardId) => ({
type: ActionTypes.SELECT_BOARD,
payload: { boardId },
}),
addColumn: (boardId, title) => ({
type: ActionTypes.ADD_COLUMN,
payload: { boardId, id: 'c_' + Date.now(), title },
}),
deleteColumn: (columnId) => ({
type: ActionTypes.DELETE_COLUMN,
payload: { columnId },
}),
addCard: (columnId, title) => ({
type: ActionTypes.ADD_CARD,
payload: { columnId, id: 'card_' + Date.now(), title },
}),
editCard: (cardId, updates) => ({
type: ActionTypes.EDIT_CARD,
payload: { cardId, updates },
}),
deleteCard: (cardId, columnId) => ({
type: ActionTypes.DELETE_CARD,
payload: { cardId, columnId },
}),
moveCard: (cardId, fromColumnId, toColumnId) => ({
type: ActionTypes.MOVE_CARD,
payload: { cardId, fromColumnId, toColumnId },
}),
addTag: (name, color) => ({
type: ActionTypes.ADD_TAG,
payload: { id: 'tag_' + Date.now(), name, color },
}),
deleteTag: (tagId) => ({
type: ActionTypes.DELETE_TAG,
payload: { tagId },
}),
setTagFilter: (tagIds) => ({
type: ActionTypes.SET_TAG_FILTER,
payload: { tagIds },
}),
setSearchFilter: (text) => ({
type: ActionTypes.SET_SEARCH_FILTER,
payload: { text },
}),
};
// ── INITIAL STATE ────────────────────────────────────────────────
const initialState = {
boards: {},
columns: {},
cards: {},
tags: {},
filters: {
tagIds: [], // array of selected tag IDs (AND filter)
searchText: '',
},
boardColumnOrder: {}, // { boardId: [colId, colId, ...] }
columnCardOrder: {}, // { colId: [cardId, cardId, ...] }
selectedBoardId: null,
allBoardIds: [],
};
// ── SUB-REDUCERS ─────────────────────────────────────────────────
function boardsReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.ADD_BOARD: {
const { id, title } = action.payload;
return {
...state,
[id]: { id, title, createdAt: new Date().toISOString() },
};
}
case ActionTypes.DELETE_BOARD: {
const { boardId } = action.payload;
const { [boardId]: removed, ...rest } = state;
return rest;
}
default:
return state;
}
}
function columnsReducer(state = {}, action, boardColumnOrder) {
switch (action.type) {
case ActionTypes.ADD_COLUMN: {
const { id, boardId, title } = action.payload;
return {
...state,
[id]: { id, title, boardId },
};
}
case ActionTypes.DELETE_COLUMN: {
const { columnId } = action.payload;
const { [columnId]: removed, ...rest } = state;
return rest;
}
default:
return state;
}
}
function cardsReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.ADD_CARD: {
const { id, columnId, title } = action.payload;
return {
...state,
[id]: {
id,
title,
description: '',
dueDate: null,
assignee: null,
tagIds: [],
columnId,
createdAt: new Date().toISOString(),
},
};
}
case ActionTypes.EDIT_CARD: {
const { cardId, updates } = action.payload;
return {
...state,
[cardId]: { ...state[cardId], ...updates },
};
}
case ActionTypes.DELETE_CARD: {
const { cardId } = action.payload;
const { [cardId]: removed, ...rest } = state;
return rest;
}
case ActionTypes.MOVE_CARD: {
const { cardId } = action.payload;
return {
...state,
[cardId]: {
...state[cardId],
columnId: action.payload.toColumnId,
},
};
}
default:
return state;
}
}
function tagsReducer(state = {}, action) {
switch (action.type) {
case ActionTypes.ADD_TAG: {
const { id, name, color } = action.payload;
return {
...state,
[id]: { id, name, color },
};
}
case ActionTypes.DELETE_TAG: {
const { tagId } = action.payload;
const { [tagId]: removed, ...rest } = state;
return rest;
}
default:
return state;
}
}
// ── ROOT REDUCER ─────────────────────────────────────────────────
function rootReducer(state, action) {
let nextState = {
...state,
boards: boardsReducer(state.boards, action),
columns: columnsReducer(state.columns, action, state.boardColumnOrder),
cards: cardsReducer(state.cards, action),
tags: tagsReducer(state.tags, action),
filters:
action.type === ActionTypes.SET_TAG_FILTER ||
action.type === ActionTypes.SET_SEARCH_FILTER ||
action.type === ActionTypes.CLEAR_FILTERS
? filtersReducer(state.filters, action)
: state.filters,
selectedBoardId:
action.type === ActionTypes.SELECT_BOARD
? action.payload.boardId
: state.selectedBoardId,
};
// Handle cross-domain side effects
if (action.type === ActionTypes.DELETE_BOARD) {
const boardId = action.payload.boardId;
const columnIds = state.boardColumnOrder[boardId] || [];
// Remove columns
const newColumns = { ...nextState.columns };
columnIds.forEach((colId) => delete newColumns[colId]);
nextState.columns = newColumns;
// Remove cards in those columns
const newCards = { ...nextState.cards };
Object.values(newCards).forEach((card) => {
if (columnIds.includes(card.columnId)) {
delete newCards[card.id];
}
});
nextState.cards = newCards;
// Clean up orders
const { [boardId]: removedOrder, ...restOrders } =
nextState.boardColumnOrder;
nextState.boardColumnOrder = restOrders;
// Clean columnCardOrder
const newColumnCardOrder = { ...nextState.columnCardOrder };
columnIds.forEach((colId) => delete newColumnCardOrder[colId]);
nextState.columnCardOrder = newColumnCardOrder;
// Deselect if needed
if (nextState.selectedBoardId === boardId) {
nextState.selectedBoardId = null;
}
}
if (action.type === ActionTypes.DELETE_COLUMN) {
const columnId = action.payload.columnId;
const cardIds = state.columnCardOrder[columnId] || [];
// Remove cards
const newCards = { ...nextState.cards };
cardIds.forEach((cardId) => delete newCards[cardId]);
nextState.cards = newCards;
// Clean columnCardOrder
const { [columnId]: removed, ...rest } = nextState.columnCardOrder;
nextState.columnCardOrder = rest;
}
if (action.type === ActionTypes.DELETE_TAG) {
const tagId = action.payload.tagId;
const newCards = { ...nextState.cards };
Object.keys(newCards).forEach((cardId) => {
newCards[cardId] = {
...newCards[cardId],
tagIds: newCards[cardId].tagIds.filter((id) => id !== tagId),
};
});
nextState.cards = newCards;
}
return nextState;
}
function filtersReducer(state, action) {
switch (action.type) {
case ActionTypes.SET_TAG_FILTER:
return { ...state, tagIds: action.payload.tagIds };
case ActionTypes.SET_SEARCH_FILTER:
return { ...state, searchText: action.payload.text };
case ActionTypes.CLEAR_FILTERS:
return { tagIds: [], searchText: '' };
default:
return state;
}
}
const [state, dispatch] = React.useReducer(rootReducer, initialState);
// ── HELPERS ──────────────────────────────────────────────────────
const getColumnsForBoard = (boardId) => {
const columnIds = state.boardColumnOrder[boardId] || [];
return columnIds.map((id) => state.columns[id]).filter(Boolean);
};
const getCardsForColumn = (columnId) => {
const cardIds = state.columnCardOrder[columnId] || [];
return cardIds
.map((id) => state.cards[id])
.filter(Boolean)
.filter((card) => {
const matchesSearch = state.filters.searchText
? card.title
.toLowerCase()
.includes(state.filters.searchText.toLowerCase()) ||
(card.description || '')
.toLowerCase()
.includes(state.filters.searchText.toLowerCase())
: true;
const matchesTags =
state.filters.tagIds.length === 0
? true
: state.filters.tagIds.every((tagId) =>
card.tagIds.includes(tagId),
);
return matchesSearch && matchesTags;
});
};
const selectedBoard = state.selectedBoardId
? state.boards[state.selectedBoardId]
: null;
// ── UI ───────────────────────────────────────────────────────────
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<h1>Kanban Board</h1>
{/* Board Selector */}
<div style={{ marginBottom: '24px' }}>
<h3>Boards</h3>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
{state.allBoardIds.map((boardId) => {
const board = state.boards[boardId];
const isSelected = boardId === state.selectedBoardId;
return (
<div
key={boardId}
onClick={() => dispatch(actions.selectBoard(boardId))}
style={{
padding: '12px 20px',
background: isSelected ? '#3b82f6' : '#f1f5f9',
color: isSelected ? 'white' : 'black',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: isSelected ? 'bold' : 'normal',
}}
>
{board.title}
<button
onClick={(e) => {
e.stopPropagation();
if (
window.confirm('Delete this board and all its content?')
) {
dispatch(actions.deleteBoard(boardId));
}
}}
style={{
marginLeft: '12px',
color: isSelected ? '#bfdbfe' : 'red',
background: 'none',
border: 'none',
}}
>
×
</button>
</div>
);
})}
<button
onClick={() => {
const title = prompt('Board title:');
if (title) {
dispatch(actions.addBoard(title.trim()));
}
}}
style={{
padding: '12px 20px',
background: '#10b981',
color: 'white',
border: 'none',
borderRadius: '8px',
}}
>
+ New Board
</button>
</div>
</div>
{selectedBoard && (
<>
<h2>{selectedBoard.title}</h2>
{/* Filters */}
<div
style={{
margin: '16px 0',
display: 'flex',
gap: '16px',
alignItems: 'center',
}}
>
<input
placeholder='Search cards...'
value={state.filters.searchText}
onChange={(e) =>
dispatch(actions.setSearchFilter(e.target.value))
}
style={{
padding: '8px',
borderRadius: '6px',
border: '1px solid #d1d5db',
}}
/>
<div>
Filter by tags:
{Object.values(state.tags).map((tag) => (
<label
key={tag.id}
style={{ marginLeft: '12px' }}
>
<input
type='checkbox'
checked={state.filters.tagIds.includes(tag.id)}
onChange={(e) => {
const newTagIds = e.target.checked
? [...state.filters.tagIds, tag.id]
: state.filters.tagIds.filter((id) => id !== tag.id);
dispatch(actions.setTagFilter(newTagIds));
}}
/>
<span
style={{
background: tag.color,
color: 'white',
padding: '2px 8px',
borderRadius: '12px',
marginLeft: '4px',
}}
>
{tag.name}
</span>
</label>
))}
</div>
<button
onClick={() => dispatch({ type: ActionTypes.CLEAR_FILTERS })}
>
Clear Filters
</button>
<button
onClick={() => {
const name = prompt('Tag name:');
const color = prompt('Color (e.g. #ef4444):', '#ef4444');
if (name && color) {
dispatch(actions.addTag(name.trim(), color));
}
}}
>
+ New Tag
</button>
</div>
{/* Columns */}
<div
style={{
display: 'flex',
gap: '20px',
overflowX: 'auto',
paddingBottom: '20px',
}}
>
{getColumnsForBoard(selectedBoard.id).map((column) => (
<div
key={column.id}
style={{
background: '#f8fafc',
borderRadius: '8px',
width: '320px',
minHeight: '500px',
padding: '12px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<h4>{column.title}</h4>
<button
onClick={() => {
if (window.confirm('Delete column and move cards?')) {
dispatch({
type: ActionTypes.DELETE_COLUMN,
payload: { columnId: column.id },
});
}
}}
style={{ color: 'red', background: 'none', border: 'none' }}
>
×
</button>
</div>
{/* Cards */}
{getCardsForColumn(column.id).map((card) => (
<div
key={card.id}
style={{
background: 'white',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<strong>{card.title}</strong>
{card.description && (
<p
style={{
margin: '8px 0',
fontSize: '0.9em',
color: '#4b5563',
}}
>
{card.description}
</p>
)}
{card.tagIds.length > 0 && (
<div style={{ margin: '8px 0' }}>
{card.tagIds.map((tagId) => {
const tag = state.tags[tagId];
return tag ? (
<span
key={tagId}
style={{
background: tag.color,
color: 'white',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.8em',
marginRight: '6px',
}}
>
{tag.name}
</span>
) : null;
})}
</div>
)}
<div
style={{
marginTop: '12px',
fontSize: '0.85em',
color: '#6b7280',
}}
>
{card.dueDate && (
<div>
Due: {new Date(card.dueDate).toLocaleDateString()}
</div>
)}
{card.assignee && <div>Assignee: {card.assignee}</div>}
</div>
<div
style={{ marginTop: '12px', display: 'flex', gap: '8px' }}
>
<button
onClick={() => {
const title = prompt('New title:', card.title);
if (title)
dispatch(actions.editCard(card.id, { title }));
}}
>
Edit
</button>
<button
onClick={() => {
if (window.confirm('Delete card?')) {
dispatch(actions.deleteCard(card.id, column.id));
}
}}
style={{ color: 'red' }}
>
Delete
</button>
<select
value=''
onChange={(e) => {
const toColumnId = e.target.value;
if (toColumnId) {
dispatch(
actions.moveCard(card.id, column.id, toColumnId),
);
}
}}
style={{ marginLeft: 'auto' }}
>
<option value=''>Move to...</option>
{getColumnsForBoard(selectedBoard.id)
.filter((c) => c.id !== column.id)
.map((c) => (
<option
key={c.id}
value={c.id}
>
{c.title}
</option>
))}
</select>
</div>
</div>
))}
{/* Add Card */}
<button
onClick={() => {
const title = prompt('Card title:');
if (title) {
dispatch(actions.addCard(column.id, title.trim()));
}
}}
style={{
width: '100%',
padding: '10px',
marginTop: '12px',
background: '#e5e7eb',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
+ Add Card
</button>
</div>
))}
{/* Add Column */}
<button
onClick={() => {
const title = prompt('Column title:');
if (title) {
dispatch(actions.addColumn(selectedBoard.id, title.trim()));
}
}}
style={{
width: '280px',
height: '120px',
background: '#f1f5f9',
border: '2px dashed #9ca3af',
borderRadius: '8px',
fontSize: '1.1em',
cursor: 'pointer',
}}
>
+ Add Column
</button>
</div>
</>
)}
{!selectedBoard && state.allBoardIds.length === 0 && (
<p style={{ textAlign: 'center', color: '#6b7280', marginTop: '60px' }}>
Create your first board to get started
</p>
)}
</div>
);
}
/*
Kết quả ví dụ:
1. Nhấn "+ New Board" → tạo "Project X"
2. Chọn board → thấy nút "+ Add Column"
3. Tạo 3 cột: To Do, In Progress, Done
4. Trong cột To Do → "+ Add Card" → "Implement login"
5. Thêm tag "Bug" (màu đỏ) → gán tag vào card
6. Tạo card khác → di chuyển card sang "In Progress" qua dropdown
7. Tìm kiếm "login" → chỉ hiện card liên quan
8. Xóa tag "Bug" → tag biến mất khỏi tất cả card
9. Xóa column "Done" → card trong đó bị xóa
10. Xóa board → toàn bộ columns + cards biến mất
*/📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh: Nested vs Normalized State
| Tiêu chí | Nested State | Normalized State | Winner |
|---|---|---|---|
| Setup Complexity | ✅ Đơn giản, tự nhiên | ❌ Phức tạp, cần plan | Nested |
| Update Performance | ❌ Clone entire tree | ✅ Clone only changed part | Normalized |
| Query Simplicity | ✅ Direct access: user.posts | ❌ Need denormalize | Nested |
| Data Duplication | ❌ Duplicate data across tree | ✅ No duplication | Normalized |
| Deeply Nested Updates | ❌ Nightmare (4+ levels) | ✅ Simple (1 level) | Normalized |
| Consistency | ❌ Same data in multiple places | ✅ Single source of truth | Normalized |
| Learning Curve | ✅ Easy to understand | ❌ Cần hiểu pattern | Nested |
| Scalability | ❌ Slow khi data grows | ✅ Scales well | Normalized |
| TypeScript Support | ⚠️ Complex nested types | ✅ Simple flat types | Normalized |
| Testing | ❌ Phải setup entire tree | ✅ Test isolated parts | Normalized |
Decision Tree: Nested vs Normalized?
START: Thiết kế state structure?
│
├─ Data đơn giản (1-2 levels nesting)?
│ └─ YES → Nested State ✅
│ Example: { user: { name, settings: { theme } } }
│
├─ Data được update thường xuyên?
│ └─ YES → Normalized State ✅
│ Reason: Fast updates quan trọng
│
├─ Data có relationships phức tạp?
│ (Many-to-many, bidirectional)
│ └─ YES → Normalized State ✅
│ Example: Users ↔ Posts ↔ Comments
│
├─ Data tree sâu (3+ levels)?
│ └─ YES → Normalized State ✅
│ Reason: Avoid nested update hell
│
├─ Data có duplication?
│ (Same object in multiple places)
│ └─ YES → Normalized State ✅
│ Example: Author info duplicated in posts
│
├─ Primarily read-only data?
│ └─ YES → Nested State ✅
│ Example: Config, static content
│
├─ Need to reference same item từ multiple places?
│ └─ YES → Normalized State ✅
│ Example: Tag referenced by multiple posts
│
└─ NOT SURE?
→ Start Nested (simple)
→ Refactor to Normalized khi:
- Updates become slow
- Code becomes messy
- Data duplication issuesAction Creators: Pros & Cons
| Approach | Code | Pros | Cons | Use When |
|---|---|---|---|---|
| Inline Objects | dispatch({ type: 'ADD', payload: {...} }) | • Ít code • Trực quan | • Typo risk • Duplicate logic • Hard refactor | Prototype, simple apps |
| Constants | dispatch({ type: Types.ADD, payload: {...} }) | • Autocomplete • Catch typos | • Still duplicate payload logic | Small-medium apps |
| Action Creators | dispatch(actions.add(data)) | • No duplication • Consistent payload • Easy test • Type-safe | • More boilerplate | Medium-large apps |
Reducer Composition Patterns
Pattern 1: Domain-Based Split
// ✅ GOOD: Split by domain
rootReducer = {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer,
}
// Use: State shape mirrors reducers
state.users → handled by usersReducer
state.posts → handled by postsReducerPattern 2: Feature-Based Split
// ✅ GOOD: Split by feature
rootReducer = {
auth: authReducer,
dashboard: dashboardReducer,
settings: settingsReducer,
};
// Use: Feature isolationPattern 3: Hybrid
// ✅ GOOD: Domain + Feature
rootReducer = {
// Shared data
entities: entitiesReducer, // users, posts, comments
// Feature-specific
auth: authReducer,
ui: uiReducer,
};🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Incorrect Normalization 🐛
// ❌ CODE BỊ LỖI
const state = {
posts: {
1: {
id: 1,
title: 'Hello',
author: { id: 'user1', name: 'Alice' }, // 🐛 Nested author!
comments: [
{ id: 'c1', text: 'Nice!' }, // 🐛 Nested comments!
],
},
},
};
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_USER_NAME': {
// 🐛 Phải update ở nhiều chỗ!
const newPosts = {};
Object.keys(state.posts).forEach((postId) => {
const post = state.posts[postId];
if (post.author.id === action.userId) {
newPosts[postId] = {
...post,
author: { ...post.author, name: action.name },
};
} else {
newPosts[postId] = post;
}
});
return { ...state, posts: newPosts };
}
}
}❓ Câu hỏi:
- Vấn đề gì với state structure?
- Tại sao update user name phức tạp?
- Fix như thế nào?
💡 Giải thích:
- Author nested → duplicated trong mỗi post
- Update name → phải traverse tất cả posts
- Comments nested → khó update individual comment
✅ Fix:
// ✅ Normalized state
const state = {
users: {
user1: { id: 'user1', name: 'Alice' },
},
posts: {
1: { id: 1, title: 'Hello', authorId: 'user1', commentIds: ['c1'] },
},
comments: {
c1: { id: 'c1', text: 'Nice!', authorId: 'user1' },
},
};
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_USER_NAME': {
// ✅ Simple! Update 1 chỗ
return {
...state,
users: {
...state.users,
[action.userId]: {
...state.users[action.userId],
name: action.name,
},
},
};
}
}
}Bug 2: Broken Cascade Delete 🐛
// ❌ CODE BỊ LỖI
function reducer(state, action) {
switch (action.type) {
case 'DELETE_POST': {
const { postId } = action.payload;
// 🐛 Delete post nhưng comments vẫn còn!
const { [postId]: deleted, ...remainingPosts } = state.posts;
return {
...state,
posts: remainingPosts,
// 🐛 Comments orphaned! postCommentIds not cleaned!
};
}
}
}
// State after delete:
// {
// posts: {}, // Post deleted
// comments: { 1001: {...}, 1002: {...} }, // Comments still here!
// postCommentIds: { 101: [1001, 1002] } // Reference still here!
// }❓ Câu hỏi:
- Vấn đề gì xảy ra sau khi delete post?
- Memory leak ở đâu?
- Fix như thế nào?
💡 Giải thích:
- Delete post nhưng không delete comments → orphaned data
- postCommentIds còn reference → memory leak
- Display UI sẽ error (render comment của post không tồn tại)
✅ Fix:
function reducer(state, action) {
switch (action.type) {
case 'DELETE_POST': {
const { postId } = action.payload;
// 1️⃣ Get comments to delete
const commentIds = state.postCommentIds[postId] || [];
// 2️⃣ Delete post
const { [postId]: deletedPost, ...remainingPosts } = state.posts;
// 3️⃣ Delete comments
const newComments = { ...state.comments };
commentIds.forEach((commentId) => {
delete newComments[commentId];
});
// 4️⃣ Delete postCommentIds entry
const { [postId]: deletedIds, ...remainingPostCommentIds } =
state.postCommentIds;
return {
...state,
posts: remainingPosts,
comments: newComments,
postCommentIds: remainingPostCommentIds,
};
}
}
}Bug 3: Action Type Typo 🐛
// ❌ CODE BỊ LỖI
// actions.js
const ActionTypes = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO',
};
// reducer.js
function reducer(state, action) {
switch (action.type) {
case ActionTypes.ADD_TODO: return ...;
case ActionTypes.TOGGLE_TODO: return ...;
case 'DELETE_TODO': return ...; // 🐛 Magic string thay vì constant!
}
}
// component.js
dispatch({ type: 'DELET_TODO', payload: { id: 1 } }); // 🐛 Typo!
// → Hit default case → throw error: "Unknown action: DELET_TODO"
// → Khó debug vì không biết typo ở đâu❓ Câu hỏi:
- Vấn đề gì với code?
- Làm sao prevent typos?
- Best practice?
💡 Giải thích:
- Magic strings → typo không catch được
- Reducer dùng constant, component dùng string → inconsistent
- Refactor action type name → phải find/replace everywhere
✅ Fix:
// ✅ ALWAYS use constants + action creators
// constants.js
export const ActionTypes = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO',
};
// actions.js
export const actions = {
addTodo: (text) => ({
type: ActionTypes.ADD_TODO,
payload: { text },
}),
toggleTodo: (id) => ({
type: ActionTypes.TOGGLE_TODO,
payload: { id },
}),
deleteTodo: (id) => ({
type: ActionTypes.DELETE_TODO,
payload: { id },
}),
};
// reducer.js
import { ActionTypes } from './constants';
function reducer(state, action) {
switch (action.type) {
case ActionTypes.ADD_TODO: return ...;
case ActionTypes.TOGGLE_TODO: return ...;
case ActionTypes.DELETE_TODO: return ...; // ✅ Consistent!
}
}
// component.js
import { actions } from './actions';
dispatch(actions.deleteTodo(1)); // ✅ Type-safe, no typos!✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
Đánh dấu ✅ khi bạn tự tin:
State Normalization:
- [ ] Tôi hiểu normalized state là gì
- [ ] Tôi biết khi nào nên normalize
- [ ] Tôi biết cách normalize nested data
- [ ] Tôi biết cách denormalize để display
- [ ] Tôi hiểu trade-offs: nested vs normalized
Action Management:
- [ ] Tôi dùng action type constants thay vì magic strings
- [ ] Tôi biết cách tạo action creators
- [ ] Tôi hiểu lợi ích của action creators
- [ ] Tôi biết cách organize action types (enum pattern)
Reducer Composition:
- [ ] Tôi biết khi nào split reducer
- [ ] Tôi biết cách combine reducers manually
- [ ] Tôi hiểu domain-based vs feature-based split
- [ ] Tôi biết cách handle cross-domain actions
Edge Cases:
- [ ] Tôi biết cách cascade delete trong normalized state
- [ ] Tôi biết cách handle orphaned data
- [ ] Tôi biết cách update relationships (many-to-many)
Code Review Checklist
State Structure:
- [ ] Flat structure cho large/complex data
- [ ] Entities keyed by ID (object, not array)
- [ ] No duplication (references by ID)
- [ ] Separate ordering arrays (
allIds) nếu cần
Actions:
- [ ] All action types là constants
- [ ] Action creators cho complex payloads
- [ ] Payload structures consistent
- [ ] Action creators documented
Reducers:
- [ ] Each reducer < 150 lines
- [ ] Clear domain boundaries
- [ ] Cross-domain logic documented
- [ ] Cascade deletes handled
- [ ] Immutable updates
Code Quality:
- [ ] No magic strings
- [ ] Clear naming (ActionTypes, actions, reducers)
- [ ] Comments cho complex logic
- [ ] Helper functions extracted
🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: Normalize Nested State
Cho nested state sau:
const nestedState = {
categories: [
{
id: 1,
name: 'Electronics',
products: [
{
id: 101,
name: 'iPhone',
price: 999,
reviews: [
{
id: 1001,
rating: 5,
text: 'Great!',
user: { id: 'u1', name: 'Alice' },
},
{
id: 1002,
rating: 4,
text: 'Good',
user: { id: 'u2', name: 'Bob' },
},
],
},
{
id: 102,
name: 'iPad',
price: 799,
reviews: [],
},
],
},
{
id: 2,
name: 'Books',
products: [
{
id: 201,
name: 'React Book',
price: 49,
reviews: [
{
id: 2001,
rating: 5,
text: 'Must read!',
user: { id: 'u1', name: 'Alice' },
},
],
},
],
},
],
};Nhiệm vụ:
- Design normalized state structure
- Write reducer với actions:
- ADD_REVIEW (thêm review vào product)
- UPDATE_PRODUCT_PRICE
- DELETE_CATEGORY (cascade delete products & reviews)
- Write helper function:
getProductWithReviews(productId)
💡 Solution
/**
* Bài tập về nhà - Normalize Nested State (Categories → Products → Reviews)
*
* 1. Normalized state structure
* 2. Reducer hỗ trợ:
* - ADD_REVIEW
* - UPDATE_PRODUCT_PRICE
* - DELETE_CATEGORY (cascade delete products & reviews)
* 3. Helper: getProductWithReviews(productId)
*
* @returns {JSX.Element}
*/
function NormalizedEcommerceDemo() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
ADD_REVIEW: 'ADD_REVIEW',
UPDATE_PRODUCT_PRICE: 'UPDATE_PRODUCT_PRICE',
DELETE_CATEGORY: 'DELETE_CATEGORY',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
addReview: (productId, rating, text, userId) => ({
type: ActionTypes.ADD_REVIEW,
payload: {
id: 'r_' + Date.now(),
productId,
rating,
text,
userId,
createdAt: new Date().toISOString(),
},
}),
updateProductPrice: (productId, newPrice) => ({
type: ActionTypes.UPDATE_PRODUCT_PRICE,
payload: { productId, newPrice },
}),
deleteCategory: (categoryId) => ({
type: ActionTypes.DELETE_CATEGORY,
payload: { categoryId },
}),
};
// ── Normalized Initial State ─────────────────────────────────────
const initialState = {
categories: {
1: { id: '1', name: 'Electronics' },
2: { id: '2', name: 'Books' },
},
products: {
101: { id: '101', name: 'iPhone', price: 999, categoryId: '1' },
102: { id: '102', name: 'iPad', price: 799, categoryId: '1' },
201: { id: '201', name: 'React Book', price: 49, categoryId: '2' },
},
reviews: {},
categoryProductIds: {
1: ['101', '102'],
2: ['201'],
},
productReviewIds: {
101: [],
102: [],
201: [],
},
users: {
u1: { id: 'u1', name: 'Alice' },
u2: { id: 'u2', name: 'Bob' },
},
};
// ── Reducer ──────────────────────────────────────────────────────
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.ADD_REVIEW: {
const { id, productId, rating, text, userId, createdAt } =
action.payload;
return {
...state,
reviews: {
...state.reviews,
[id]: { id, productId, rating, text, userId, createdAt },
},
productReviewIds: {
...state.productReviewIds,
[productId]: [...(state.productReviewIds[productId] || []), id],
},
};
}
case ActionTypes.UPDATE_PRODUCT_PRICE: {
const { productId, newPrice } = action.payload;
return {
...state,
products: {
...state.products,
[productId]: {
...state.products[productId],
price: newPrice,
},
},
};
}
case ActionTypes.DELETE_CATEGORY: {
const { categoryId } = action.payload;
// 1. Lấy tất cả product IDs trong category
const productIds = state.categoryProductIds[categoryId] || [];
// 2. Xóa các reviews của các product đó
const newReviews = { ...state.reviews };
const newProductReviewIds = { ...state.productReviewIds };
productIds.forEach((productId) => {
const reviewIds = state.productReviewIds[productId] || [];
reviewIds.forEach((reviewId) => delete newReviews[reviewId]);
delete newProductReviewIds[productId];
});
// 3. Xóa các products
const newProducts = { ...state.products };
productIds.forEach((productId) => delete newProducts[productId]);
// 4. Xóa category và mối quan hệ
const { [categoryId]: removedCategory, ...restCategories } =
state.categories;
const { [categoryId]: removedProductIds, ...restCategoryProductIds } =
state.categoryProductIds;
return {
...state,
categories: restCategories,
products: newProducts,
reviews: newReviews,
categoryProductIds: restCategoryProductIds,
productReviewIds: newProductReviewIds,
};
}
default:
return state;
}
};
const [state, dispatch] = React.useReducer(reducer, initialState);
// ── Helper: Denormalize product + reviews ────────────────────────
const getProductWithReviews = (productId) => {
const product = state.products[productId];
if (!product) return null;
const reviewIds = state.productReviewIds[productId] || [];
const reviews = reviewIds.map((id) => {
const review = state.reviews[id];
return {
...review,
user: state.users[review.userId],
};
});
const category = state.categories[product.categoryId];
return {
...product,
category,
reviews,
};
};
// ── UI Demo ──────────────────────────────────────────────────────
return (
<div style={{ fontFamily: 'system-ui', padding: '20px' }}>
<h2>Normalized E-commerce State Demo</h2>
<div
style={{
margin: '20px 0',
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
}}
>
{/* Hiển thị tất cả products */}
{Object.values(state.products).map((product) => {
const fullProduct = getProductWithReviews(product.id);
return (
<div
key={product.id}
style={{
border: '1px solid #d1d5db',
borderRadius: '8px',
padding: '16px',
width: '320px',
}}
>
<h4>{fullProduct.name}</h4>
<p>Category: {fullProduct.category?.name}</p>
<p>
Price: <strong>${fullProduct.price}</strong>
</p>
<button
onClick={() => {
const newPrice = prompt('New price:', fullProduct.price);
if (newPrice && !isNaN(newPrice)) {
dispatch(
actions.updateProductPrice(product.id, Number(newPrice)),
);
}
}}
>
Update Price
</button>
<div style={{ marginTop: '16px' }}>
<h5>Reviews ({fullProduct.reviews.length})</h5>
{fullProduct.reviews.map((review) => (
<div
key={review.id}
style={{ margin: '8px 0', fontSize: '0.95em' }}
>
<strong>{review.user.name}</strong> ({review.rating}★):{' '}
{review.text}
</div>
))}
<button
onClick={() => {
const text = prompt('Your review:');
const rating = prompt('Rating (1-5):');
if (
text &&
rating &&
['1', '2', '3', '4', '5'].includes(rating)
) {
dispatch(
actions.addReview(
product.id,
Number(rating),
text,
Math.random() > 0.5 ? 'u1' : 'u2', // random user
),
);
}
}}
style={{ marginTop: '8px' }}
>
Add Review
</button>
</div>
</div>
);
})}
</div>
<div style={{ marginTop: '40px' }}>
<h3>Actions</h3>
<button
onClick={() => {
if (
window.confirm(
'Delete Electronics category and all its products & reviews?',
)
) {
dispatch(actions.deleteCategory('1'));
}
}}
style={{
background: '#ef4444',
color: 'white',
padding: '10px 16px',
border: 'none',
borderRadius: '6px',
}}
>
Delete Electronics Category
</button>
</div>
<pre
style={{
marginTop: '40px',
background: '#f8f9fa',
padding: '16px',
borderRadius: '8px',
}}
>
{JSON.stringify(state, null, 2)}
</pre>
</div>
);
}
/*
Kết quả ví dụ:
Ban đầu:
- iPhone $999, 0 reviews
- iPad $799, 0 reviews
- React Book $49, 0 reviews
Thao tác:
1. Update price iPhone → 1099 → giá thay đổi ngay lập tức
2. Add review cho iPhone: "Great phone!" 5★ → review xuất hiện
3. Add review khác cho iPhone → danh sách reviews tăng
4. Delete category "Electronics" → iPhone & iPad + tất cả reviews của chúng biến mất
React Book vẫn còn vì thuộc category khác
*/Nâng cao (60 phút)
Bài 2: Build Reddit-like Comment System
Requirements:
- Posts có nested comments (unlimited depth)
- Comments có replies (cũng là comments)
- User có thể upvote/downvote comments
- Display comment tree with indentation
State structure gợi ý:
{
posts: { [id]: { id, title, content, commentIds } },
comments: {
[id]: {
id,
text,
authorId,
postId,
parentId, // null nếu top-level, commentId nếu reply
replyIds: [], // Array of reply comment IDs
upvotes: 0,
downvotes: 0
}
},
users: { [id]: { id, name } }
}Actions:
- ADD_COMMENT (có thể là top-level hoặc reply)
- EDIT_COMMENT
- DELETE_COMMENT (cascade delete replies)
- UPVOTE_COMMENT
- DOWNVOTE_COMMENT
Challenges:
- Nested comments display (recursive component)
- Cascade delete với unlimited depth
- Calculate total replies count
💡 Solution
/**
* Bài tập về nhà - Reddit-like Comment System (Nested comments, unlimited depth)
*
* Features:
* - Posts có comments + replies (recursive)
* - Upvote / Downvote comments
* - Add comment (top-level hoặc reply)
* - Edit comment
* - Delete comment (cascade delete replies)
* - Hiển thị comment tree với indentation
*
* @returns {JSX.Element}
*/
function RedditCommentsDemo() {
// ── Action Type Constants ────────────────────────────────────────
const ActionTypes = {
ADD_COMMENT: 'ADD_COMMENT',
EDIT_COMMENT: 'EDIT_COMMENT',
DELETE_COMMENT: 'DELETE_COMMENT',
UPVOTE_COMMENT: 'UPVOTE_COMMENT',
DOWNVOTE_COMMENT: 'DOWNVOTE_COMMENT',
};
// ── Action Creators ──────────────────────────────────────────────
const actions = {
addComment: (postId, parentId, text, authorId = 'u1') => ({
type: ActionTypes.ADD_COMMENT,
payload: {
id: 'c_' + Date.now(),
postId,
parentId: parentId || null,
text,
authorId,
timestamp: new Date().toISOString(),
upvotes: 0,
downvotes: 0,
},
}),
editComment: (commentId, newText) => ({
type: ActionTypes.EDIT_COMMENT,
payload: { commentId, newText },
}),
deleteComment: (commentId) => ({
type: ActionTypes.DELETE_COMMENT,
payload: { commentId },
}),
upvoteComment: (commentId) => ({
type: ActionTypes.UPVOTE_COMMENT,
payload: { commentId },
}),
downvoteComment: (commentId) => ({
type: ActionTypes.DOWNVOTE_COMMENT,
payload: { commentId },
}),
};
// ── Initial State (Normalized) ───────────────────────────────────
const initialState = {
posts: {
p1: {
id: 'p1',
title: 'React vs Vue in 2026 – which one are you using?',
content: 'Let’s discuss the current state of these frameworks...',
commentIds: [],
},
},
comments: {}, // { commentId: { id, text, authorId, postId, parentId, replyIds, upvotes, downvotes, timestamp } }
users: {
u1: { id: 'u1', name: 'Alice', avatar: '👩' },
u2: { id: 'u2', name: 'Bob', avatar: '👨' },
u3: { id: 'u3', name: 'Charlie', avatar: '🧑' },
},
};
// ── Reducer ──────────────────────────────────────────────────────
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.ADD_COMMENT: {
const { id, postId, parentId, text, authorId, timestamp } =
action.payload;
const newComment = {
id,
postId,
parentId,
text,
authorId,
timestamp,
upvotes: 0,
downvotes: 0,
replyIds: [],
};
// Thêm vào comments
let newState = {
...state,
comments: {
...state.comments,
[id]: newComment,
},
};
// Cập nhật replyIds của parent (nếu là reply)
if (parentId) {
newState = {
...newState,
comments: {
...newState.comments,
[parentId]: {
...newState.comments[parentId],
replyIds: [...(newState.comments[parentId].replyIds || []), id],
},
},
};
}
// Cập nhật commentIds của post (nếu là top-level)
else {
newState = {
...newState,
posts: {
...newState.posts,
[postId]: {
...newState.posts[postId],
commentIds: [...(newState.posts[postId].commentIds || []), id],
},
},
};
}
return newState;
}
case ActionTypes.EDIT_COMMENT: {
const { commentId, newText } = action.payload;
return {
...state,
comments: {
...state.comments,
[commentId]: {
...state.comments[commentId],
text: newText,
editedAt: new Date().toISOString(),
},
},
};
}
case ActionTypes.DELETE_COMMENT: {
const { commentId } = action.payload;
// Thu thập tất cả comment IDs cần xóa (cascade)
const toDelete = new Set([commentId]);
const queue = [commentId];
while (queue.length > 0) {
const current = queue.shift();
const comment = state.comments[current];
if (comment?.replyIds) {
comment.replyIds.forEach((rid) => {
if (!toDelete.has(rid)) {
toDelete.add(rid);
queue.push(rid);
}
});
}
}
// Xóa comments
const newComments = { ...state.comments };
toDelete.forEach((id) => delete newComments[id]);
// Cập nhật parent (nếu có)
const parentId = state.comments[commentId]?.parentId;
if (parentId) {
newComments[parentId] = {
...newComments[parentId],
replyIds: (newComments[parentId].replyIds || []).filter(
(id) => !toDelete.has(id),
),
};
}
// Cập nhật post (nếu top-level)
else {
const postId = state.comments[commentId]?.postId;
if (postId) {
const post = state.posts[postId];
state.posts[postId] = {
...post,
commentIds: (post.commentIds || []).filter(
(id) => !toDelete.has(id),
),
};
}
}
return {
...state,
comments: newComments,
};
}
case ActionTypes.UPVOTE_COMMENT:
case ActionTypes.DOWNVOTE_COMMENT: {
const { commentId } = action.payload;
const field =
action.type === ActionTypes.UPVOTE_COMMENT ? 'upvotes' : 'downvotes';
return {
...state,
comments: {
...state.comments,
[commentId]: {
...state.comments[commentId],
[field]: (state.comments[commentId][field] || 0) + 1,
},
},
};
}
default:
return state;
}
};
const [state, dispatch] = React.useReducer(reducer, initialState);
// ── Recursive Comment Component ──────────────────────────────────
function Comment({ commentId, depth = 0 }) {
const comment = state.comments[commentId];
if (!comment) return null;
const author = state.users[comment.authorId];
const score = comment.upvotes - comment.downvotes;
return (
<div style={{ marginLeft: `${depth * 24}px`, marginBottom: '16px' }}>
<div
style={{
borderLeft: depth > 0 ? '2px solid #d1d5db' : 'none',
paddingLeft: depth > 0 ? '16px' : '0',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px',
}}
>
<strong>{author.name}</strong>
<span style={{ color: '#6b7280', fontSize: '0.85em' }}>
•{' '}
{new Date(comment.timestamp).toLocaleString([], {
dateStyle: 'short',
timeStyle: 'short',
})}
{comment.editedAt && ' (edited)'}
</span>
</div>
<p style={{ margin: '4px 0 8px' }}>{comment.text}</p>
<div
style={{
display: 'flex',
gap: '16px',
fontSize: '0.9em',
color: '#4b5563',
}}
>
<button onClick={() => dispatch(actions.upvoteComment(commentId))}>
▲ {comment.upvotes}
</button>
<button
onClick={() => dispatch(actions.downvoteComment(commentId))}
>
▼ {comment.downvotes}
</button>
<span>Score: {score}</span>
<button
onClick={() => {
const text = prompt('Reply:', '');
if (text?.trim()) {
dispatch(
actions.addComment(comment.postId, comment.id, text.trim()),
);
}
}}
>
Reply
</button>
<button
onClick={() => {
const newText = prompt('Edit comment:', comment.text);
if (newText?.trim() && newText !== comment.text) {
dispatch(actions.editComment(comment.id, newText.trim()));
}
}}
>
Edit
</button>
<button
onClick={() => {
if (window.confirm('Delete this comment and all replies?')) {
dispatch(actions.deleteComment(comment.id));
}
}}
style={{ color: '#ef4444' }}
>
Delete
</button>
</div>
{/* Replies */}
{comment.replyIds?.length > 0 && (
<div style={{ marginTop: '12px' }}>
{comment.replyIds.map((replyId) => (
<Comment
key={replyId}
commentId={replyId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
</div>
);
}
// ── UI ───────────────────────────────────────────────────────────
const post = state.posts.p1;
const topLevelComments = (post.commentIds || [])
.map((id) => state.comments[id])
.filter(Boolean);
return (
<div
style={{
maxWidth: '800px',
margin: '0 auto',
padding: '20px',
fontFamily: 'system-ui',
}}
>
<h1>{post.title}</h1>
<p style={{ color: '#4b5563' }}>{post.content}</p>
<div style={{ marginTop: '32px' }}>
<h3>Comments ({topLevelComments.length})</h3>
<div style={{ margin: '16px 0' }}>
<textarea
id='topComment'
placeholder='What are your thoughts?'
style={{
width: '100%',
minHeight: '80px',
padding: '12px',
borderRadius: '8px',
border: '1px solid #d1d5db',
}}
/>
<button
onClick={() => {
const input = document.getElementById('topComment');
if (input.value.trim()) {
dispatch(actions.addComment('p1', null, input.value.trim()));
input.value = '';
}
}}
style={{
marginTop: '8px',
padding: '8px 16px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
Comment
</button>
</div>
<div>
{topLevelComments.map((comment) => (
<Comment
key={comment.id}
commentId={comment.id}
depth={0}
/>
))}
</div>
{topLevelComments.length === 0 && (
<p
style={{ color: '#6b7280', textAlign: 'center', marginTop: '40px' }}
>
Be the first to comment!
</p>
)}
</div>
</div>
);
}
/*
Kết quả ví dụ:
1. Gõ "Vue still has better DX in 2026" → Comment → xuất hiện top-level comment
2. Nhấn Reply dưới comment → gõ "Nah, React Server Components win" → reply xuất hiện thụt vào
3. Reply tiếp dưới reply → tạo depth 3
4. Upvote / Downvote → score thay đổi
5. Edit comment → text cập nhật + (edited)
6. Delete comment ở depth 2 → toàn bộ subtree (nếu có replies) bị xóa
*/📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
Normalizing State Shape:
- https://redux.js.org/usage/structuring-reducers/normalizing-state-shape
- Áp dụng cho useReducer (không cần Redux)
React Docs - Extracting State Logic:
- https://react.dev/learn/extracting-state-logic-into-a-reducer
- Phần "Comparing useState and useReducer"
Đọc thêm
Immutable Update Patterns:
- https://redux.js.org/usage/structuring-reducers/immutable-update-patterns
- Deep updates, arrays, objects
Reducer Composition:
- https://redux.js.org/usage/structuring-reducers/splitting-reducer-logic
- Pattern áp dụng được cho useReducer
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (Đã học)
Ngày 26: useReducer Fundamentals
- Hôm nay build trên nền đó: advanced patterns
- Reducer basics → Now: complex state structures
Ngày 1-2: ES6 Destructuring, Spread
- Critical cho immutable updates
const { [id]: deleted, ...rest } = state.items
Ngày 31-34: Performance (memo, useMemo)
- Normalized state → fast updates
- Denormalize helpers → useMemo
Hướng tới (Sẽ học)
Ngày 28: useReducer + useEffect
- Data fetching patterns
- Normalize API responses
- Loading/error states trong reducer
Ngày 29: Custom Hooks với useReducer
- useNormalizedData hook
- useEntityManager hook
- Reusable state management
Phase 5: Context API
- Context + useReducer = Global state
- Alternative to Redux
- Normalized state globally
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Khi nào KHÔNG normalize:
// ✅ GOOD: Nested OK cho read-only config
const themeConfig = {
light: {
colors: { primary: '#007bff', secondary: '#6c757d' },
fonts: { heading: 'Arial', body: 'Helvetica' },
},
dark: {
/* ... */
},
};
// ❌ BAD: Normalize sẽ over-engineering
// {
// themes: { light: {...}, dark: {...} },
// colors: { primary_light: '#007bff', ... },
// fonts: { heading_light: 'Arial', ... }
// }2. Normalization levels:
// Level 1: Flat entities
{ users: {...}, posts: {...}, comments: {...} }
// Level 2: Relationships tracked
{ users: {...}, posts: {...}, userPostIds: {...} }
// Level 3: Full relational (như database)
// with indexes, lookups, etc.
// → Chọn level phù hợp với complexity3. Migration strategy:
// ❌ DON'T: Refactor everything at once
// ✅ DO: Migrate gradually
// Step 1: Add normalized entities alongside nested
const state = {
// Old (keep working)
users: [...nested...],
// New (add gradually)
normalizedUsers: {...},
};
// Step 2: Migrate reads to normalized
// Step 3: Migrate writes
// Step 4: Remove old structureCâu Hỏi Phỏng Vấn
Junior Level:
Q: "Normalized state là gì?"
Expected:
- Flat structure, no nesting
- Entities keyed by ID
- References by ID thay vì embedding
- Example: posts reference userId, không embed user object
Q: "Tại sao dùng action creators?"
Expected:
- Prevent typos
- Centralize logic
- Consistent payload
- Easy to refactor
Mid Level:
Q: "Làm sao handle cascade delete trong normalized state?"
Expected:
- Identify related entities (postCommentIds)
- Delete main entity
- Loop và delete related entities
- Clean up relationship arrays
- Example code
Q: "Trade-off của normalized state?"
Expected:
- Pros: Fast updates, no duplication, scalable
- Cons: Complex queries, need denormalize, boilerplate
- When to use: Large data, frequent updates, relationships
- When not: Simple data, read-only
Senior Level:
Q: "Thiết kế state architecture cho social network app (users, posts, comments, likes, shares). Explain your decisions."
Expected:
- Normalized structure với entities
- Separate metadata (counts, timestamps)
- Consider read/write patterns
- Indexes cho common queries
- Pagination strategy
- Caching denormalized data
- Performance tradeoffs
- Migration path
War Stories
Story 1: Nested Hell → Normalized Heaven
"App e-commerce có products nested trong categories, reviews nested trong products. Update product price phải clone entire category tree. Performance terrible với 1000+ products. Sau 2 ngày refactor sang normalized: state.products[id].price = X. Update time từ 150ms → 5ms. Code từ 50 lines nested spread → 10 lines flat. Lesson: Profile before optimize, nhưng normalized thường win." - Senior Engineer
Story 2: Premature Normalization
"Junior dev normalize TẤT CẢ, kể cả user settings object (3 fields). Kết quả: Simple feature cần 5 actions, 3 reducers, 10 helper functions. Code review: 'This is 2 levels deep, nested is fine'. Rolled back, code giảm 70%. Lesson: Normalize khi CẦN, không phải by default." - Tech Lead
Story 3: Action Creator Saved Us
"Production bug: Feature flag typo 'ENABEL_FEATURE' → silent fail trong switch/case, users không thấy feature. Với action creator, typo = autocomplete error ngay. Sau đó enforce rule: NO dispatch inline objects. Only action creators. Bugs giảm 40%." - CTO
🎯 PREVIEW NGÀY MAI
Ngày 28: useReducer + useEffect - Async Actions Pattern
Bạn sẽ học:
- ✨ Data fetching với useReducer
- ✨ Loading/Success/Error states pattern
- ✨ Request cancellation
- ✨ Optimistic updates
- ✨ Retry logic
Chuẩn bị:
- Hoàn thành bài tập hôm nay (normalize state)
- Review useEffect (Ngày 16-20)
- Review data fetching basics
- Suy nghĩ: Làm sao combine reducer (sync) + effects (async)?
🎉 Chúc mừng! Bạn đã hoàn thành Ngày 27!
Bạn giờ đã master:
- ✅ State normalization
- ✅ Action type constants & creators
- ✅ Reducer composition
- ✅ Complex state architectures
Tomorrow: Async world với useReducer! 💪