Bài 21.1: Cài đặt Redux với TypeScript

Chào mừng bạn đến với Bài 21.1 trong series Lập trình Web Front-end của chúng ta! Hôm nay, chúng ta sẽ cùng nhau bước chân vào thế giới của việc quản lý state (trạng thái) trong các ứng dụng React lớn hơn. Khi ứng dụng của bạn phát triển, việc truyền dữ liệu giữa các component ngày càng trở nên phức tạp (prop drilling là một nỗi ám ảnh!). Đây chính là lúc các thư viện quản lý state như Redux tỏa sáng.

Và khi kết hợp Redux với sức mạnh của TypeScript, bạn không chỉ giải quyết được vấn đề quản lý state tập trung mà còn đảm bảo rằng state và các hành động của bạn luôn có kiểu dữ liệu rõ ràng, giúp giảm thiểu lỗi và làm cho code dễ đọc, dễ bảo trì hơn rất nhiều.

Bài viết này sẽ hướng dẫn bạn từng bước cài đặt và cấu hình Redux với TypeScript trong một ứng dụng React hiện đại, sử dụng thư viện được khuyến nghị hiện nay là Redux Toolkit. Hãy cùng bắt đầu nhé!

Tại sao lại là Redux Toolkit?

Trước đây, việc cài đặt Redux khá cồng kềnh với nhiều boilerplate code. Tuy nhiên, Redux Toolkit (RTK) đã ra đời để giải quyết vấn đề đó. RTK là bộ công cụ chính thức được Redux khuyến nghị, giúp đơn giản hóa đáng kể quá trình cài đặt và sử dụng Redux. Nó bao gồm các tiện ích giúp:

  • Giảm thiểu boilerplate.
  • Cấu hình store dễ dàng hơn.
  • Viết reducers hiệu quả hơn với createSlice.
  • Tích hợp sẵn các middleware hữu ích (như Redux Thunk để xử lý bất đồng bộ).
  • Đặc biệt, nó được thiết kế để hoạt động tuyệt vời với TypeScript ngay từ đầu!

Vì vậy, trong bài viết này, chúng ta sẽ tập trung hoàn toàn vào việc sử dụng Redux Toolkit.

Chuẩn bị

Trước khi bắt đầu, đảm bảo bạn đã có:

  1. Node.jsnpm hoặc yarn đã cài đặt.
  2. Một dự án React đã được tạo (ví dụ: sử dụng Create React App với template TypeScript hoặc Vite).
  3. Kiến thức cơ bản về ReactTypeScript.

Nếu bạn đang sử dụng Vite, bạn có thể tạo một dự án React + TypeScript nhanh chóng bằng lệnh sau:

npm create vite my-redux-app --template react-ts
# hoặc
yarn create vite my-redux-app --template react-ts
# hoặc
pnpm create vite my-redux-app --template react-ts

Sau đó di chuyển vào thư mục dự án và cài đặt các dependencies:

cd my-redux-app
npm install
# hoặc
yarn install
# hoặc
pnpm install

Bước 1: Cài đặt các Dependencies cần thiết

Chúng ta cần cài đặt Redux Toolkit, thư viện react-redux để kết nối Redux với React, và các định nghĩa kiểu TypeScript tương ứng.

Mở terminal trong thư mục gốc của dự án và chạy lệnh sau:

npm install @reduxjs/toolkit react-redux
npm install @types/react-redux --save-dev

Giải thích:

  • @reduxjs/toolkit: Chứa Redux Toolkit, bộ công cụ chính.
  • react-redux: Thư viện giúp kết nối React components với Redux store (ví dụ: hooks useSelector, useDispatch).
  • @types/react-redux: Chứa các định nghĩa kiểu TypeScript cho react-redux, giúp TypeScript hiểu cách sử dụng các hooks và component Provider của thư viện này.

Bước 2: Cấu hình Redux Store

