Bài 16.5: Bài tập thực hành quản lý state với React-TypeScript

Bài 16.5: Bài tập thực hành quản lý state với React-TypeScript
Chào mừng bạn quay trở lại chuỗi bài viết về lập trình Front-end! Hôm nay, chúng ta sẽ tập trung vào một chủ đề cực kỳ quan trọng trong React: Quản lý State (Trạng thái). Đây không chỉ là lý thuyết suông, mà là nền tảng cho bài tập thực hành sắp tới của chúng ta. Hiểu và làm chủ state là chìa khóa để xây dựng các ứng dụng React tương tác và mạnh mẽ, đặc biệt khi kết hợp với sự chặt chẽ của TypeScript.
Trong bài viết này, chúng ta sẽ ôn lại và đi sâu hơn vào các phương pháp quản lý state phổ biến trong React functional components sử dụng Hooks, đồng thời xem TypeScript giúp chúng ta đảm bảo tính an toàn và chính xác của state như thế nào.
Tại Sao Việc Quản lý State Lại Quan Trọng Đến Thế?
Hãy hình dung một ứng dụng web như một ngôi nhà sống động, không chỉ là một bức tranh tĩnh. State chính là trạng thái hiện tại của ngôi nhà đó: đèn đang bật hay tắt, cửa đang mở hay đóng, nhiệt độ là bao nhiêu, người dùng đang xem mục nào trong danh sách...
Trong React, state đại diện cho dữ liệu có thể thay đổi theo thời gian và ảnh hưởng đến giao diện người dùng. Khi state thay đổi, React sẽ tự động re-render (vẽ lại) các thành phần giao diện liên quan để phản ánh sự thay đổi đó. Nếu không quản lý state hiệu quả, ứng dụng của bạn sẽ trở nên:
- Khó dự đoán: Bạn không biết khi nào dữ liệu thay đổi và ảnh hưởng đến đâu.
- Khó bảo trì: Việc sửa lỗi hoặc thêm tính năng mới trở nên phức tạp vì state bị phân tán hoặc xử lý lộn xộn.
- Kém hiệu năng: Re-render không cần thiết có thể làm chậm ứng dụng.
TypeScript bước vào sân chơi này như một người bạn đồng hành đáng tin cậy. Bằng cách định nghĩa rõ ràng kiểu dữ liệu cho state, TypeScript giúp chúng ta:
- Ngăn chặn lỗi kiểu dữ liệu: Phát hiện sớm các lỗi phổ biến ngay trong quá trình phát triển (compile time) thay vì chạy ứng dụng.
- Tăng khả năng đọc hiểu: Code trở nên rõ ràng hơn về loại dữ liệu mà state đang nắm giữ.
- Hỗ trợ refactoring: Thay đổi cấu trúc state trở nên an toàn hơn.
Chúng ta sẽ xem TypeScript đóng vai trò này như thế nào trong các ví dụ dưới đây.
Phương Pháp Quản lý State với React Hooks (và TypeScript)
React cung cấp nhiều Hook để quản lý state trong functional components. Dưới đây là các Hook phổ biến nhất mà chúng ta sẽ tập trung vào:
1. useState
: Đơn giản, Phổ biến và Mạnh mẽ
Đây là Hook cơ bản nhất để quản lý state trong functional components. Nó cho phép bạn thêm một biến state vào component của mình.
Cú pháp cơ bản với TypeScript:
import React, { useState } from 'react';
function MyComponent() {
// Khai báo state với kiểu dữ liệu là number, giá trị khởi tạo là 0
const [count, setCount] = useState<number>(0);
const increment = () => {
setCount(count + 1); // Cập nhật state
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Tăng</button>
</div>
);
}
Giải thích code:
useState<number>(0)
: Chúng ta gọi HookuseState
.<number>
là generic type mà TypeScript cung cấp, nói cho TypeScript biết rằng statecount
sẽ có kiểu dữ liệu lànumber
. Giá trị0
là giá trị khởi tạo cho statecount
.const [count, setCount]
:useState
trả về một mảng có 2 phần tử. Chúng ta sử dụng array destructuring để lấy ra:count
: Biến state hiện tại (có kiểu lànumber
nhờ<number>
).setCount
: Một hàm để cập nhật state.
setCount(count + 1)
: Khi button được click, chúng ta gọi hàmsetCount
với giá trị mới. React sẽ nhận biết sự thay đổi này và re-render componentMyComponent
.
TypeScript và useState
:
TypeScript rất thông minh! Nếu bạn bỏ <number>
, TypeScript vẫn có thể suy luận kiểu dữ liệu dựa trên giá trị khởi tạo:
const [count, setCount] = useState(0); // TypeScript suy luận count là number
const [name, setName] = useState(''); // TypeScript suy luận name là string
const [isActive, setIsActive] = useState(false); // TypeScript suy luận isActive là boolean
Tuy nhiên, việc chỉ định rõ kiểu dữ liệu bằng <Type>
là rất hữu ích khi:
- Giá trị khởi tạo có thể là
null
hoặcundefined
, nhưng state sau đó sẽ nhận một kiểu cụ thể.interface User { id: number; name: string; } // State có thể là null hoặc User const [user, setUser] = useState<User | null>(null);
- Kiểu dữ liệu của state là một object hoặc array phức tạp.
interface Todo { id: number; text: string; completed: boolean; } const [todos, setTodos] = useState<Todo[]>([]); // State là một mảng các đối tượng Todo
2. useState
với Object và Array
Quản lý state là object hoặc array là trường hợp rất phổ biến. Lưu ý quan trọng: Khi cập nhật state là object hoặc array, bạn phải tạo một object/array mới. Việc thay đổi trực tiếp object/array cũ sẽ không kích hoạt re-render.
Ví dụ với Object:
import React, { useState } from 'react';
interface UserProfile {
name: string;
age: number;
email: string;
}
function ProfileEditor() {
const [profile, setProfile] = useState<UserProfile>({
name: 'John Doe',
age: 30,
email: 'john.doe@example.com',
});
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Tạo object MỚI bằng cách sao chép các thuộc tính cũ và ghi đè name
setProfile({ ...profile, name: event.target.value });
};
const handleAgeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Đảm bảo age là number
const newAge = parseInt(event.target.value, 10);
if (!isNaN(newAge)) {
setProfile({ ...profile, age: newAge });
}
};
return (
<div>
<h2>Edit Profile</h2>
<label>
Name:
<input type="text" value={profile.name} onChange={handleNameChange} />
</label>
<br />
<label>
Age:
<input type="number" value={profile.age} onChange={handleAgeChange} />
</label>
<p>Email: {profile.email}</p> {/* Giả sử email không đổi qua editor này */}
</div>
);
}
Giải thích:
- Chúng ta định nghĩa
UserProfile
interface để mô tả cấu trúc của object state. useState<UserProfile>(...)
khởi tạo state với một object theo đúng cấu trúc đó.- Trong
handleNameChange
vàhandleAgeChange
, chúng ta sử dụng spread syntax (...profile
) để sao chép tất cả các thuộc tính hiện có của objectprofile
vào một object mới, sau đó ghi đè thuộc tínhname
hoặcage
với giá trị mới. Cách này đảm bảo React nhận được một object mới và kích hoạt re-render.
Ví dụ với Array:
import React, { useState } from 'react';
interface TodoItem {
id: number;
text: string;
completed: boolean;
}
function TodoList() {
const [todos, setTodos] = useState<TodoItem[]>([]);
const [newTodoText, setNewTodoText] = useState<string>('');
const handleAddTodo = () => {
if (newTodoText.trim()) {
const newTodo: TodoItem = {
id: Date.now(), // ID đơn giản dựa trên thời gian
text: newTodoText,
completed: false,
};
// Tạo mảng MỚI bằng cách sao chép mảng cũ và thêm item mới
setTodos([...todos, newTodo]);
setNewTodoText(''); // Clear input
}
};
const handleToggleComplete = (id: number) => {
// Tạo mảng MỚI bằng cách ánh xạ (map) qua mảng cũ và cập nhật item phù hợp
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
<div>
<h2>Todo List</h2>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add new todo"
/>
<button onClick={handleAddTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
<button onClick={() => handleToggleComplete(todo.id)}>
{todo.completed ? 'Undo' : 'Complete'}
</button>
</li>
))}
</ul>
</div>
);
}
Giải thích:
- Chúng ta định nghĩa
TodoItem
interface cho mỗi phần tử trong mảng state. useState<TodoItem[]>([]):
Khởi tạo statetodos
là một mảng rỗng cácTodoItem
.handleAddTodo
: Sử dụng spread syntax (...todos
) để thêm item mới vào cuối mảng, tạo ra một mảng mới.handleToggleComplete
: Sử dụng phương thức.map()
để tạo ra một mảng mới. Với mỗi item trong mảng cũ, nếuid
khớp, chúng ta tạo một object item mới (lại dùng spread syntax...todo
) với thuộc tínhcompleted
được đảo ngược. Nếu không khớp, giữ nguyên item cũ (vì.map
tạo mảng mới, giữ nguyên item cũ vẫn là an toàn).
3. State Updates Có Thể Bất Đồng Bộ
Một điểm quan trọng cần nhớ là các hàm cập nhật state (như setCount
, setProfile
, setTodos
) không hoạt động ngay lập tức. React có thể nhóm nhiều cập nhật state lại với nhau để tối ưu hiệu năng. Điều này có nghĩa là nếu bạn cần cập nhật state dựa trên giá trị hiện tại của state, việc sử dụng trực tiếp giá trị state có thể dẫn đến kết quả sai nếu có nhiều cập nhật xảy ra cùng lúc hoặc liên tiếp.
Ví dụ dễ gây nhầm lẫn:
const [count, setCount] = useState(0);
const incrementTwice = () => {
setCount(count + 1); // Lần 1
setCount(count + 1); // Lần 2
};
// Kết quả mong đợi: count tăng lên 2
// Kết quả thực tế: count chỉ tăng lên 1 (vì cả hai lần gọi đều thấy count = 0)
Để giải quyết vấn đề này, setCount
(và các hàm set state khác) chấp nhận một hàm làm đối số. Hàm này sẽ nhận giá trị state trước đó làm tham số và trả về giá trị state mới. React đảm bảo hàm này sẽ được gọi với giá trị state chính xác nhất.
Sử dụng functional update:
const [count, setCount] = useState(0);
const incrementTwiceCorrect = () => {
setCount(prevCount => prevCount + 1); // Lần 1: prevCount là 0, trả về 1
setCount(prevCount => prevCount + 1); // Lần 2: prevCount là 1 (từ lần 1), trả về 2
};
// Kết quả: count tăng lên 2 (ĐÚNG)
TypeScript và functional update:
TypeScript tự động suy luận kiểu dữ liệu của prevCount
dựa trên kiểu dữ liệu của state.
const [count, setCount] = useState<number>(0);
setCount(prevCount => prevCount + 1); // prevCount được suy luận là number
interface Config {
theme: string;
fontSize: number;
}
const [config, setConfig] = useState<Config>({ theme: 'dark', fontSize: 16 });
setConfig(prevConfig => ({ ...prevConfig, theme: 'light' })); // prevConfig được suy luận là Config
Sử dụng functional update là một thực hành tốt khi cập nhật state phụ thuộc vào giá trị state trước đó.
4. Quản lý State Phức tạp với useReducer
Đối với các state có logic cập nhật phức tạp hơn, hoặc khi các hành động (actions) để thay đổi state trở nên đa dạng, useReducer
là một lựa chọn tuyệt vời. Nó tương tự như mô hình Redux nhưng được tích hợp sẵn trong React Hook.
useReducer
nhận vào hai thứ:
- Một hàm
reducer
: Hàm này nhận state hiện tại và mộtaction
(hành động) làm đối số, sau đó trả về state mới. - Giá trị state khởi tạo.
Nó trả về:
- State hiện tại.
- Một hàm
dispatch
: Dùng để "gửi" các hành động đến hàmreducer
.
Cú pháp cơ bản với TypeScript:
import React, { useReducer } from 'react';
// 1. Định nghĩa kiểu dữ liệu cho State
interface CounterState {
count: number;
}
// 2. Định nghĩa các loại Action (hành động)
// Sử dụng union types để mô tả các action có thể xảy ra
type CounterAction =
| { type: 'INCREMENT'; payload?: number } // Action tăng, có thể có payload (số lượng tăng)
| { type: 'DECREMENT'; payload?: number } // Action giảm, có thể có payload
| { type: 'RESET' }; // Action reset
// 3. Định nghĩa hàm Reducer
// Nhận state hiện tại và action, trả về state mới
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT':
// Sử dụng payload nếu có, mặc định là 1
return { count: state.count + (action.payload ?? 1) };
case 'DECREMENT':
// Sử dụng payload nếu có, mặc định là 1
return { count: state.count - (action.payload ?? 1) };
case 'RESET':
return { count: 0 };
default:
// Luôn trả về state hiện tại nếu action không hợp lệ
return state;
}
}
// 4. Component sử dụng useReducer
function CounterWithReducer() {
const initialState: CounterState = { count: 0 };
// useReducer trả về state và hàm dispatch
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
{/* Gửi các action đến reducer bằng dispatch */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>Tăng 1</button>
<button onClick={() => dispatch({ type: 'DECREMENT', payload: 5 })}>Giảm 5</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
Giải thích code:
interface CounterState
: Định nghĩa kiểu cho state (trong ví dụ này là một object có thuộc tínhcount
).type CounterAction
: Định nghĩa các loại action có thể gửi đi. Đây là một union type của các object, mỗi object mô tả một action (có thuộc tínhtype
và có thể có thêmpayload
để mang dữ liệu). TypeScript ở đây buộc bạn phải định nghĩa rõ ràng cấu trúc của từng action.function counterReducer(state: CounterState, action: CounterAction): CounterState
: Hàm reducer nhận đúng kiểu state và action, và trả về đúng kiểu state mới. TypeScript giúp bạn đảm bảo điều này. Bên trong reducer, chúng ta dùngswitch
để xử lý các loại action khác nhau. Quan trọng: Reducer phải là hàm thuần khiết (pure function) - không thay đổi state hoặc props trực tiếp, không gọi API, không có side effects.const [state, dispatch] = useReducer(counterReducer, initialState)
: Gọi HookuseReducer
, truyền vào hàm reducer và state khởi tạo. Nó trả về state hiện tại và hàmdispatch
.dispatch({ type: 'INCREMENT' })
: Khi button được click, chúng ta gọidispatch
với một object action. Object này sẽ được truyền làm đối sốaction
cho hàmcounterReducer
.
useReducer
hữu ích khi logic cập nhật state phức tạp (phụ thuộc vào nhiều yếu tố, có nhiều trường hợp), hoặc khi state của bạn là một object có cấu trúc sâu và bạn muốn cập nhật các phần khác nhau một cách rõ ràng thông qua các action được đặt tên.
5. Chia sẻ State với useContext
Đôi khi, bạn có một state cần được truy cập bởi nhiều component ở các cấp độ lồng nhau khác nhau trong cây component. Việc truyền state thông qua props (gọi là "prop drilling") có thể trở nên cồng kềnh và khó quản lý. useContext
giúp giải quyết vấn đề này.
useContext
cho phép bạn tạo một "context" để chia sẻ dữ liệu (bao gồm cả state và các hàm cập nhật state) xuống dưới mà không cần truyền props qua từng cấp độ.
Cú pháp cơ bản với TypeScript:
import React, { createContext, useContext, useState, ReactNode } from 'react';
// 1. Định nghĩa kiểu dữ liệu cho Context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// 2. Tạo Context với giá trị mặc định (hoặc null, nhưng cần xử lý trong Provider)
// TypeScript đòi hỏi giá trị khởi tạo phù hợp với kiểu ThemeContextType | undefined
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 3. Tạo một Provider Component để bọc các component con cần truy cập Context
interface ThemeProviderProps {
children: ReactNode; // Kiểu cho nội dung bên trong Provider
}
function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
// Giá trị thực tế sẽ được cung cấp cho Context
const contextValue: ThemeContextType = {
theme,
toggleTheme,
};
return (
// Provider bọc lấy children và cung cấp contextValue
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}
// 4. Tạo một Hook tùy chỉnh để sử dụng Context dễ dàng hơn
function useTheme() {
const context = useContext(ThemeContext);
// Kiểm tra xem context có được sử dụng bên trong Provider không
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// 5. Component sử dụng Context (có thể ở bất kỳ đâu bên dưới Provider)
function ThemeSwitcher() {
// Sử dụng custom hook để lấy state và hàm từ Context
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Theme
</button>
);
}
function ThemedComponent() {
const { theme } = useTheme();
return (
<div style={{ background: theme === 'light' ? '#eee' : '#333', color: theme === 'light' ? '#333' : '#eee', padding: '20px', marginTop: '10px' }}>
<p>Đây là một component được áp dụng theme: {theme}</p>
<ThemeSwitcher /> {/* Component con cũng có thể sử dụng Context */}
</div>
);
}
// Cách sử dụng trong ứng dụng (ví dụ trong App.tsx)
/*
import React from 'react';
import { ThemeProvider, ThemedComponent } from './ThemeContextExample'; // Giả sử các component trên nằm trong file này
function App() {
return (
<ThemeProvider>
<h1>Ứng dụng của tôi</h1>
<ThemedComponent />
// Các component khác cũng có thể dùng useTheme() nếu nằm trong ThemeProvider
</ThemeProvider>
);
}
*/
Giải thích code:
interface ThemeContextType
: Định nghĩa kiểu dữ liệu cho giá trị mà context sẽ cung cấp. Ở đây làtheme
và hàmtoggleTheme
.createContext<ThemeContextType | undefined>(undefined)
: Tạo context.<ThemeContextType | undefined>
nói với TypeScript rằng giá trị context sẽ hoặc là đối tượngThemeContextType
hoặc làundefined
(giá trị khởi tạo).ThemeProvider
: Một component bắt buộc để "cung cấp" giá trị context. Statetheme
và hàmtoggleTheme
được khai báo ở đây bằnguseState
. Giá trị này được truyền vào propvalue
củaThemeContext.Provider
. Bất kỳ component nào được đặt bên trongThemeProvider
đều có thể truy cập giá trị này.useTheme
: Một custom hook để sử dụng context dễ dàng và an toàn hơn. Nó gọiuseContext(ThemeContext)
và kiểm tra xem kết quả có phải làundefined
không (điều này xảy ra nếuuseTheme
được gọi bên ngoàiThemeProvider
). Nếu hợp lệ, nó trả về giá trị context.ThemeSwitcher
vàThemedComponent
: Các component sử dụng custom hookuseTheme()
để lấy giá trịtheme
và hàmtoggleTheme
mà không cần nhận chúng qua props.
useContext
rất hữu ích cho việc chia sẻ các state "toàn cục" hoặc bán toàn cục như thông tin người dùng đăng nhập, cài đặt ngôn ngữ, chủ đề (theme)... Tuy nhiên, nó có thể gây re-render cho tất cả các component sử dụng context mỗi khi giá trị context thay đổi, ngay cả khi chúng chỉ quan tâm đến một phần nhỏ của giá trị đó. Đối với state thực sự phức tạp và lớn, các thư viện quản lý state chuyên dụng như Redux hoặc Zustand có thể phù hợp hơn, nhưng useContext
đủ mạnh mẽ cho nhiều trường hợp.
Comments