Bài 16.2: useReducer với TypeScript

Chào mừng bạn đến với bài viết chuyên sâu về useReducer kết hợp với TypeScript trong React!

Khi phát triển các ứng dụng React, việc quản lý trạng thái (state) là một phần không thể thiếu. Với các trạng thái đơn giản, hook useState hoạt động rất hiệu quả. Tuy nhiên, khi logic cập nhật trạng thái trở nên phức tạp hơn, hoặc khi nhiều phần của trạng thái phụ thuộc lẫn nhau, useState có thể dẫn đến những đoạn code khó đọc, khó kiểm thử và dễ gây lỗi. Đây chính là lúc useReducer tỏa sáng, mang lại một cách tiếp cận có cấu trúc và dự đoán được hơn.

Và khi kết hợp useReducer với sức mạnh tĩnh của TypeScript, chúng ta sẽ có một công cụ cực kỳ mạnh mẽ để xây dựng các ứng dụng mạnh mẽ, an toàndễ bảo trì.

useReducer là gì và tại sao lại cần nó?

Hãy hình dung trạng thái của ứng dụng của bạn giống như một... trạng thái vậy (đúng như tên gọi!). Trạng thái này thay đổi theo thời gian dựa trên các hành động (actions) xảy ra trong ứng dụng (ví dụ: người dùng click nút, dữ liệu được fetch về).

useReducer cung cấp một mô hình để quản lý sự thay đổi trạng thái này theo nguyên lý reducer function. Về cơ bản, reducer là một hàm nhận vào trạng thái hiện tại và một hành động, sau đó trả về trạng thái mới.

useReducer rất hữu ích khi:

  1. Logic cập nhật trạng thái phức tạp, liên quan đến nhiều hành động khác nhau.
  2. Trạng thái bao gồm nhiều giá trị phụ thuộc hoặc liên quan đến nhau.
  3. Bạn muốn tách biệt logic cập nhật trạng thái ra khỏi component.
  4. Bạn muốn kiểm thử logic cập nhật trạng thái một cách dễ dàng hơn.

Cú pháp cơ bản của useReducer:

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer: Là hàm reducer của bạn.
  • initialState: Là giá trị khởi tạo của trạng thái.
  • state: Là giá trị trạng thái hiện tại.
  • dispatch: Là một hàm bạn dùng để "gửi" (dispatch) các hành động đến reducer. Khi bạn gọi dispatch(action), React sẽ chạy hàm reducer với trạng thái hiện tại và action đó, sau đó cập nhật trạng thái component với giá trị trả về từ reducer.

Nâng cấp với TypeScript: Sự an toàn và rõ ràng

useReducer đã tốt, nhưng khi làm việc với các dự án lớn hoặc đội nhóm, việc đảm bảo các hành động được gửi đi là hợp lệ và trạng thái có cấu trúc đúng là vô cùng quan trọng. TypeScript giúp chúng ta đạt được điều này bằng cách định nghĩa rõ ràng kiểu dữ liệu cho trạng thái và các hành động.

Định nghĩa kiểu dữ liệu cho State và Actions

Đây là bước đầu tiên và quan trọng nhất khi sử dụng useReducer với TypeScript. Chúng ta cần định nghĩa:

  1. Kiểu dữ liệu cho trạng thái (State Type): Mô tả cấu trúc của toàn bộ trạng thái mà reducer quản lý.
  2. Kiểu dữ liệu cho các hành động (Action Types): Định nghĩa các loại hành động khác nhau mà reducer có thể xử lý, bao gồm cả dữ liệu (payload) đi kèm với hành động đó.

Một pattern phổ biến và mạnh mẽ cho Action Types là sử dụng Union Type (kiểu kết hợp) với một thuộc tính phân biệt (discriminant property), thường là type.

Ví dụ về một counter đơn giản:

// 1. Định nghĩa kiểu dữ liệu cho State
interface CounterState {
  count: number;
}

// 2. Định nghĩa kiểu dữ liệu cho Actions
// Sử dụng Union Type với 'type' là thuộc tính phân biệt
type CounterAction =
  | { type: 'increment' } // Hành động tăng, không cần thêm dữ liệu
  | { type: 'decrement' } // Hành động giảm, không cần thêm dữ liệu
  | { type: 'reset', payload: number }; // Hành động reset, cần dữ liệu là giá trị mới

Trong ví dụ trên, CounterAction có thể là một trong ba loại hành động được định nghĩa. TypeScript sẽ hiểu rằng nếu action.type'reset', thì chắc chắn nó sẽ có thuộc tính payload với kiểu number. Điều này cực kỳ hữu ích trong hàm reducer!

Định nghĩa kiểu dữ liệu cho Reducer Function

Hàm reducer của bạn cần được gán kiểu dữ liệu phù hợp. Kiểu dữ liệu này sẽ mô tả hàm nhận vào StateAction, và trả về State.

