Gần đây, mình đã phải đối mặt với một vấn đề khá đau đầu trong việc quản lý trạng thái ứng dụng React của mình. Mình muốn tìm cách tối ưu hóa việc quản lý dữ liệu mà không muốn làm phức tạp thêm phần code. Sau một thời gian tìm hiểu và thử nghiệm, mình quyết định kết hợp Redux và React Query. Hôm nay, mình muốn chia sẻ với các bạn cách mình đã làm điều đó, hy vọng sẽ giúp ích cho bạn nào đang cần.
Bài viết này sẽ hướng dẫn bạn cách kết hợp Redux và React Query trong một ứng dụng React sử dụng TypeScript và Vite, thông qua việc xây dựng một ứng dụng CRUD đầy đủ chức năng. Cùng mình đi qua từng bước một, từ thiết lập backend với Nodejs và Express, đến việc xây dựng frontend với React, Redux, và React Query.
1. Tại sao nên kết hợp Redux và React Query?
Ban đầu, mình chỉ sử dụng Redux để quản lý toàn bộ trạng thái của ứng dụng. Mọi thứ hoạt động rất ổn, nhưng khi ứng dụng ngày càng lớn, việc quản lý các request API và dữ liệu từ server trở nên phức tạp. Mình nhận thấy code bắt đầu trở nên cồng kềnh và khó bảo trì.
Sau đó, mình may mắn tìm hiểu ra React Query – một thư viện giúp quản lý dữ liệu từ server một cách hiệu quả, với các tính năng như caching, refetching, và quản lý trạng thái tải dữ liệu. Tuy nhiên, mình vẫn cần Redux để quản lý trạng thái cục bộ và các logic UI phức tạp.
Vì vậy, mình quyết định kết hợp cả hai xem kết quả ra sao:
- Sử dụng Redux cho trạng thái cục bộ, UI state, và các logic không liên quan đến dữ liệu từ server.
- Sử dụng React Query để quản lý dữ liệu từ server, tối ưu hóa việc gọi API và caching.
2. Xây dựng Backend với Express
Mình sẽ hướng dẫn bạn từ đầu, nhưng sẽ không đi vào chi tiết giải thích, nếu bạn thắc mắc có thể tham khảo thêm tại đây nha.
B1: Đầu tiên, mình tạo một folder và cài đặt các package:
mkdir server
cd server
npm init -y
npm install express cors body-parser fs
npm install --save-dev typescript @types/node @types/express @types/cors ts-node nodemon
B2: Khởi tạo cấu hình TypeScript
npx tsc --init
Thêm phần này vào file tsconfig.json
để đặt outDir và rootDir
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
},
}
Cấu trúc thư mục mình sẽ tạo như thế này:
server/
├── src/
│ ├── db.json
│ └── server.ts
├── tsconfig.json
├── package.json
B3: Sau đó, mình tạo một file db.json
đơn giản để mock data dữ liệu
{
"items": [
{ "id": 1, "title": "Đây là sản phẩm 1" },
{ "id": 2, "title": "Đây là sản phẩm 2" }
]
}
B4: Mình sẽ tạo file server.ts và viết các API CRUD:
import express, { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import cors from 'cors';
//-------------------------------------------------------------------
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
const dbFilePath = path.join(__dirname, 'db.json');
interface Item {
id: number;
title: string;
}
interface Data {
items: Item[];
}
const readData = (): Data => {
if (!fs.existsSync(dbFilePath)) {
return { items: [] };
}
const data = fs.readFileSync(dbFilePath, 'utf-8');
return JSON.parse(data);
};
const writeData = (data: Data): void => {
fs.writeFileSync(dbFilePath, JSON.stringify(data, null, 2));
};
app.get('/items', (req: Request, res: Response) => {
const data = readData();
res.json(data.items);
});
app.post('/items', (req: Request, res: Response) => {
const data = readData();
const newItem: Item = { id: Date.now(), title: req.body.title };
data.items.push(newItem);
writeData(data);
res.status(201).json(newItem);
});
app.put('/items/:id', (req: Request, res: Response) => {
const data = readData();
const itemIndex = data.items.findIndex((item) => item.id === parseInt(req.params.id));
if (itemIndex > -1) {
data.items[itemIndex] = { ...data.items[itemIndex], title: req.body.title };
writeData(data);
res.json(data.items[itemIndex]);
} else {
res.status(404).json({ message: 'Không tìm thấy item' });
}
});
app.delete('/items/:id', (req: Request, res: Response) => {
const data = readData();
data.items = data.items.filter((item) => item.id !== parseInt(req.params.id));
writeData(data);
res.status(204).end();
});
app.listen(PORT, () => {
console.log(`Server đang chạy tại http://localhost:${PORT}`);
});
B5: Mình sẽ vào file package.json
để thiết lập các scripts chạy server
"scripts": {
"start": "node dist/server.js",
"build": "tsc",
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts"
},
B6: Hoàn tất, mình sẽ dùng câu lệnh npm run dev
để chạy server.
3.Xây dựng Frontend với React, Redux, React Query và TypeScript
Nếu bạn chưa có source base thì có thể tham khảo tại đây, mình có hướng dẫn ở bài trước. Nên mình sẽ rút ngắn đi vào phần chính luôn nha.
B1: Cài đặt các thư viện cần thiết
npm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query
B2: Mình sẽ để cấu trúc thư mục của dự án ở đây để bạn dễ hình dung nha
src/
├── api/
│ └── apiClient.ts
├── features/
│ ├── items/
│ │ ├── itemsSlice.ts
│ │ └── itemsApi.ts
├── store.ts
├── App.tsx
├── main.tsx
B3: Thiết lập Axios Instance
Tại file apiClient.ts
mình sẽ tạo Axios instance:
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost:3001',
headers: { 'Content-Type': 'application/json' },
});
export default apiClient;
B4: Tạo Redux Slice cho items
// file itemsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { RootState } from '../../store';
import apiClient from '../../api/apiClient';
export interface Item {
id: number;
title: string;
}
interface ItemsState {
items: Item[];
loading: boolean;
error: string | null;
}
const initialState: ItemsState = {
items: [],
loading: false,
error: null,
};
export const fetchItems = createAsyncThunk<Item[]>('items/fetchItems', async () => {
const response = await apiClient.get<Item[]>('/items');
return response.data;
});
const itemsSlice = createSlice({
name: 'items',
initialState,
reducers: {
addItem: (state, action: PayloadAction<Item>) => {
state.items.push(action.payload);
},
updateItem: (state, action: PayloadAction<Item>) => {
const index = state.items.findIndex((item) => item.id === action.payload.id);
if (index !== -1) state.items[index] = action.payload;
},
deleteItem: (state, action: PayloadAction<number>) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchItems.pending, (state) => {
state.loading = true;
})
.addCase(fetchItems.fulfilled, (state, action: PayloadAction<Item[]>) => {
state.items = action.payload;
state.loading = false;
})
.addCase(fetchItems.rejected, (state, action) => {
state.error = action.error.message || 'Lấy dữ liệu thất bại';
state.loading = false;
});
},
});
export const { addItem, updateItem, deleteItem } = itemsSlice.actions;
export const selectItems = (state: RootState) => state.items.items;
export const selectLoading = (state: RootState) => state.items.loading;
export const selectError = (state: RootState) => state.items.error;
export default itemsSlice.reducer;
B5: Khởi tạo một số React Query Hooks
// itemsAPI.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDispatch } from 'react-redux';
import apiClient from '../../api/apiClient';
import { Item, addItem, updateItem, deleteItem } from './itemsSlice';
//------------------------------------------------------------------
export const useFetchItems = () => {
return useQuery<Item[], Error>({
queryKey: ['items'],
queryFn: async () => {
const response = await apiClient.get<Item[]>('/items');
return response.data;
},
staleTime: 5 * 60 * 1000, // 5 phút
refetchOnWindowFocus: true,
});
};
export const useAddItem = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation<Item, Error, { title: string }>({
mutationFn: async (newItem) => {
const response = await apiClient.post<Item>('/items', newItem);
return response.data;
},
onSuccess: (data) => {
queryClient.invalidateQueries(['items']);
dispatch(addItem(data));
},
});
};
export const useUpdateItem = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation<Item, Error, { id: number; title: string }>({
mutationFn: async (updatedItem) => {
const response = await apiClient.put<Item>(`/items/${updatedItem.id}`, updatedItem);
return response.data;
},
onSuccess: (data) => {
queryClient.invalidateQueries(['items']);
dispatch(updateItem(data));
},
});
};
export const useDeleteItem = () => {
const queryClient = useQueryClient();
const dispatch = useDispatch();
return useMutation<void, Error, number>({
mutationFn: async (id) => {
await apiClient.delete(`/items/${id}`);
},
onSuccess: (_, id) => {
queryClient.invalidateQueries(['items']);
dispatch(deleteItem(id));
},
});
};
B6: Thiếp lập Redux Store
// file store.ts
import { configureStore } from '@reduxjs/toolkit';
import itemsReducer from './features/items/itemsSlice';
//----------------------------------------------------------
const store = configureStore({
reducer: {
items: itemsReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
B7: Trong App.tsx
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchItems, selectItems, selectLoading, selectError } from './features/items/itemsSlice';
import { AppDispatch } from './store';
import {
useFetchItems,
useAddItem,
useUpdateItem,
useDeleteItem,
} from './features/items/itemsApi';
//--------------------------------------------------------------------
const App: React.FC = () => {
const dispatch = useDispatch<AppDispatch>();
const items = useSelector(selectItems);
const loading = useSelector(selectLoading);
const error = useSelector(selectError);
// Dùng hooks
const { data: queryItems, isLoading: isQueryLoading } = useFetchItems();
const addItemMutation = useAddItem();
const updateItemMutation = useUpdateItem();
const deleteItemMutation = useDeleteItem();
const [editingItem, setEditingItem] = useState<{ id: number; title: string } | null>(null);
useEffect(() => {
dispatch(fetchItems());
}, [dispatch]);
const handleAddItem = () => {
const newItem = { title: 'Đây là sản phẩm mới' };
addItemMutation.mutate(newItem);
};
const handleUpdateItem = (id: number, title: string) => {
updateItemMutation.mutate({ id, title });
setEditingItem(null);
};
const handleDeleteItem = (id: number) => {
deleteItemMutation.mutate(id);
};
if (loading || isQueryLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>Product List</h1>
<button onClick={handleAddItem}>Add Product</button>
<ul>
{(queryItems || items).map((item) => (
<li key={item.id}>
{editingItem && editingItem.id === item.id ? (
<>
<input
type="text"
value={editingItem.title}
onChange={(e) => setEditingItem({ ...editingItem, title: e.target.value })}
/>
<button onClick={() => handleUpdateItem(item.id, editingItem.title)}>Save</button>
<button onClick={() => setEditingItem(null)}>Cancel</button>
</>
) : (
<>
{item.title}
<button onClick={() => setEditingItem(item)}>Sửa</button>
<button onClick={() => handleDeleteItem(item.id)}>Delete</button>
</>
)}
</li>
))}
</ul>
</div>
);
};
export default App;
B8: Thiết lập Providers
//file main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import store from './store';
import App from './App';
//---------------------------------------------------------------
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</Provider>
</React.StrictMode>
);
Cuối cùng là chạy chương trình thôi. Lưu ý là bạn có thể setup đổi port để chạy phù hợp với máy của bạn nhất nhé.
4. Kết luận
Bài viết trên, giúp bạn nhận thấy rằng việc kết hợp Redux và React Query giúp tối ưu hóa việc quản lý trạng thái trong ứng dụng React. Redux giúp mình quản lý trạng thái cục bộ và các logic UI phức tạp, trong khi React Query giúp quản lý dữ liệu từ server một cách hiệu quả, giảm thiểu code bị loop.
Hy vọng qua bài viết này, các bạn sẽ có thêm một sự lựa chọn trong việc quản lý trạng thái trong dự án React của mình.
Các bài viết liên quan:
Bài viết liên quan
Vercel là gì? Hướng dẫn deploy dự án Next.js bằng Vercel
Dec 07, 2024 • 14 min read
So sánh giữa HOCs, Render Props và Hooks.
Dec 05, 2024 • 8 min read
Render Props pattern là gì? Hướng dẫn sử dụng Render Props
Dec 03, 2024 • 8 min read
HOCs Pattern là gì? Hướng dẫn triển khai Hocs Pattern trong dự án React
Dec 02, 2024 • 7 min read
Hooks Pattern là gì? Hướng dẫn áp dụng Hooks Pattern trong dự án React
Nov 28, 2024 • 11 min read
Promise là gì? Hướng dẫn sử dụng Promise trong dự án React
Nov 27, 2024 • 7 min read