Bài 21.3: Redux Toolkit với TypeScript

Chào mừng trở lại với chuỗi bài viết về lập trình Front-end của chúng ta! Nếu bạn đã từng làm việc với Redux "truyền thống", có lẽ bạn đã nếm trải cơn ác mộng mang tên "boilerplate" - hàng tá file, hàng tá code chỉ để thực hiện một thay đổi trạng thái nhỏ nhoi. Rồi khi tích hợp với TypeScript, mọi thứ lại càng trở nên... phức tạp hơn nữa với việc định nghĩa types cho actions, reducers, state, v.v.

May mắn thay, cộng đồng Redux đã lắng nghe và cho ra đời Redux Toolkit (RTK) - bộ công cụ chính thức, chuẩn mực giúp đơn giản hóa việc phát triển Redux. Và khi kết hợp RTK với TypeScript, bạn sẽ nhận được một combo sức mạnh: quản lý trạng thái hiệu quả hơn, ít code hơn, và quan trọng nhất là an toàn kiểu dữ liệu tuyệt đối.

Bài viết này sẽ đưa bạn đi sâu vào cách kết hợp hai "người hùng" này để xây dựng nên các ứng dụng Front-end mạnh mẽ và dễ bảo trì.

Tại Sao Cần Redux Toolkit?

Trước khi nói về TypeScript, hãy nhanh chóng lướt qua lý do RTK ra đời. Redux truyền thống có những nhược điểm cố hữu:

  1. Quá nhiều Boilerplate: Setup store, action types, action creators, reducers, constants... mọi thứ đều cần định nghĩa riêng lẻ.
  2. Phức tạp để cấu hình: Cấu hình store với middleware (như Thunk hay Saga), enhancers... có thể khá rắc rối cho người mới.
  3. Khó hiểu về các patterns: Mặc dù các patterns như Ducks hay Redux-Saga/Observable giải quyết vấn đề, chúng lại là các khái niệm bên ngoài cần học thêm.

Redux Toolkit ra đời để giải quyết tất cả những vấn đề này. Nó cung cấp các hàm tiện ích "có sẵn pin" giúp bạn:

  • configureStore: Thay thế createStore, giúp cấu hình store dễ dàng hơn nhiều, tự động setup Redux DevTools và tích hợp Redux Thunk theo mặc định.
  • createSlice: Đơn giản hóa đáng kể việc tạo reducers và actions. Bạn chỉ cần định nghĩa trạng thái ban đầu và một object chứa các "case reducers" (hàm xử lý cho từng loại action). RTK sẽ tự động tạo action types, action creators và reducer chính cho slice đó.
  • createAsyncThunk: Chuẩn hóa việc xử lý các logic bất đồng bộ (như gọi API). Nó tạo ra các action types (pending, fulfilled, rejected) và giúp bạn xử lý chúng dễ dàng trong reducer.
  • createEntityAdapter: Giúp quản lý dữ liệu dạng danh sách/bảng (arrays of objects) dễ dàng hơn.

TypeScript & Redux Toolkit: Bộ Đôi Hoàn Hảo

Ok, RTK giúp viết Redux ít code hơndễ hơn. Nhưng tại sao lại cần TypeScript đi kèm?

TypeScript mang đến an toàn kiểu dữ liệu. Khi làm việc với trạng thái (state), đây là điều cực kỳ quan trọng.

  • Bạn muốn chắc chắn rằng state của bạn luôn có cấu trúc như bạn mong đợi.
  • Bạn muốn chắc chắn rằng action payload có đúng kiểu dữ liệu mà reducer của bạn cần xử lý.
  • Bạn muốn nhận được gợi ý code (autocompletion) thông minh khi truy cập state hoặc dispatch actions.
  • Bạn muốn bắt lỗi ngay tại thời điểm viết code chứ không phải lúc runtime khi người dùng gặp lỗi.

Khi kết hợp RTK với TypeScript, bạn sẽ nhận được những lợi ích này mà không phải đánh vật với việc định nghĩa type thủ công cho từng action creator hay reducer. RTK được thiết kế để hoạt động hài hòa với TypeScript, cho phép nó tự động suy luận (infer) nhiều kiểu dữ liệu dựa trên cách bạn định nghĩa slice và store.

Bước 1: Cài Đặt

Đầu tiên, đảm bảo project React của bạn đã có TypeScript và Redux. Nếu chưa, bạn có thể tạo một project mới sử dụng template của Create React App hoặc Vite:

npx create-react-app my-app --template redux-typescript
# Hoặc với Vite
npm create vite@latest my-app --template react-ts
cd my-app
npm install @reduxjs/toolkit react-redux
# hoặc
yarn add @reduxjs/toolkit react-redux