// 3. Định nghĩa kiểu dữ liệu và viết hàm Reducer
const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      // TypeScript biết rằng nếu action.type là 'reset', thì action sẽ có payload: number
      return { count: action.payload };
    default:
      // Luôn trả về trạng thái hiện tại nếu hành động không được xử lý
      return state;
  }
};

Ở đây, chúng ta đã gán kiểu CounterState cho tham số state, CounterAction cho tham số action, và chỉ định kiểu trả về là CounterState. Bên trong hàm, khi chúng ta xử lý case 'reset', TypeScript tự động thu hẹp kiểu của action chỉ còn là { type: 'reset', payload: number }, cho phép chúng ta truy cập an toàn vào action.payload. Nếu bạn cố gắng truy cập action.payload trong case 'increment', TypeScript sẽ báo lỗi ngay lập tức!

Sử dụng useReducer với các kiểu đã định nghĩa

Cuối cùng, bạn sử dụng hook useReducer và truyền các kiểu dữ liệu đã định nghĩa vào:

import React, { useReducer } from 'react';

// ... (Định nghĩa các kiểu CounterState và CounterAction ở trên)
// ... (Viết hàm counterReducer ở trên)

const CounterComponent: React.FC = () => {
  // Sử dụng useReducer với reducer và trạng thái khởi tạo
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Tăng</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Giảm</button>
      <button onClick={() => dispatch({ type: 'reset', payload: 0 })}>Reset về 0</button>
      {/* TypeScript sẽ báo lỗi nếu bạn gửi một hành động không hợp lệ */}
      {/* <button onClick={() => dispatch({ type: 'unknown' })}>Lỗi?</button> */}
      {/* TypeScript cũng báo lỗi nếu bạn gửi hành động reset mà thiếu payload */}
      {/* <button onClick={() => dispatch({ type: 'reset' })}>Lỗi payload?</button> */}
    </div>
  );
};

export default CounterComponent;

TypeScript tự động suy luận kiểu của stateCounterState và kiểu của dispatch là một hàm nhận vào CounterAction. Điều này có nghĩa là khi bạn gọi dispatch, TypeScript sẽ kiểm tra xem đối số bạn truyền vào có khớp với một trong các loại trong CounterAction hay không. Nếu không, bạn sẽ nhận được lỗi compile-time!

Ví dụ nâng cao hơn: Quản lý form

Hãy xem xét một ví dụ phức tạp hơn một chút: quản lý trạng thái của một form đơn giản.

import React, { useReducer, ChangeEvent } from 'react';

// 1. Định nghĩa kiểu dữ liệu cho State
interface FormState {
  name: string;
  email: string;
  age: number | ''; // Cho phép rỗng ban đầu
  isSubmitting: boolean;
  error: string | null;
}

// 2. Định nghĩa kiểu dữ liệu cho Actions
// Sử dụng Union Type với thuộc tính 'type'
type FormAction =
  | { type: 'SET_FIELD'; field: keyof FormState; value: any } // Action chung để set giá trị cho một trường
  | { type: 'SUBMIT_START' } // Bắt đầu submit
  | { type: 'SUBMIT_SUCCESS' } // Submit thành công
  | { type: 'SUBMIT_ERROR'; payload: string } // Submit lỗi, cần thông báo lỗi
  | { type: 'RESET_FORM' }; // Reset form

// Trạng thái khởi tạo
const initialFormState: FormState = {
  name: '',
  email: '',
  age: '',
  isSubmitting: false,
  error: null,
};

// 3. Định nghĩa kiểu dữ liệu và viết hàm Reducer
const formReducer = (state: FormState, action: FormAction): FormState => {
  switch (action.type) {
    case 'SET_FIELD':
      // TypeScript kiểm tra action.field có phải là key hợp lệ của FormState không
      // TypeScript cũng kiểm tra action.value có tương thích với kiểu của trường đó không (đến một mức độ nào đó, 'any' ở đây cần cẩn thận hoặc dùng generic)
      // Để an toàn hơn với 'SET_FIELD', bạn có thể cần action payload chi tiết hơn
      // Ví dụ: action: { type: 'SET_NAME', value: string } | { type: 'SET_EMAIL', value: string } ...
      // Tuy nhiên, cách dùng keyof State như trên là một pattern phổ biến cho form đơn giản
      return {
        ...state,
        [action.field]: action.value,
      };
    case 'SUBMIT_START':
      return {
        ...state,
        isSubmitting: true,
        error: null, // Xóa lỗi cũ khi bắt đầu submit
      };
    case 'SUBMIT_SUCCESS':
      return {
        ...initialFormState, // Reset form về trạng thái ban đầu
        isSubmitting: false, // Dừng submitting
      };
    case 'SUBMIT_ERROR':
      // TypeScript biết action.payload là string
      return {
        ...state,
        isSubmitting: false, // Dừng submitting
        error: action.payload, // Lưu thông báo lỗi
      };
    case 'RESET_FORM':
        return {
            ...initialFormState // Reset form hoàn toàn
        }
    default:
      return state;
  }
};

