Bài 21.4: Async actions với Redux Thunk

Chào mừng trở lại với series về Lập trình Web Front-end! Chúng ta đã cùng nhau đi qua các khái niệm cơ bản của Redux: Store, Actions, và Reducers. Bạn đã biết cách dispatch các action là plain object (các đối tượng JavaScript đơn giản) để cập nhật state một cách đồng bộ.

Tuy nhiên, thế giới thực không phải lúc nào cũng đồng bộ. Chúng ta thường xuyên cần thực hiện các tác vụ bất đồng bộ như:

  • Gọi API để lấy dữ liệu từ máy chủ.
  • Lưu dữ liệu vào localStorage.
  • Thực hiện các thao tác dựa trên timer (delay, debounce).

Vấn đề là Redux store chỉ hiểu và xử lý các plain object action một cách đồng bộ. Nếu bạn cố gắng dispatch một Promise, một async function hay bất cứ thứ gì không phải plain object, Redux sẽ báo lỗi. Vậy làm thế nào để chúng ta tích hợp các tác vụ bất đồng bộ này vào luồng Redux?

Đây chính là lúc Middleware phát huy sức mạnh, và Redux Thunk là một trong những middleware phổ biến nhất để giải quyết vấn đề async actions một cách đơn giản.

Vấn đề với Async Actions trong Redux thuần túy

Nhắc lại một chút, trong Redux cơ bản, một action là một object có trường type:

// Một action đồng bộ
{
  type: 'TANG_SO_DEM',
  payload: 1
}

Khi bạn gọi store.dispatch({ type: 'TANG_SO_DEM', payload: 1 }), action này sẽ ngay lập tức được gửi tới reducer, reducer tính toán state mới và store cập nhật state. Mọi thứ diễn ra tức thìtheo thứ tự.

Bây giờ, hãy tưởng tượng bạn muốn lấy danh sách người dùng từ một API. Quá trình này sẽ mất một khoảng thời gian (ví dụ vài trăm mili giây hoặc vài giây). Bạn không thể dispatch một action kiểu { type: 'LAY_DU_LIEU_NGUOI_DUNG_THANH_CONG', payload: duLieuNguoiDung } ngay lập tức, bởi vì bạn chưa có duLieuNguoiDung. Dữ liệu chỉ có sau khi request API hoàn thành.

Nếu bạn đặt logic gọi API trực tiếp vào component React, điều đó sẽ phá vỡ nguyên tắc tách biệt: component nên lo việc hiển thị UI, không phải xử lý logic fetching data phức tạp. Chúng ta muốn logic này nằm trong "lớp Redux", gần với nơi quản lý state tập trung.

Chúng ta cần một cách để:

  1. Bắt đầu một tác vụ bất đồng bộ.
  2. Chờ đợi tác vụ đó hoàn thành.
  3. Dựa vào kết quả (thành công hay thất bại), sau đó dispatch một hoặc nhiều action đồng bộ thông thường để cập nhật state (ví dụ: action 'ĐANG_TAI', action 'TAI_THANH_CONG', action 'TAI_THAT_BAI').

Middleware là giải pháp hoàn hảo cho việc này. Nó có thể chặn một action trước khi nó đến reducer, thực hiện một số công việc và sau đó quyết định sẽ tiếp tục gửi action ban đầu đi, gửi action khác đi, hay thậm chí là không làm gì cả.

Redux Thunk là gì và hoạt động như thế nào?

Redux Thunk là một middleware rất nhỏ gọn. Nhiệm vụ chính của nó là cho phép bạn viết action creators trả về một function thay vì một plain object action.

Khi Redux Thunk middleware được áp dụng vào store, nó sẽ kiểm tra mọi thứ được dispatch:

  1. Nếu thứ được dispatch là một plain object (action thông thường): Thunk bỏ qua và để action đó tiếp tục đi đến middleware tiếp theo hoặc đến reducer.
  2. Nếu thứ được dispatch là một function: Thunk sẽ không gửi function này đến reducer. Thay vào đó, nó sẽ thực thi function đó. Quan trọng hơn, Thunk sẽ tự động truyền hai đối số vào function này: dispatchgetState.

Đây chính là mấu chốt! Bên trong cái function mà chúng ta trả về (thường được gọi là thunk), chúng ta có toàn quyền thực hiện các tác vụ bất đồng bộ (gọi API, setTimeout, ...). Khi tác vụ bất đồng bộ đó hoàn thành, chúng ta có thể sử dụng hàm dispatch được cung cấp để dispatch các plain object action thông thường. Chúng ta cũng có thể truy cập state hiện tại bằng getState nếu cần.

