Quản lý State với useReducer: Gọn gàng và Dễ đoán (React Roadmap)

Chào mừng trở lại với chuỗi bài viết “React Roadmap”! Chúng ta đã cùng nhau đi qua những khái niệm nền tảng của React như React là gì, Hiểu về JSX, Functional Components, và cách quản lý dữ liệu cơ bản với Props và State. Chúng ta cũng đã khám phá sức mạnh của useState và useEffect, cũng như cách tạo Custom Hooks để tái sử dụng logic. Hôm nay, chúng ta sẽ nâng cấp khả năng quản lý state của mình với một Hook mạnh mẽ khác: useReducer.

useState rất tuyệt vời cho việc quản lý các state đơn giản như boolean, string, number, hoặc các object/array nhỏ. Tuy nhiên, khi state của component trở nên phức tạp hơn—khi có nhiều giá trị state liên quan chặt chẽ với nhau, hoặc khi logic cập nhật state trở nên phức tạp và phụ thuộc vào state trước đó—thì việc sử dụng nhiều useState có thể dẫn đến mã khó đọc, khó bảo trì và dễ gây lỗi. Đây chính là lúc useReducer tỏa sáng.

useReducer Là Gì?

useReducer là một Hook trong React cung cấp một cách thay thế cho useState để quản lý state. Nó được xây dựng dựa trên một pattern phổ biến trong lập trình gọi là “reducer” (giảm thiểu), mà bạn có thể đã nghe đến trong thư viện quản lý state như Redux. Về cơ bản, useReducer giúp bạn tập trung logic cập nhật state vào một hàm duy nhất, gọi là reducer function.

Cơ chế hoạt động của useReducer xoay quanh ba khái niệm chính:

  • State: Biến state hiện tại của bạn (tương tự như state trong useState).
  • Action: Một object (thường có thuộc tính type) mô tả *điều gì đã xảy ra* (chứ không phải làm thế nào để thay đổi state). Ví dụ: { type: 'INCREMENT' }, { type: 'ADD_TODO', payload: 'Mua sữa' }.
  • Reducer Function: Một hàm thuần khiết (pure function) nhận state hiện tại và một action, sau đó trả về state mới. Hàm này chứa tất cả logic về cách state thay đổi dựa trên các action khác nhau. Signature của nó là (state, action) => newState.
  • Dispatch Function: Một hàm mà bạn nhận được từ useReducer. Bạn gọi hàm này và truyền vào một action object để “kích hoạt” quá trình cập nhật state. Khi dispatch được gọi, React sẽ chạy reducer function với state hiện tại và action bạn đã dispatch, sau đó cập nhật state component với giá trị mới được trả về từ reducer.

Hãy hình dung nó như một cỗ máy: Bạn gửi một yêu cầu (action) đến cỗ máy (reducer), cỗ máy xử lý yêu cầu đó dựa trên tình trạng hiện tại của nó (state hiện tại), và đưa ra một tình trạng mới (state mới).

Cú Pháp của useReducer

Cú pháp cơ bản của useReducer trông như sau:

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer: Là hàm reducer mà bạn đã định nghĩa.
  • initialState: Là giá trị khởi tạo cho state của bạn.
  • state: Biến state hiện tại, giống như biến state từ useState.
  • dispatch: Hàm dùng để gửi (dispatch) các action đến reducer.

useReducer cũng có một tham số thứ ba tùy chọn cho phép bạn khởi tạo state một cách “lười biếng” (lazy initialization), rất hữu ích nếu giá trị khởi tạo state cần tính toán phức tạp hoặc dựa trên props:

const [state, dispatch] = useReducer(reducer, initialArg, init);

Trong đó, init là một hàm. State khởi tạo sẽ được tính bằng cách gọi init(initialArg). Nếu tham số thứ ba được truyền vào, initialState (tham số thứ hai) sẽ được sử dụng như là đối số đầu tiên cho hàm init.

Ví Dụ Đơn Giản: Bộ Đếm (Counter)

