Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Redux & Redux Toolkit

Khái niệm Cơ bản

Redux là state management library theo unidirectional data flow:

┌─────────────────────────────────────────────────────────────┐
│                    Redux Data Flow                          │
│                                                             │
│   Component  ──dispatch(action)──▶  Reducer  ──▶  Store    │
│      ▲                                                  │   │
│      └──────────────── state ───────────────────────────┘   │
│                                                             │
│   Action: { type: 'counter/increment', payload: 1 }        │
│   Reducer: Pure function (state, action) => newState        │
│   Store: Single source of truth                             │
└─────────────────────────────────────────────────────────────┘

Redux Toolkit (RTK) - Cách hiện đại

RTK là cách chính thức và được khuyến nghị để dùng Redux.

Cài đặt

npm install @reduxjs/toolkit react-redux

createSlice

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

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

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

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      // RTK dùng Immer - có thể "mutate" state trực tiếp
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    reset: (state) => {
      state.value = 0;
    },
  },
});

// Export actions
export const { increment, decrement, incrementByAmount, reset } =
  counterSlice.actions;

// Export selectors
export const selectCount = (state: RootState) => state.counter.value;

export default counterSlice.reducer;

configureStore

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import cartReducer from '../features/cart/cartSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    cart: cartReducer,
  },
  // Middleware mặc định: thunk, serializability check
  // middleware: (getDefaultMiddleware) =>
  //   getDefaultMiddleware().concat(myMiddleware),
});

// TypeScript types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Typed hooks
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Provider Setup

// index.tsx / main.tsx
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Sử dụng trong Component

import { useAppDispatch, useAppSelector } from '../../app/store';
import { increment, decrement, selectCount } from './counterSlice';

function Counter() {
  const count = useAppSelector(selectCount);
  const dispatch = useAppDispatch();

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
}

createAsyncThunk - Async Operations

// features/users/usersSlice.ts
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

interface User {
  id: number;
  name: string;
  email: string;
}

interface UsersState {
  entities: User[];
  status: 'idle' | 'loading' | 'succeeded' | 'failed';
  error: string | null;
}

// Tạo async thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchAll',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error('Server error');
      return await response.json() as User[];
    } catch (error) {
      return rejectWithValue((error as Error).message);
    }
  }
);

export const createUser = createAsyncThunk(
  'users/create',
  async (userData: Omit<User, 'id'>, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });
      if (!response.ok) throw new Error('Failed to create user');
      return await response.json() as User;
    } catch (error) {
      return rejectWithValue((error as Error).message);
    }
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entities: [],
    status: 'idle',
    error: null,
  } as UsersState,
  reducers: {
    userRemoved: (state, action: PayloadAction<number>) => {
      state.entities = state.entities.filter((u) => u.id !== action.payload);
    },
  },
  extraReducers: (builder) => {
    builder
      // fetchUsers
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.entities = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload as string;
      })
      // createUser
      .addCase(createUser.fulfilled, (state, action) => {
        state.entities.push(action.payload);
      });
  },
});

export const { userRemoved } = usersSlice.actions;
export default usersSlice.reducer;

// Selectors
export const selectAllUsers = (state: RootState) => state.users.entities;
export const selectUsersStatus = (state: RootState) => state.users.status;
// Component sử dụng
function UserList() {
  const dispatch = useAppDispatch();
  const users = useAppSelector(selectAllUsers);
  const status = useAppSelector(selectUsersStatus);

  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchUsers());
    }
  }, [dispatch, status]);

  if (status === 'loading') return <Spinner />;
  if (status === 'failed') return <p>Error loading users</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => dispatch(userRemoved(user.id))}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

RTK Query - Data Fetching

RTK Query là giải pháp data fetching tích hợp trong Redux Toolkit.

// services/usersApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) {
        headers.set('Authorization', `Bearer ${token}`);
      }
      return headers;
    },
  }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query<User[], void>({
      query: () => '/users',
      providesTags: ['User'],
    }),
    getUserById: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    createUser: builder.mutation<User, Omit<User, 'id'>>({
      query: (body) => ({
        url: '/users',
        method: 'POST',
        body,
      }),
      invalidatesTags: ['User'], // Tự động refetch users sau khi create
    }),
    updateUser: builder.mutation<User, Partial<User> & { id: number }>({
      query: ({ id, ...body }) => ({
        url: `/users/${id}`,
        method: 'PUT',
        body,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
    deleteUser: builder.mutation<void, number>({
      query: (id) => ({ url: `/users/${id}`, method: 'DELETE' }),
      invalidatesTags: ['User'],
    }),
  }),
});

// Export hooks tự động generate
export const {
  useGetUsersQuery,
  useGetUserByIdQuery,
  useCreateUserMutation,
  useUpdateUserMutation,
  useDeleteUserMutation,
} = usersApi;
// Thêm vào store
export const store = configureStore({
  reducer: {
    [usersApi.reducerPath]: usersApi.reducer,
    // ...other reducers
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(usersApi.middleware),
});
// Sử dụng RTK Query hooks
function UserList() {
  const { data: users = [], isLoading, isError } = useGetUsersQuery();
  const [deleteUser] = useDeleteUserMutation();

  if (isLoading) return <Spinner />;
  if (isError) return <p>Error!</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => deleteUser(user.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

// Conditional fetching
function UserProfile({ userId }: { userId: number | null }) {
  const { data: user } = useGetUserByIdQuery(userId!, {
    skip: userId === null, // Không fetch nếu userId null
  });

  return <div>{user?.name}</div>;
}