Cấu trúc cơ bản của một thunk action creator trông như thế này:

// Đây là một THUNK action creator
const doSomethingAsync = (someValue) => {
  // Hàm này sẽ được Redux Thunk gọi và truyền dispatch, getState
  return (dispatch, getState) => {
    // Thực hiện tác vụ bất đồng bộ ở đây...
    // Ví dụ: setTimeout, fetch(), axios(), ...

    // Khi tác vụ bất đồng bộ hoàn thành, dispatch các action thông thường
    setTimeout(() => {
      console.log('Tác vụ bất đồng bộ hoàn thành sau 1 giây');
      console.log('Có thể truy cập state hiện tại:', getState());

      dispatch({
        type: 'TAC_VU_HOAN_THANH',
        payload: someValue + ' - đã xử lý'
      });
    }, 1000);
  };
};

// Để sử dụng, bạn dispatch cái THUNK action creator:
// store.dispatch(doSomethingAsync('dữ liệu ban đầu'));
// Redux Thunk sẽ bắt lấy doSomethingAsync() và thực thi cái function bên trong nó.

Cài đặt và Cấu hình Redux Thunk

Sử dụng Redux Thunk rất đơn giản.

  1. Cài đặt:

    npm install redux-thunk redux react-redux
    # hoặc với yarn
    yarn add redux-thunk redux react-redux
    

    Lưu ý: reduxreact-redux đã có sẵn nếu bạn đang xây dựng ứng dụng React với Redux.

  2. Áp dụng Middleware vào Store: Bạn cần sử dụng hàm applyMiddleware từ Redux khi tạo store.

    // store.js
    import { createStore, applyMiddleware } from 'redux';
    import { thunk } from 'redux-thunk'; // Hoặc import thunk từ 'redux-thunk' tùy phiên bản/cách import
    import rootReducer from './reducers'; // Reducer gốc của bạn
    
    const store = createStore(
      rootReducer,
      // applyMiddleware sẽ gói store ban đầu lại để xử lý middleware
      applyMiddleware(thunk)
      // Nếu có nhiều middleware, bạn liệt kê chúng theo thứ tự
      // applyMiddleware(middleware1, thunk, middleware2)
    );
    
    export default store;
    

    Chú ý: Nếu bạn sử dụng Redux Toolkit (@reduxjs/toolkit), Thunk đã được tích hợp sẵn và cấu hình tự động trong configureStore, bạn không cần phải làm gì thêm để áp dụng nó.

    // Với Redux Toolkit (đơn giản hơn)
    import { configureStore } from '@reduxjs/toolkit';
    import rootReducer from './reducers';
    
    const store = configureStore({
      reducer: rootReducer,
      // Middleware mặc định của configureStore đã bao gồm redux-thunk
      // middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(customMiddleware)
      // Nếu bạn không có middleware tùy chỉnh nào khác, phần này không cần thiết.
    });
    
    export default store;
    

    Trong ví dụ này, chúng ta sẽ tập trung vào cách viết thunk action creator khi Thunk đã được áp dụng (dù bằng applyMiddleware hay configureStore).

Ví dụ Minh Họa: Lấy Dữ Liệu Người Dùng từ API

Hãy xem xét ví dụ cụ thể về việc lấy danh sách người dùng từ một API giả định.

Chúng ta sẽ cần 3 action type để mô tả các trạng thái của quá trình lấy dữ liệu:

  • FETCH_USERS_REQUEST: Bắt đầu yêu cầu.
  • FETCH_USERS_SUCCESS: Yêu cầu thành công, có dữ liệu.
  • FETCH_USERS_FAILURE: Yêu cầu thất bại, có lỗi.

Và một reducer để quản lý state liên quan đến người dùng, bao gồm trạng thái tải (loading), danh sách người dùng (users), và thông báo lỗi (error).

1. Định nghĩa Action Types
// actionTypes.js
export const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
2. Viết Thunk Action Creator

Đây là phần chính sử dụng Redux Thunk. Chúng ta sẽ viết một hàm gọi là fetchUsers trả về một function (thunk).

// userActions.js
import {
  FETCH_USERS_REQUEST,
  FETCH_USERS_SUCCESS,
  FETCH_USERS_FAILURE
} from './actionTypes';
import axios from 'axios'; // Sử dụng axios hoặc fetch API