Chúng ta hãy bắt đầu với một ví dụ kinh điển: Bộ đếm. Mặc dù bạn hoàn toàn có thể dùng useState cho việc này, ví dụ này giúp minh họa cách hoạt động của useReducer một cách rõ ràng.

Đầu tiên, định nghĩa state khởi tạo:

const initialState = { count: 0 };

Tiếp theo, định nghĩa hàm reducer. Hàm này nhận state hiện tại và action, sau đó quyết định cách cập nhật state:

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState; // Có thể reset về giá trị khởi tạo ban đầu
    default:
      // Luôn ném lỗi nếu gặp action không xác định
      throw new Error();
  }
}

Lưu ý rằng hàm reducer phải là hàm *thuần khiết*: nó không được thay đổi state trực tiếp (immutable update) mà phải trả về một object state *mới*. Nó cũng không được thực hiện các tác vụ phụ (side effects) như gọi API, DOM manipulation,…

Bây giờ, sử dụng useReducer trong functional component:

import React, { useReducer } from 'react';

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  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' })}>Reset</button>
    </div>
  );
}

export default Counter;

Trong ví dụ này:

  • State được lưu trong object { count: 0 }, truy cập thông qua biến state.
  • Các nút nhấn không trực tiếp thay đổi state. Thay vào đó, chúng gọi hàm dispatch và truyền vào một object action mô tả ý định của người dùng (ví dụ: { type: 'increment' }).
  • Hàm dispatch gửi action này đến hàm reducer.
  • Hàm reducer nhận action và state hiện tại, sau đó trả về state mới dựa trên loại action.
  • React nhận state mới từ reducer và re-render component Counter với giá trị state đã cập nhật.

Cách tiếp cận này có vẻ dài dòng hơn useState(0)setCount(count + 1) cho một ví dụ đơn giản như thế này. Nhưng nó thể hiện rõ ràng sự tách biệt giữa “điều gì xảy ra” (action) và “làm thế nào để thay đổi state” (reducer logic), điều này trở nên cực kỳ quan trọng khi state và logic trở nên phức tạp hơn.

Tại Sao Nên Dùng useReducer? Lợi Ích Mang Lại

Mặc dù useState là lựa chọn mặc định cho hầu hết các state đơn giản, useReducer mang lại nhiều lợi ích đáng kể trong các trường hợp phức tạp:

  1. Tập Trung Logic Cập Nhật State: Thay vì có nhiều hàm setter state rải rác trong component (và có thể cả trong useEffect), tất cả logic cập nhật state được gom lại trong một hàm reducer duy nhất. Điều này giúp mã nguồn gọn gàng, dễ theo dõi và bảo trì hơn.
  2. Quản Lý State Liên Quan: Khi state của bạn là một object hoặc array phức tạp với nhiều thuộc tính liên quan và các thuộc tính này thường thay đổi cùng nhau hoặc dựa trên sự thay đổi của nhau, useReducer giúp quản lý các mối quan hệ này một cách mạch lạc hơn. Thay vì gọi nhiều hàm setX, setY, setZ, bạn dispatch một action duy nhất, và reducer xử lý tất cả các cập nhật liên quan trong một lần.
  3. State Cập Nhật Phụ Thuộc vào State Trước Đó: Reducer function luôn nhận state *mới nhất* làm đối số đầu tiên. Điều này đảm bảo rằng logic cập nhật state luôn dựa trên giá trị state hiện tại chính xác, tránh được các vấn đề tiềm ẩn khi sử dụng các hàm setter của useState với các giá trị state cũ (stale closures).
  4. Khả Năng Dự Đoán và Kiểm Thử: Vì reducer là một hàm thuần khiết, với cùng một input (state hiện tại và action), nó luôn trả về cùng một output (state mới). Điều này làm cho logic cập nhật state trở nên dễ dự đoán và cực kỳ dễ kiểm thử một cách độc lập, không cần render component.
  5. Truyền Dispatch Xuống Dưới Dễ Dàng Hơn: Khi cần truyền khả năng thay đổi state xuống các component con sâu hơn (prop drilling), việc truyền một hàm dispatch duy nhất thường đơn giản hơn nhiều so với việc truyền xuống nhiều hàm setter từ useState. Khi kết hợp với Context API (chủ đề của bài tiếp theo trong Roadmap), bạn có thể tạo ra một giải pháp quản lý state toàn cục mạnh mẽ và sạch sẽ.