Bước 2: Cấu Hình Store Với TypeScript

Đây là một trong những điểm khác biệt quan trọng. Thay vì chỉ export store, chúng ta cần export thêm các kiểu dữ liệu suy luận từ store để sử dụng trong các hook React-Redux.

Tạo file src/app/store.ts:

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice'; // Ví dụ: import một reducer

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    // Thêm các reducers khác ở đây
  },
});

// Định nghĩa kiểu dữ liệu cho RootState và AppDispatch
// `RootState` là kiểu dữ liệu của toàn bộ trạng thái trong store
export type RootState = ReturnType<typeof store.getState>;

// `AppDispatch` là kiểu dữ liệu của hàm dispatch trong store, bao gồm cả thunks
export type AppDispatch = typeof store.dispatch;

// Export store để Provider sử dụng
export default store;

Giải thích:

  • configureStore được dùng để tạo store. Nó nhận một object reducer chứa các slice reducers của bạn.
  • export type RootState = ReturnType<typeof store.getState>;: Đây là cách TypeScript suy luận kiểu dữ liệu của toàn bộ state trong store. store.getState là một hàm trả về trạng thái hiện tại của store, typeof store.getState lấy kiểu của hàm đó, và ReturnType<...> lấy kiểu dữ liệu mà hàm đó trả về. Kết quả là RootState sẽ tự động có kiểu { counter: CounterState, ... } dựa trên cấu hình reducer của bạn.
  • export type AppDispatch = typeof store.dispatch;: Tương tự, chúng ta lấy kiểu dữ liệu của hàm store.dispatch. Kiểu này đã bao gồm cả các thunks nhờ cấu hình mặc định của configureStore.

Bước 3: Tạo Typed Hooks

Các hook useSelectoruseDispatch từ react-redux mặc định không biết gì về kiểu dữ liệu của store của bạn. Để có an toàn kiểu dữ liệu, chúng ta cần tạo ra các phiên bản đã được typed của chúng.

Tạo file src/app/hooks.ts:

// src/app/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store'; // Import kiểu dữ liệu từ store

// Sử dụng các kiểu đã export từ store để tạo các hook typed
// useAppDispatch: Hook phiên bản typed của useDispatch
export const useAppDispatch = () => useDispatch<AppDispatch>();

// useAppSelector: Hook phiên bản typed của useSelector
// TypedUseSelectorHook là một helper type từ react-redux giúp định nghĩa kiểu cho useSelector
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Giải thích:

  • Chúng ta import các kiểu RootStateAppDispatch đã định nghĩa ở store.ts.
  • useAppDispatch: đơn giản là trả về useDispatch nhưng ép kiểu (assert) nó thành AppDispatch. Điều này đảm bảo rằng khi bạn gọi dispatch(...), TypeScript sẽ kiểm tra xem action đó có hợp lệ với AppDispatch không (bao gồm cả việc dispatch thunks).
  • useAppSelector: Chúng ta gán useSelector cho biến useAppSelector và cung cấp kiểu TypedUseSelectorHook<RootState>. Điều này cho TypeScript biết rằng hàm chọn (selector function) bạn truyền vào useAppSelector sẽ nhận vào đối số là RootState và phải trả về một giá trị có kiểu phù hợp. Quan trọng hơn, khi bạn truy cập state.counter.value, TypeScript sẽ biết state có cấu trúc của RootState và cung cấp autocompletion cũng như kiểm tra kiểu.

Lưu ý: Bạn nên sử dụng các hook useAppSelectoruseAppDispatch này trong toàn bộ ứng dụng của mình thay vì useSelectoruseDispatch gốc từ react-redux.

Bước 4: Tạo Slice Với TypeScript

Đây là nơi createSlice phát huy sức mạnh, đặc biệt là khi kết hợp với TypeScript.

Tạo file src/features/counter/counterSlice.ts:

// src/features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

// 1. Định nghĩa kiểu dữ liệu cho trạng thái của slice
interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed'; // Ví dụ thêm trạng thái loading
}