// Đây là Thunk Action Creator
export const fetchUsers = () => {
  // Thunk function nhận dispatch và getState làm đối số
  return async (dispatch, getState) => {
    // Bước 1: Dispatch action báo hiệu bắt đầu request
    // Plain object action, sẽ đi thẳng đến reducer
    dispatch({ type: FETCH_USERS_REQUEST });

    try {
      // Bước 2: Thực hiện tác vụ bất đồng bộ (gọi API)
      const response = await axios.get('https://jsonplaceholder.typicode.com/users'); // API giả
      const users = response.data;

      // Bước 3: Nếu thành công, dispatch action success với payload là dữ liệu
      // Plain object action, sẽ đi thẳng đến reducer
      dispatch({
        type: FETCH_USERS_SUCCESS,
        payload: users
      });

      // Bạn có thể dispatch thêm các action khác nếu cần thiết
      // dispatch({ type: 'LOG_SUCCESS', message: 'Users fetched successfully' });

      // Bạn cũng có thể đọc state hiện tại nếu logic yêu cầu
      // const currentState = getState();
      // console.log('State sau khi fetch thành công:', currentState);

    } catch (error) {
      // Bước 4: Nếu thất bại, dispatch action failure với payload là lỗi
      // Plain object action, sẽ đi thẳng đến reducer
      dispatch({
        type: FETCH_USERS_FAILURE,
        payload: error.message // Hoặc đối tượng lỗi đầy đủ
      });

      // dispatch({ type: 'LOG_ERROR', message: 'Failed to fetch users' });
    }
  };
};

// Các action creators đồng bộ khác (nếu có) vẫn được viết như bình thường:
// export const addUser = (user) => ({
//   type: 'ADD_USER',
//   payload: user
// });

Giải thích code:

  • fetchUsers là một hàm bên ngoài, nó không phải là action creator thông thường trả về { type: '...' }. Thay vào đó, nó trả về một hàm khác.
  • Cái hàm bên trong async (dispatch, getState) => { ... } chính là thunk. Đây là hàm mà Redux Thunk sẽ nhận lấy và thực thi.
  • Redux Thunk truyền vào thunk này hai tham số: dispatch (là hàm dispatch của Redux store) và getState (là hàm để lấy state hiện tại của Redux store).
  • Bên trong thunk, chúng ta đầu tiên dispatch một action đồng bộ FETCH_USERS_REQUEST. Action này sẽ lập tức đến reducer để cập nhật state, ví dụ đặt loading: true.
  • Tiếp theo, chúng ta sử dụng await axios.get(...) để thực hiện cuộc gọi API bất đồng bộ. await tạm dừng việc thực thi thunk cho đến khi API trả về kết quả.
  • Nếu API thành công (try block), chúng ta dùng hàm dispatch được truyền vào để dispatch một action đồng bộ khác: FETCH_USERS_SUCCESS, kèm theo dữ liệu người dùng nhận được làm payload. Action này lại đến reducer để cập nhật state, ví dụ đặt loading: false và lưu danh sách users.
  • Nếu API thất bại (catch block), chúng ta dùng dispatch để dispatch action đồng bộ FETCH_USERS_FAILURE, kèm theo thông báo lỗi. Reducer sẽ nhận action này và cập nhật state, ví dụ đặt loading: false và lưu thông báo error.
  • Như bạn thấy, thunk không trực tiếp thay đổi state. Nó chỉ điều phối các tác vụ bất đồng bộ và sau đó dispatch các action đồng bộ mà reducer của bạn đã biết cách xử lý.
3. Viết Reducer

Reducer sẽ xử lý các plain object action (FETCH_USERS_REQUEST, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE) mà thunk đã dispatch.

// userReducer.js
import {
  FETCH_USERS_REQUEST,
  FETCH_USERS_SUCCESS,
  FETCH_USERS_FAILURE
} from './actionTypes';

const initialState = {
  loading: false, // Trạng thái loading
  users: [],      // Danh sách người dùng
  error: ''       // Thông báo lỗi
};

const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case FETCH_USERS_REQUEST:
      return {
        ...state,
        loading: true,
        error: '' // Xóa lỗi cũ khi bắt đầu request mới
      };
    case FETCH_USERS_SUCCESS:
      return {
        ...state,
        loading: false,
        users: action.payload, // Lưu dữ liệu người dùng nhận được
        error: ''
      };
    case FETCH_USERS_FAILURE:
      return {
        ...state,
        loading: false,
        users: [], // Xóa dữ liệu người dùng khi có lỗi
        error: action.payload // Lưu thông báo lỗi
      };
    default:
      return state;
  }
};

export default userReducer;