useReducer vs. useState: Chọn Công Cụ Phù Hợp

Khi nào nên dùng useReducer thay vì useState? Dưới đây là bảng so sánh giúp bạn đưa ra quyết định:

Tính năng useState useReducer
Độ phức tạp State Đơn giản (boolean, number, string, object/array nhỏ và độc lập) Phức tạp (object/array lớn, nhiều thuộc tính liên quan)
Logic cập nhật State Đơn giản, độc lập (ví dụ: toggle boolean, increment/decrement một số) Phức tạp, phụ thuộc vào state trước đó, hoặc nhiều cập nhật liên quan xảy ra cùng lúc
Số lượng State liên quan Ít, các giá trị state không liên quan chặt chẽ Nhiều, các giá trị state có mối quan hệ logic
Khả năng dự đoán & Kiểm thử Thường đơn giản, nhưng logic phức tạp có thể nằm rải rác Rất cao, logic cập nhật state tập trung trong hàm thuần khiết (reducer)
Mã boilerplate Ít cho state đơn giản Nhiều hơn cho state đơn giản, nhưng ít hơn cho state phức tạp khi so với nhiều useState
Tái sử dụng logic cập nhật Khó tái sử dụng trực tiếp Có thể tái sử dụng logic reducer giữa các component

Như bảng trên cho thấy, useReducer không phải là sự thay thế cho useState, mà là một công cụ bổ sung trong hộp công cụ của bạn. Hãy chọn useState cho những gì nó làm tốt (state đơn giản, độc lập) và useReducer khi bạn đối mặt với sự phức tạp.

Ví Dụ Thực Tế Hơn: Quản lý State Form

Hãy xem xét một ví dụ phức tạp hơn một chút: một form với nhiều trường nhập liệu và logic validation đơn giản. Quản lý state cho từng trường bằng useState có thể nhanh chóng trở nên cồng kềnh.

State khởi tạo có thể là một object:

const initialFormState = {
  name: '',
  email: '',
  password: '',
  errors: {},
  isSubmitting: false,
};

Các action có thể bao gồm thay đổi giá trị trường, báo lỗi validation, gửi form, reset form, v.v. Mỗi action sẽ cần một type và có thể có thêm dữ liệu (payload) như tên trường và giá trị mới:

const formReducer = (state, action) => {
  switch (action.type) {
    case 'HANDLE_INPUT_CHANGE':
      return {
        ...state, // Luôn giữ lại state cũ
        [action.field]: action.value, // Cập nhật trường cụ thể
        errors: { // Có thể xóa lỗi khi người dùng gõ lại
            ...state.errors,
            [action.field]: undefined
        }
      };
    case 'SET_ERRORS':
        return {
            ...state,
            errors: action.errors // Cập nhật object lỗi
        };
    case 'SET_SUBMITTING':
        return {
            ...state,
            isSubmitting: action.isSubmitting
        };
    case 'RESET_FORM':
      return initialFormState; // Reset về trạng thái ban đầu
    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
};

Và đây là cách sử dụng trong component form:

import React, { useReducer } from 'react';

const initialFormState = {
  name: '',
  email: '',
  password: '',
  errors: {},
  isSubmitting: false,
};

const formReducer = (state, action) => {
  switch (action.type) {
    case 'HANDLE_INPUT_CHANGE':
      return {
        ...state,
        [action.field]: action.value,
        errors: {
            ...state.errors,
            [action.field]: undefined
        }
      };
    case 'SET_ERRORS':
        return {
            ...state,
            errors: action.errors
        };
    case 'SET_SUBMITTING':
        return {
            ...state,
            isSubmitting: action.isSubmitting
        };
    case 'RESET_FORM':
      return initialFormState;
    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
};


function SimpleForm() {
  const [formState, dispatch] = useReducer(formReducer, initialFormState);

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    dispatch({
      type: 'HANDLE_INPUT_CHANGE',
      field: name,
      value: value,
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    // Logic validation ở đây
    const formErrors = {};
    if (!formState.name) formErrors.name = 'Tên không được để trống';
    if (!formState.email) formErrors.email = 'Email không được để trống';
    // ... các validation khác ...

    if (Object.keys(formErrors).length > 0) {
      dispatch({ type: 'SET_ERRORS', errors: formErrors });
    } else {
      dispatch({ type: 'SET_SUBMITTING', isSubmitting: true });
      console.log('Form data:', formState);
      // Simulate API call
      setTimeout(() => {
        alert('Form submitted!');
        dispatch({ type: 'SET_SUBMITTING', isSubmitting: false });
        dispatch({ type: 'RESET_FORM' }); // Reset form sau khi submit
      }, 1000);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Tên:</label>
        <input
          type="text"
          name="name"
          value={formState.name}
          onChange={handleInputChange}
        />
        {formState.errors.name && <p style={{ color: 'red' }}>{formState.errors.name}</p>}
      </div>
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={formState.email}
          onChange={handleInputChange}
        />
        {formState.errors.email && <p style={{ color: 'red' }}>{formState.errors.email}</p>}
      </div>
      <div>
        <label>Mật khẩu:</label>
        <input
          type="password"
          name="password"
          value={formState.password}
          onChange={handleInputChange}
        />
        {formState.errors.password && <p style={{ color: 'red' }}>{formState.errors.password}</p>}
      </div>
      <button type="submit" disabled={formState.isSubmitting}>
        {formState.isSubmitting ? 'Đang gửi...' : 'Gửi Form'}
      </button>
      <button type="button" onClick={() => dispatch({ type: 'RESET_FORM' })}>
        Reset
      </button>
    </form>
  );
}

export default SimpleForm;

Trong ví dụ này, toàn bộ state của form (các giá trị trường, lỗi, trạng thái gửi) được quản lý trong một object duy nhất bởi useReducer. Hàm formReducer xử lý tất cả các loại thay đổi có thể xảy ra với form state. Việc này giúp logic quản lý form state trở nên tập trung, dễ hiểu và dễ mở rộng hơn nhiều so với việc sử dụng useState riêng cho từng trường và trạng thái.

Khi Nào Không Nên Dùng useReducer?

Như đã đề cập, useReducer không phải là giải pháp cho mọi vấn đề về state. Đối với các state rất đơn giản và không liên quan đến state khác (ví dụ: state chỉ để bật/tắt một modal, hiển thị/ẩn một phần tử), useState là đủ và làm cho code dễ đọc hơn nhiều.

Sử dụng useReducer cho những trường hợp quá đơn giản có thể làm tăng boilerplate code một cách không cần thiết (phải định nghĩa action type, reducer, dispatch). Hãy cân nhắc độ phức tạp của state và logic cập nhật trước khi quyết định.

Kết Luận

useReducer là một Hook mạnh mẽ trong React, cung cấp một cách tiếp cận có cấu trúc và dễ dự đoán hơn để quản lý state, đặc biệt là state phức tạp hoặc state có logic cập nhật phụ thuộc vào nhau. Bằng cách tách biệt “điều gì xảy ra” (action) khỏi “cách thay đổi state” (reducer), bạn có thể viết mã quản lý state gọn gàng, dễ kiểm thử và bảo trì hơn.

Trong lộ trình học React của mình, việc nắm vững useReducer là một bước quan trọng giúp bạn xử lý các tình huống quản lý state phức tạp hơn mà không cần ngay lập tức nhảy sang các thư viện quản lý state toàn cục lớn như Redux. Nó cũng là nền tảng để hiểu cách thức hoạt động của các thư viện đó.

Tiếp theo trong chuỗi bài viết React Roadmap, chúng ta sẽ khám phá Context API, một công cụ tuyệt vời khi kết hợp với useReducer để chia sẻ state phức tạp giữa các component mà không cần prop drilling. Hãy cùng chờ đón nhé!

Các bài viết khác trong chuỗi React Roadmap:

Chỉ mục