// 2. Định nghĩa trạng thái ban đầu với kiểu đã định nghĩa
const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// 3. Sử dụng createSlice
export const counterSlice = createSlice({
  name: 'counter', // Tên của slice, dùng làm tiền tố cho action types
  initialState,     // Trạng thái ban đầu (TypeScript sẽ suy luận kiểu từ đây)
  reducers: {       // Định nghĩa các "case reducers" cho các action đồng bộ
    increment: (state) => {
      // Redux Toolkit cho phép "mutate" state trực tiếp bên trong createSlice
      // nhờ Immer. TypeScript biết state ở đây có kiểu CounterState.
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Action với payload. PayloadAction<number> định nghĩa kiểu payload là number.
    incrementByAmount: (state, action: PayloadAction<number>) => {
      // TypeScript biết action.payload ở đây là number
      state.value += action.payload;
    },
  },
  // extraReducers xử lý các action được định nghĩa bên ngoài slice,
  // ví dụ như từ createAsyncThunk
  // extraReducers: (builder) => {
  //   builder
  //     .addCase(fetchData.pending, (state) => {
  //       state.status = 'loading';
  //     })
  //     .addCase(fetchData.fulfilled, (state, action: PayloadAction<number>) => {
  //       state.status = 'idle';
  //       state.value += action.payload;
  //     })
  //     .addCase(fetchData.rejected, (state) => {
  //       state.status = 'failed';
  //     });
  // },
});

// 4. Export các action creators được tạo tự động
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// 5. Export reducer chính của slice
export default counterSlice.reducer;

Giải thích:

  • interface CounterState { ... }: Chúng ta định nghĩa rõ ràng cấu trúc và kiểu dữ liệu của trạng thái cho slice này.
  • const initialState: CounterState = { ... };: Gán kiểu CounterState cho trạng thái ban đầu. Điều này giúp TypeScript suy luận kiểu cho state trong các reducers.
  • createSlice({ ... }):
    • name: Tên duy nhất cho slice.
    • initialState: Trạng thái ban đầu.
    • reducers: Object chứa các hàm xử lý logic đồng bộ. RTK sử dụng thư viện Immer bên dưới, cho phép bạn "mutate" state trực tiếp (ví dụ state.value += 1;) mà không vi phạm tính bất biến của Redux. TypeScript biết state ở đây là kiểu CounterState.
    • PayloadAction<number>: Khi một action có payload, bạn có thể dùng helper type PayloadAction từ @reduxjs/toolkit để định nghĩa kiểu dữ liệu của payload. Ví dụ: PayloadAction<number> nghĩa là payload phải là một số. Nếu bạn dispatch action này với một string, TypeScript sẽ báo lỗi.
  • RTK tự động tạo ra action types (ví dụ: 'counter/increment') và action creators (các hàm như increment(), decrement(), incrementByAmount(amount)) dựa trên tên slice và tên reducer.
  • counterSlice.actions: Object chứa tất cả action creators đã được tạo.
  • counterSlice.reducer: Reducer chính cho slice này, kết hợp tất cả các case reducers.

Bước 5: Sử Dụng Typed Hooks Trong Component React

Bây giờ, bạn có thể sử dụng các hook useAppSelectoruseAppDispatch đã tạo để tương tác với store một cách an toàn kiểu dữ liệu.

// src/features/counter/Counter.tsx
import React, { useState } from 'react';

// Import các typed hooks và action creators
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import {
  decrement,
  increment,
  incrementByAmount,
  // import action thunk nếu có
  // incrementAsync,
} from './counterSlice';

function Counter() {
  // Sử dụng useAppSelector. TypeScript biết state.counter có kiểu CounterState
  // và state.counter.value là một number, cung cấp autocompletion và kiểm tra lỗi.
  const count = useAppSelector((state) => state.counter.value);
  const status = useAppSelector((state) => state.counter.status); // TypeScript biết status có kiểu 'idle' | 'loading' | 'failed'

  // Sử dụng useAppDispatch. TypeScript biết dispatch có kiểu AppDispatch,
  // cho phép dispatch cả action objects và thunks.
  const dispatch = useAppDispatch();

  const [incrementAmount, setIncrementAmount] = useState('2');

  const incrementValue = Number(incrementAmount) || 0;

  return (
    <div>
      {/* Hiển thị trạng thái loading */}
      <p>Status: {status}</p>

      <div>
        <button
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())} // TypeScript kiểm tra action này
        >
          -
        </button>
        <span>{count}</span>
        <button
          aria-label="Increment value"
          onClick={() => dispatch(increment())} // TypeScript kiểm tra action này
        >
          +
        </button>
      </div>
      <div>
        <input
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
        />
        <button
          onClick={() => dispatch(incrementByAmount(incrementValue))}
          // Nếu bạn thử dispatch(incrementByAmount('abc')), TypeScript sẽ báo lỗi
        >
          Add Amount
        </button>
        {/* Nút ví dụ cho action bất đồng bộ */}
        {/* <button
          onClick={() => dispatch(incrementAsync(incrementValue))}
          disabled={status === 'loading'} // TypeScript biết status
        >
          Add Async
        </button> */}
      </div>
    </div>
  );
}

export default Counter;

