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ànchí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 Hook useState. <number>generic type mà TypeScript cung cấp, nói cho TypeScript biết rằng state count sẽ có kiểu dữ liệu là number. Giá trị 0 là giá trị khởi tạo cho state count.
  • 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àm setCount với giá trị mới. React sẽ nhận biết sự thay đổi này và re-render component MyComponent.

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ặc undefined, 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 handleNameChangehandleAgeChange, 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 object profile vào một object mới, sau đó ghi đè thuộc tính name hoặc age 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 state todos là một mảng rỗng các TodoItem.
  • 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ếu id 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ính completed đượ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ứ:

  1. Một hàm reducer: Hàm này nhận state hiện tại và một action (hành động) làm đối số, sau đó trả về state mới.
  2. Giá trị state khởi tạo.

Nó trả về:

  1. State hiện tại.
  2. Một hàm dispatch: Dùng để "gửi" các hành động đến hàm reducer.

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ính count).
  • 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ính type và có thể có thêm payload để 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ùng switch để 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 Hook useReducer, truyền vào hàm reducer và state khởi tạo. Nó trả về state hiện tại và hàm dispatch.
  • dispatch({ type: 'INCREMENT' }): Khi button được click, chúng ta gọi dispatch với một object action. Object này sẽ được truyền làm đối số action cho hàm counterReducer.

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  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àm toggleTheme.
  • 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ượng ThemeContextType hoặc là undefined (giá trị khởi tạo).
  • ThemeProvider: Một component bắt buộc để "cung cấp" giá trị context. State theme và hàm toggleTheme được khai báo ở đây bằng useState. Giá trị này được truyền vào prop value của ThemeContext.Provider. Bất kỳ component nào được đặt bên trong ThemeProvider đề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ọi useContext(ThemeContext) và kiểm tra xem kết quả có phải là undefined không (điều này xảy ra nếu useTheme được gọi bên ngoài ThemeProvider). Nếu hợp lệ, nó trả về giá trị context.
  • ThemeSwitcherThemedComponent: Các component sử dụng custom hook useTheme() để lấy giá trị theme và hàm toggleTheme 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

There are no comments at the moment.