// Component sử dụng useReducer
const FormComponent: React.FC = () => {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value, type } = e.target;
    // Ép kiểu value tùy thuộc vào type của input
    const fieldValue = type === 'number' ? (value === '' ? '' : Number(value)) : value;

    // Dispatch action SET_FIELD
    dispatch({
      type: 'SET_FIELD',
      field: name as keyof FormState, // Ép kiểu tên input thành key của FormState
      value: fieldValue
    });
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    // Giả lập một API call
    try {
      console.log('Submitting:', state);
      await new Promise(resolve => setTimeout(resolve, 1000)); // Chờ 1 giây
      if (state.name === 'error') { // Giả lập lỗi nếu tên là 'error'
         throw new Error('Lỗi từ server!');
      }
      dispatch({ type: 'SUBMIT_SUCCESS' });
      alert('Submit thành công!');
    } catch (err: any) {
      dispatch({ type: 'SUBMIT_ERROR', payload: err.message });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Tên:</label>
        <input
          type="text"
          id="name"
          name="name"
          value={state.name}
          onChange={handleInputChange}
          disabled={state.isSubmitting}
        />
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={state.email}
          onChange={handleInputChange}
          disabled={state.isSubmitting}
        />
      </div>
      <div>
        <label htmlFor="age">Tuổi:</label>
        <input
          type="number"
          id="age"
          name="age"
          value={state.age}
          onChange={handleInputChange}
          disabled={state.isSubmitting}
        />
      </div>

      {state.error && <p style={{ color: 'red' }}>Lỗi: {state.error}</p>}

      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Đang gửi...' : 'Gửi Form'}
      </button>
       <button type="button" onClick={() => dispatch({ type: 'RESET_FORM' })} disabled={state.isSubmitting}>
        Reset
      </button>
    </form>
  );
};

export default FormComponent;

Trong ví dụ form này, chúng ta thấy:

  • Trạng thái (FormState) là một object phức tạp hơn.
  • Các hành động (FormAction) được định nghĩa rõ ràng, bao gồm cả hành động chung SET_FIELD sử dụng keyof FormState để đảm bảo tên trường hợp lệ và các hành động dành riêng cho quy trình submit.
  • Hàm reducer xử lý các hành động này, cập nhật trạng thái một cách logic.
  • TypeScript giúp kiểm tra tại compile-time rằng chúng ta chỉ dispatch các hành động đã định nghĩa và truyền đúng payload (ví dụ: payload trong SUBMIT_ERROR phải là string).

Lợi ích khi sử dụng useReducer với TypeScript

Tóm lại, việc kết hợp useReducer và TypeScript mang lại nhiều lợi ích đáng kể:

  • Code Rõ Ràng và Dễ Đọc Hơn: Bằng cách định nghĩa rõ ràng các kiểu State và Action, mã nguồn của bạn trở nên dễ hiểu hơn về cấu trúc dữ liệu và luồng cập nhật trạng thái.
  • Phòng Ngừa Lỗi Compile-time: TypeScript bắt được nhiều lỗi tiềm ẩn ngay trong quá trình phát triển, chẳng hạn như gửi sai loại hành động, truy cập thuộc tính không tồn tại trên state hoặc action.
  • Dễ Dàng Tái Cấu Trúc (Refactor): Khi bạn thay đổi cấu trúc trạng thái hoặc thêm/bỏ hành động, TypeScript sẽ chỉ ra tất cả các nơi cần cập nhật, giảm thiểu rủi ro phá vỡ ứng dụng.
  • Kiểm Thử Dễ Dàng Hơn: Logic xử lý trạng thái được tập trung trong hàm reducer, vốn là một hàm "thuần khiết" (pure function). Việc kiểm thử các hàm thuần khiết này dễ hơn rất nhiều so với kiểm thử logic nằm rải rác trong các hàm useStateuseEffect.
  • Quản Lý Trạng Thái Phức Tạp Tốt Hơn: Đối với các ứng dụng có logic trạng thái phức tạp, useReducer cùng TypeScript cung cấp một mô hình có tổ chức và dự đoán được, giúp mở rộng và bảo trì ứng dụng dễ dàng hơn.

Sử dụng useReducer có thể có một chút đường cong học hỏi ban đầu so với useState, nhưng khi kết hợp với sự an toàn và rõ ràng của TypeScript, nó trở thành một công cụ cực kỳ mạnh mẽ để xây dựng các ứng dụng React chất lượng cao.

Comments

There are no comments at the moment.