Store là "ngôi nhà" chứa toàn bộ trạng thái của ứng dụng của bạn. Chúng ta sẽ sử dụng configureStore từ Redux Toolkit để tạo store.

Tạo một thư mục mới, ví dụ src/app, và bên trong đó tạo file store.ts.

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';

export const store = configureStore({
  reducer: {
    // Chúng ta sẽ thêm các "reducers" (bộ xử lý trạng thái) vào đây sau.
    // Mỗi key ở đây sẽ đại diện cho một phần của trạng thái tổng thể.
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
// Infer the `RootState` (Loại trạng thái gốc) và `AppDispatch` (Loại hàm dispatch) từ store
export type RootState = ReturnType<typeof store.getState>;
// Infer the `AppDispatch` type from the store's dispatch function
export type AppDispatch = typeof store.dispatch;

Giải thích:

  • configureStore: Một hàm từ Redux Toolkit giúp cấu hình store với ít cài đặt hơn so với Redux truyền thống. Nó tự động thêm middleware (như Redux Thunk) và cài đặt Redux DevTools extension.
  • reducer: Một object chứa tất cả các slice reducers của bạn. Mỗi key trong object này (ví dụ: counter, user, items) sẽ tương ứng với một phần của trạng thái tổng thể (state.counter, state.user, state.items).
  • export const store: Xuất store đã tạo để có thể sử dụng ở nơi khác trong ứng dụng.
  • export type RootState = ReturnType<typeof store.getState>;: Đây là cách quan trọng để TypeScript biết toàn bộ cấu trúc trạng thái của ứng dụng của bạn. ReturnType<typeof store.getState> sẽ tự động suy ra kiểu của hàm store.getState, tức là kiểu của toàn bộ trạng thái gốc sau khi kết hợp tất cả các reducers.
  • export type AppDispatch = typeof store.dispatch;: Tương tự, dòng này giúp TypeScript biết kiểu của hàm store.dispatch. Điều này quan trọng vì configureStore tự động thêm middleware vào dispatch, và kiểu này sẽ bao gồm cả các middleware đó (ví dụ: cho phép dispatch hàm bất đồng bộ nếu dùng Thunk).

Bước 3: Cung cấp Store cho ứng dụng React

Để các component trong React có thể truy cập vào Redux store, chúng ta cần "cung cấp" store đó bằng component Provider từ thư viện react-redux.

Mở file entry point của ứng dụng (thường là src/index.tsx hoặc src/main.tsx nếu dùng Vite).

// src/index.tsx (hoặc src/main.tsx)
import React from 'react';
import ReactDOM from 'react-dom/client'; // hoặc 'react-dom'
import App from './App';
import { store } from './app/store'; // Nhập store từ file vừa tạo
import { Provider } from 'react-redux'; // Nhập Provider

// Chọn root element của ứng dụng
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container!); // Tạo root

root.render(
  <React.StrictMode>
    {/* Bọc component gốc <App /> bằng Provider và truyền store vào prop store */}
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

Giải thích:

  • Provider: Component này sử dụng React Context API để đưa store vào "ngữ cảnh" của ứng dụng. Bằng cách đặt nó ở cấp độ cao nhất (thường bọc component App), tất cả các component con bên trong App đều có thể truy cập vào store.
  • store={store}: Truyền instance store mà chúng ta đã tạo ở Bước 2 vào prop store của Provider.

Bước 4: Tạo các Typed Hooks

Để sử dụng useSelectoruseDispatch (các hooks từ react-redux để đọc state và gửi action) một cách an toàn với TypeScript, Redux Toolkit khuyến nghị tạo ra các phiên bản hooks đã được typed sẵn. Điều này giúp tránh việc phải gán kiểu thủ công mỗi lần sử dụng hooks và đảm bảo tính nhất quán trong toàn bộ ứng dụng.

Tạo một file mới, ví dụ src/app/hooks.ts.

// src/app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store'; // Nhập kiểu RootState và AppDispatch

// Sử dụng các exported hooks này trong toàn bộ ứng dụng thay vì plain `useDispatch` và `useSelector`
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Giải thích:

  • Chúng ta nhập useDispatchuseSelector từ react-redux.
  • Chúng ta nhập RootStateAppDispatch từ file store.ts mà chúng ta đã tạo. Sử dụng import type chỉ nhập các khai báo kiểu, không phải giá trị JavaScript, giúp tối ưu hóa bundle size.
  • export const useAppDispatch = () => useDispatch<AppDispatch>();: Tạo một hook tùy chỉnh useAppDispatch. Nó gọi useDispatch gốc nhưng chỉ định rõ ràng kiểu trả vềAppDispatch. Điều này đảm bảo rằng khi bạn sử dụng useAppDispatch, TypeScript biết chính xác các loại action bạn có thể dispatch (bao gồm cả các action bất đồng bộ nếu có middleware).
  • export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;: Tạo một hook tùy chỉnh useAppSelector. Chúng ta gán kiểu TypedUseSelectorHook<RootState> cho nó. TypedUseSelectorHook là một kiểu generic được cung cấp bởi react-redux, giúp bạn chỉ định kiểu của toàn bộ state mà selector của bạn sẽ nhận vào (ở đây là RootState). Điều này đảm bảo rằng khi bạn sử dụng useAppSelector, TypeScript biết cấu trúc của trạng thái và có thể cung cấp gợi ý code cũng như kiểm tra lỗi kiểu.

Từ bây giờ, bạn sẽ sử dụng useAppDispatchuseAppSelector trong các component React của mình thay vì useDispatchuseSelector gốc.

Bước 5: Tạo một Redux Slice (Ví dụ: Counter)

Redux Toolkit giới thiệu khái niệm "slice". Một slice là một phần của logic Redux (state, reducers và actions) cho một tính năng cụ thể trong ứng dụng của bạn (ví dụ: slice cho người dùng, slice cho sản phẩm, slice cho bộ đếm).

createSlice là hàm "ma thuật" từ RTK giúp bạn định nghĩa một slice một cách dễ dàng.

Tạo một thư mục mới, ví dụ src/features, và bên trong tạo thư mục con cho tính năng của bạn, ví dụ counter. Trong thư mục counter, tạo file counterSlice.ts.

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

// Định nghĩa kiểu cho trạng thái của slice này
// Define a type for the slice state
interface CounterState {
  value: number;
  // Bạn có thể thêm các trường khác vào trạng thái slice của mình
  // status: 'idle' | 'loading' | 'failed';
}

// Định nghĩa trạng thái ban đầu sử dụng kiểu đã định nghĩa
// Define the initial state using that type
const initialState: CounterState = {
  value: 0,
  // status: 'idle',
};

export const counterSlice = createSlice({
  name: 'counter', // Tên của slice. Sử dụng trong các action type (ví dụ: 'counter/increment')
  initialState, // Trạng thái ban đầu
  reducers: {
    // Reducers định nghĩa cách trạng thái thay đổi dựa trên các action
    // RTK cho phép 'thay đổi' trạng thái trực tiếp nhờ thư viện Immer
    increment: (state) => {
      // Redux Toolkit cho phép chúng ta viết logic mutating ngay trong reducers.
      // Điều này không thực sự thay đổi state gốc mà tạo ra một bản sao mới.
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Action với payload: Nhận dữ liệu bổ sung
    incrementByAmount: (state, action: PayloadAction<number>) => {
      // `PayloadAction<number>` khai báo rằng `action.payload` sẽ là một số
      state.value += action.payload;
    },
    // Bạn có thể thêm nhiều reducers khác ở đây
  },
  // extraReducers được sử dụng để xử lý các action types được định nghĩa bên ngoài slice này,
  // ví dụ: action từ các slice khác hoặc các async thunks (sẽ học sau).
  // extraReducers: (builder) => {
  // ...
  // },
});

// `createSlice` tự động tạo ra các action creator functions cho mỗi reducer mà chúng ta định nghĩa.
// Ví dụ: action type 'counter/increment' sẽ có action creator `increment()`.
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// Hàm selector ví dụ (Tùy chọn nhưng là best practice để truy cập trạng thái slice)
// Bạn có thể định nghĩa các selector ở đây hoặc ngay trong component.
// import type { RootState } from '../../app/store'; // Nhập RootState nếu định nghĩa selector tại đây
// export const selectCounterValue = (state: RootState) => state.counter.value;


// Hàm reducer chính của slice này để thêm vào store
export default counterSlice.reducer;

Giải thích:

  • interface CounterState: Chúng ta định nghĩa kiểu dữ liệu cho trạng thái của slice counter. Đây là nơi TypeScript phát huy tác dụng, giúp bạn biết rõ cấu trúc dữ liệu mà slice này quản lý.
  • initialState: CounterState: Khởi tạo trạng thái ban đầu cho slice, đảm bảo nó tuân thủ kiểu CounterState.
  • createSlice({...}): Hàm chính để tạo slice.
    • name: 'counter': Đặt tên cho slice. RTK sẽ sử dụng tên này để tạo các action type (ví dụ: 'counter/increment').
    • initialState: Cung cấp trạng thái ban đầu.
    • reducers: Một object chứa các hàm reducer. Mỗi hàm ở đây sẽ trở thành một case reducer xử lý một action cụ thể. RTK sử dụng thư viện Immer bên dưới, cho phép bạn "mutation" (thay đổi trực tiếp) trạng thái nháp bên trong reducer một cách an toàn, và Immer sẽ tự động tạo ra một trạng thái mới và bất biến dựa trên các thay đổi đó. Điều này làm cho code reducer dễ đọc và viết hơn rất nhiều.
      • increment: (state) => { ... }: Reducer increment. Nhận trạng thái hiện tại (state).
      • incrementByAmount: (state, action: PayloadAction<number>) => { ... }: Reducer incrementByAmount. Nhận trạng thái và object action. PayloadAction<number> là kiểu generic từ RTK giúp bạn khai báo kiểu dữ liệu của action.payload. TypeScript giờ đây biết rằng action.payload sẽ là một số.
  • export const { increment, decrement, incrementByAmount } = counterSlice.actions;: createSlice tự động tạo ra các action creator (hàm tạo action) cho mỗi reducer. Chúng ta xuất chúng để có thể gọi các hàm này trong component (ví dụ: dispatch(increment())).
  • export default counterSlice.reducer;: Xuất reducer chính của slice. Đây là hàm mà chúng ta sẽ thêm vào object reducer trong configureStore.

Bước 6: Kết nối Slice với Store

Bây giờ chúng ta đã có slice counter, hãy thêm reducer của nó vào store tổng thể.

Mở lại file src/app/store.ts và cập nhật nó:

// src/app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice'; // Nhập reducer từ slice

export const store = configureStore({
  reducer: {
    // Thêm slice reducer vào đây. Key 'counter' sẽ là tên phần trạng thái trong RootState
    counter: counterReducer,
    // Nếu có các slice khác, bạn thêm vào đây tương tự:
    // users: usersReducer,
    // products: productsReducer,
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: { counter: CounterState, users: UsersState, ... }

// Infer the `AppDispatch` type from the store's dispatch function
export type AppDispatch = typeof store.dispatch;
// Inferred type: ThunkDispatch<RootState, undefined, AnyAction> & Dispatch<AnyAction>

Giải thích:

  • Chúng ta nhập counterReducer từ file counterSlice.ts.
  • Trong object reducer của configureStore, chúng ta thêm một cặp key-value: counter: counterReducer. Key counter xác định tên của phần trạng thái này trong store tổng thể. Bây giờ, trạng thái của slice counter sẽ được truy cập thông qua state.counter.
  • Điều kỳ diệu của TypeScript: Khi bạn thêm counter: counterReducer vào object reducer, TypeScript sẽ tự động cập nhật kiểu RootState. Bây giờ, RootState sẽ biết rằng nó có một trường counter với kiểu dữ liệu là CounterState (đã định nghĩa trong counterSlice.ts). Điều này hoàn toàn tự động nhờ ReturnType<typeof store.getState>.

Bước 7: Sử dụng State và Dispatch Actions trong Component React

Cuối cùng, chúng ta sẽ sử dụng các typed hooks (useAppSelector, useAppDispatch) và các action creator từ slice trong một component React.

Tạo một component mới, ví dụ src/features/counter/Counter.tsx.

// src/features/counter/Counter.tsx
import React from 'react';
import { useAppSelector, useAppDispatch } from '../../app/hooks'; // Nhập hooks đã được typed
import { increment, decrement, incrementByAmount } from './counterSlice'; // Nhập các action creator từ slice

function Counter() {
  // Sử dụng useAppSelector để đọc giá trị 'value' từ trạng thái slice 'counter'
  // TypeScript biết rằng state.counter.value là một số (number)
  const count = useAppSelector(state => state.counter.value);
  // Nếu bạn có các trường khác trong CounterState, bạn có thể truy cập chúng tương tự:
  // const status = useAppSelector(state => state.counter.status);

  // Sử dụng useAppDispatch để lấy hàm dispatch đã được typed
  const dispatch = useAppDispatch();

  return (
    <div>
      <h2>Giá trị Bộ đếm: {count}</h2>
      <div>
        <button onClick={() => dispatch(increment())}>Tăng</button>
        <button onClick={() => dispatch(decrement())}>Giảm</button>
        {/* Dispatch action với payload */}
        <button onClick={() => dispatch(incrementByAmount(5))}>Tăng 5</button>
      </div>
      {/* Hiển thị trạng thái khác nếu có */}
      {/* <p>Trạng thái: {status}</p> */}
    </div>
  );
}

export default Counter;

Giải thích:

  • Chúng ta nhập useAppSelectoruseAppDispatch từ file hooks.ts.
  • Chúng ta nhập các action creator increment, decrement, incrementByAmount từ file counterSlice.ts.
  • const count = useAppSelector(state => state.counter.value);:
    • useAppSelector được gọi với một selector function.
    • Selector function nhận vào toàn bộ trạng thái gốc (state).
    • Nhờ TypedUseSelectorHook<RootState>, TypeScript biết cấu trúc của state. Nó biết rằng state có trường counter, và state.counter có trường value. Kiểu của state.counter.value được suy ra là number, giống như bạn đã định nghĩa trong CounterState. Bạn sẽ nhận được gợi ý code và kiểm tra lỗi kiểu ngay lập tức!
  • const dispatch = useAppDispatch();: Lấy hàm dispatch đã được typed.
  • onClick={() => dispatch(increment())}: Khi button được click, chúng ta gọi dispatch và truyền vào kết quả của việc gọi action creator increment(). increment() trả về một action object ({ type: 'counter/increment' }). TypeScript biết rằng dispatch chấp nhận action object này.
  • onClick={() => dispatch(incrementByAmount(5))}: Khi button "Tăng 5" được click, chúng ta gọi incrementByAmount(5). Action creator này nhận một đối số (payload) và trả về action object ({ type: 'counter/incrementByAmount', payload: 5 }). TypeScript biết rằng dispatch chấp nhận action này và payload của nó phải là một số.

Cuối cùng, bạn có thể thêm component Counter này vào App.tsx để hiển thị nó:

// src/App.tsx
import React from 'react';
import './App.css';
import Counter from './features/counter/Counter'; // Nhập component Counter

function App() {
  return (
    <div className="App">
      <header className="App-header">
        {/* Sử dụng component Counter đã kết nối với Redux */}
        <Counter />
        <p>
           dụ về tích hợp Redux với TypeScript
        </p>
      </header>
    </div>
  );
}

export default App;

Comments

There are no comments at the moment.