Giải thích code:

  • Reducer này là reducer Redux tiêu chuẩn. Nó nhận state hiện tại và action, và trả về state mới.
  • Nó xử lý 3 loại action type đã định nghĩa: FETCH_USERS_REQUEST, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE.
  • Khi nhận FETCH_USERS_REQUEST, nó set loading thành true và xóa error.
  • Khi nhận FETCH_USERS_SUCCESS, nó set loading thành false, lưu action.payload (danh sách người dùng) vào state users, và xóa error.
  • Khi nhận FETCH_USERS_FAILURE, nó set loading thành false, xóa danh sách users, và lưu action.payload (thông báo lỗi) vào state error.
4. Sử dụng trong Component React

Trong component React, bạn sẽ sử dụng hook useDispatch từ react-redux để lấy hàm dispatch và hook useSelector để lấy state từ store. Bạn sẽ dispatch cái thunk action creator (fetchUsers) khi component cần tải dữ liệu (ví dụ: khi component mount).

// UserList.js (Component React)
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUsers } from './userActions'; // Import thunk action creator

function UserList() {
  const dispatch = useDispatch(); // Lấy hàm dispatch từ Redux store
  // Lấy các phần state cần thiết từ store (giả sử reducer được combine với key 'users')
  const { loading, users, error } = useSelector(state => state.users);

  useEffect(() => {
    // Dispatch THUNK action creator!
    // Bạn dispatch hàm fetchUsers(), không phải một plain object.
    // Redux Thunk middleware sẽ bắt lấy và thực thi hàm bên trong nó.
    dispatch(fetchUsers());

    // Dependency array rỗng để chỉ chạy một lần khi component mount
    // Nếu logic yêu cầu fetch lại khi có props/state thay đổi, thêm vào đây
  }, [dispatch]); // Added dispatch to dependency array as recommended by ESLint

  // Hiển thị UI dựa trên state
  if (loading) {
    return <div>Đang tải danh sách người dùng...</div>;
  }

  if (error) {
    return <div> lỗi xảy ra khi tải người dùng: {error}</div>;
  }

  return (
    <div>
      <h2>Danh sách người dùng</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name} ({user.email})</li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

Giải thích code:

  • Trong useEffect, chúng ta gọi dispatch(fetchUsers()).
  • Điều kỳ diệu ở đây là: chúng ta dispatch một hàm (fetchUsers chính là một hàm trả về hàm thunk), chứ không phải một object { type: '...' } như bình thường.
  • Middleware Redux Thunk nhận diện đây là một hàm, nó không gửi hàm này tới reducer. Thay vào đó, nó thực thi cái hàm bên trong mà fetchUsers trả về.
  • Hàm thunk đó chạy, thực hiện gọi API, và nội bộ nó sẽ dùng hàm dispatch (mà Thunk đã truyền cho nó) để gửi các plain object action (FETCH_USERS_REQUEST, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE) đến reducer đúng lúc.
  • Component UserList không cần biết chi tiết về việc fetching diễn ra như thế nào. Nó chỉ cần gọi dispatch(fetchUsers()) và sử dụng useSelector để đọc trạng thái loading, users, error từ Redux store và hiển thị giao diện phù hợp.

Ưu điểm của Redux Thunk

  • Đơn giản và dễ hiểu: Đối với các tác vụ bất đồng bộ cơ bản như gọi API đơn lẻ, mô hình thunk rất trực quan.
  • Nhẹ gọn: Là một middleware rất nhỏ, không thêm nhiều overhead cho ứng dụng của bạn.
  • Linh hoạt: Bạn có toàn quyền kiểm soát luồng bất đồng bộ bên trong thunk function, có thể dispatch nhiều action, đọc state, gọi các API khác, v.v.

Khi nào nên sử dụng Redux Thunk?

Redux Thunk là lựa chọn tuyệt vời cho hầu hết các trường hợp xử lý bất đồng bộ trong Redux, đặc biệt là:

  • Fetch data từ API.
  • Thực hiện các thao tác có độ trễ (ví dụ: hiển thị thông báo sau 3 giây).
  • Thực hiện logic phức tạp hơn liên quan đến việc dispatch nhiều action dựa trên kết quả bất đồng bộ.

Tuy nhiên, đối với các luồng bất đồng bộ rất phức tạp, phụ thuộc lẫn nhau, hoặc yêu cầu xử lý cancellation/throttle/debounce phức tạp, các middleware khác như redux-saga (sử dụng Generator functions) hoặc redux-observable (sử dụng RxJS Observables) có thể cung cấp mô hình mạnh mẽ và có cấu trúc hơn, mặc dù chúng phức tạp hơn để học và sử dụng.

Đối với phần lớn các ứng dụng web thông thường, Redux Thunk là đủ mạnh mẽ và là điểm khởi đầu tốt nhất cho việc xử lý async actions trong Redux.

Comments

There are no comments at the moment.