Giải thích:

  • Khi gọi useAppSelector, hàm chọn (selector function) nhận đối số state. Nhờ kiểu TypedUseSelectorHook<RootState>, TypeScript biết state có cấu trúc của RootState (chính là { counter: CounterState } trong ví dụ này) và cung cấp gợi ý code thông minh khi bạn gõ state. hoặc state.counter..
  • Khi gọi useAppDispatch, hàm dispatch trả về có kiểu AppDispatch. Điều này cho phép bạn dispatch các action objects thông thường hoặc các thunks. Quan trọng hơn, khi bạn gọi dispatch(incrementByAmount(incrementValue)), TypeScript kiểm tra xem incrementByAmount có phải là một action creator hợp lệ và payload (incrementValue) có đúng kiểu dữ liệu mà incrementByAmount mong đợi (number) hay không. Nếu không, bạn sẽ nhận được lỗi ngay lúc biên dịch.

Xử Lý Logic Bất Đồng Bộ Với createAsyncThunk và TypeScript

Redux Toolkit cũng đơn giản hóa xử lý async với createAsyncThunk. TypeScript hoạt động rất tốt với nó.

// src/features/counter/counterSlice.ts (Tiếp tục)
import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
// Giả định có một hàm fetch API trả về Promise<number>
import { fetchCount } from './counterAPI'; // File giả định

interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// Định nghĩa Async Thunk
// createAsyncThunk<Kiểu trả về khi thành công, Kiểu đối số truyền vào thunk, Config (bao gồm kiểu State)>
export const incrementAsync = createAsyncThunk<
  number,                 // Kiểu trả về của promise (action.payload khi fulfilled)
  number,                 // Kiểu đối số truyền vào thunk (amount)
  { state: RootState }    // Config: Định nghĩa kiểu cho store state (getState())
>(
  'counter/fetchCount',   // Action type prefix
  async (amount, thunkAPI) => {
    // amount có kiểu number nhờ định nghĩa thứ 2 trong generic
    const response = await fetchCount(amount);
    // response.data có kiểu number nhờ hàm fetchCount
    return response.data; // Giá trị trả về này sẽ trở thành action.payload trong fulfilled action
  }
);

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    // ... (reducers đồng bộ như trước)
  },
  // extraReducers xử lý các action được định nghĩa bên ngoài slice,
  // bao gồm các action lifecycle (pending, fulfilled, rejected) từ createAsyncThunk
  extraReducers: (builder) => {
    builder
      // addCase nhận vào action creator và một reducer tương ứng
      // action có kiểu đã được TypeScript suy luận tự động từ createAsyncThunk
      .addCase(incrementAsync.pending, (state) => {
        // TypeScript biết state có kiểu CounterState
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action: PayloadAction<number>) => {
        // TypeScript biết action.payload có kiểu number nhờ định nghĩa đầu tiên trong generic của createAsyncThunk
        state.status = 'idle';
        state.value += action.payload;
      })
      .addCase(incrementAsync.rejected, (state) => {
        // TypeScript biết state có kiểu CounterState
        state.status = 'failed';
      });
  },
});

// ... (exports như trước)

Giải thích:

  • createAsyncThunk nhận 3 tham số generic: ReturnType, ArgType, Config.
    • number: Kiểu dữ liệu mà promise trả về (sẽ là action.payload của action fulfilled).
    • number: Kiểu dữ liệu của đối số đầu tiên truyền vào thunk (ở đây là amount).
    • { state: RootState }: Config object. Việc định nghĩa state: RootState ở đây cho phép bạn sử dụng thunkAPI.getState() trong thunk và TypeScript sẽ biết kiểu dữ liệu của state toàn cục là RootState.
  • Bên trong thunk, TypeScript biết amount có kiểu number.
  • Trong extraReducers:
    • builder: Một object giúp bạn thêm các case reducers cho các action được định nghĩa bên ngoài slice.
    • addCase(incrementAsync.pending, (state) => { ... }): Bạn chỉ cần truyền action creator (incrementAsync.pending). RTK và TypeScript sẽ tự động suy luận kiểu dữ liệu của action này. state vẫn có kiểu CounterState.
    • addCase(incrementAsync.fulfilled, (state, action) => { ... }): Ở đây, action được tự động gán kiểu PayloadAction<number> nhờ định nghĩa number đầu tiên trong generic của createAsyncThunk.
    • addCase(incrementAsync.rejected, (state) => { ... }): Tương tự, action này cũng được suy luận kiểu phù hợp.

Việc này giúp bạn xử lý các trạng thái của promise (pending, fulfilled, rejected) một cách có tổ chức và được kiểm soát kiểu dữ liệu hoàn toàn.

Comments

There are no comments at the moment.