⚡🚀 ReactJS、TypeScript、Vite 与 Redux 和 TanStack(React Query)实战⚛️
1. Redux 与 React Query 的比较(TanStack)
乍一看,将Redux与Thunk和React Query (TanStack)一起使用似乎有些多余,但每种工具都有其独特的优势:
-
使用 Thunk 进行 Redux:
- 状态管理: Redux 为您的应用程序提供了一个集中式状态容器,允许您在组件之间管理和共享状态。
- 自定义逻辑和控制:借助 Thunk,您可以添加自定义逻辑来处理 Redux 操作中的副作用(例如异步 API 调用),从而控制状态更新的时间和方式。
- 非常适合复杂的 UI 状态: Redux 非常适合管理不完全基于远程数据的复杂 UI 状态,例如 UI 开关、表单和身份验证状态。
-
React 查询(TanStack):
- 优化的数据获取: React Query 专为服务器状态管理而设计(数据来自服务器,可能与 UI 状态不同步)。它可以自动从 API 获取、缓存、同步和更新数据,从而节省大量样板代码。
- 缓存和后台重新获取: React Query 会自动缓存数据,并可在后台重新获取数据以保持其新鲜度,从而最大限度地减少手动更新的需要。
- 自动重试和过期数据管理: React Query 包含自动重试、错误处理以及根据可自定义策略将数据标记为“过期”或“新鲜”等功能。
何时使用每种方法:
- Redux非常适合管理客户端状态和与 UI 本身相关的状态。
- React Query最适合服务器端状态(API 数据),因为它简化了异步数据的生命周期管理。
本质上,Redux和React Query相辅相成:Redux 非常适合以 UI 为中心的状态管理,而 React Query 则专门用于从远程 API 获取数据和同步数据,从而减少样板代码并提高数据新鲜度。使用 Github Copilot 快速提升您的 React 项目开发效率。
2. 项目介绍
为了创建一个包含Redux(使用 Thunk)和React Query(TanStack)的完整ReactJS + TypeScript + Vite示例,用于 CRUD 操作,我们将搭建一个Node.js Express 服务器,并以 JSON 文件作为数据源。该服务器将提供 API 端点来模拟真实的后端环境。快速了解一下 TypeScript 的特性。
以下是概述:
- 前端:
React++Vite,TypeScript使用ReduxReact Query 处理 CRUD 操作。 - 后端:
Node.js+Express创建从 .json 文件检索、添加、更新和删除数据的端点。
3. 设置
1. 使用以下工具设置后端Express
-
为后端创建一个新目录,
server并添加一个db.json用于模拟数据存储的文件。server/ ├── db.json └── server.js -
在以下位置初始化一个 Node.js 项目
server/:cd server npm init -y npm install express body-parser fs cors -
创建
db.json一个简单的 JSON 文件来存储项目(server/db.json):{ "items": [ { "id": 1, "title": "Sample Item 1" }, { "id": 2, "title": "Sample Item 2" } ] } -
创建
server.js– 设置一个Express具有 CRUD 操作端点的服务器(server/server.js):const express = require('express'); const fs = require('fs'); const path = require('path'); const cors = require('cors'); // Import the cors package const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); // Enable CORS for all routes app.use(express.json()); const dbFilePath = path.join(__dirname, 'db.json'); // Helper function to read and write to the db.json file const readData = () => { if (!fs.existsSync(dbFilePath)) { return { items: [] }; } const data = fs.readFileSync(dbFilePath, 'utf-8'); return JSON.parse(data); }; const writeData = (data) => fs.writeFileSync(dbFilePath, JSON.stringify(data, null, 2)); // Get all items app.get('/items', (req, res) => { const data = readData(); res.json(data.items); }); // Add a new item app.post('/items', (req, res) => { const data = readData(); const newItem = { id: Date.now(), title: req.body.title }; data.items.push(newItem); writeData(data); res.status(201).json(newItem); }); // Update an item app.put('/items/:id', (req, res) => { 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: 'Item not found' }); } }); // Delete an item app.delete('/items/:id', (req, res) => { 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 running on http://localhost:${PORT}`); }); -
运行后端服务器:
node server.js
Vite2. 使用、TypeScript、Redux和设置前端。React Query
-
初始化 Vite 项目:
npm create vite@latest react-redux-query-example --template react-ts cd react-redux-query-example -
安装依赖项:
npm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query -
项目结构:
src/ ├── api/ │ └── apiClient.ts ├── features/ │ ├── items/ │ │ ├── itemsSlice.ts │ │ └── itemsApi.ts ├── hooks/ │ └── useItems.ts ├── App.tsx ├── App.module.css ├── store.ts └── main.tsx
4. 前端实现
-
api/apiClient.ts- Axios 实例import axios from 'axios'; const apiClient = axios.create({ baseURL: 'http://localhost:3001', headers: { 'Content-Type': 'application/json', }, }); export default apiClient; -
features/items/itemsSlice.ts- 使用 Thunks
定义 Redux 切片来处理 CRUD 操作Redux Thunk。import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; 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, }; // Thunks export const fetchItems = createAsyncThunk('items/fetchItems', async () => { const response = await apiClient.get('/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 || 'Failed to fetch items'; state.loading = false; }); }, }); export const { addItem, updateItem, deleteItem } = itemsSlice.actions; export default itemsSlice.reducer; -
features/items/itemsApi.ts- React 查询钩子- 使用 React Query 定义 CRUD 操作。
import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; import { useDispatch } from 'react-redux'; import apiClient from '../../api/apiClient'; import { Item, addItem as addItemAction, updateItem as updateItemAction, deleteItem as deleteItemAction } from './itemsSlice'; export const useFetchItems = (): UseQueryResult<Item[], Error> => useQuery({ queryKey: ['items'], // The queryFn in React Query is called under several conditions: // 1. Initial Load: When the component that uses the useQuery hook mounts for the first time. // 2. Stale Data: When the data in the cache is considered stale. This is determined by the staleTime configuration. // 3. Window Focus: When the window regains focus, if refetchOnWindowFocus is set to true. // 4. Interval Refetching: At regular intervals, if refetchInterval is set. // 5. Manual Refetch: When you manually refetch the query using methods like queryClient.invalidateQueries or queryClient.refetchQueries. // 6. Network Reconnect: When the network reconnects, if refetchOnReconnect is set to true. queryFn: async (): Promise<Item[]> => { const response = await apiClient.get('/items'); return response.data; } // The time that cache stays valid // If staleTime has not been reached, React Query will not trigger a new request when useFetchItems is called again. // Instead, it will serve the data from its cache since the data is still considered "fresh." // If the server data changes during the staleTime, React Query won't automatically know about it // since it doesn't check the server until the cache is marked as "stale" or a manual refresh is triggered (e.g., queryClient.invalidateQueries or refetch). or refetchInterval is used staleTime: 5 * 60 * 1000, // Cache for 5 minutes (default: 0) // Scenarios When refetchOnWindowFocus is Triggered // 1. Switching Tabs: When the user switches from another browser tab back to the tab where your application is running. // 2. Switching Windows: When the user switches from another application window (e.g., a different browser window or a different application) back to the browser window where your application is running. // 3. Minimize/Restore: When the user minimizes the browser window and then restores it. // 4. Lock/Unlock Screen: When the user locks their computer screen and then unlocks it, bringing the browser window back into focus. refetchOnWindowFocus: true, // Refetch on focus (default: true) // Use refetchInterval: Automatically poll the server at regular intervals. refetchInterval: 10000, // Polling every 10 seconds (default: false) // If the data in the cache is still valid (not stale), React Query will return it directly without making a new API request. // In this case, onSuccess won’t be triggered since the queryFn isn’t executed. // If the data returned by queryFn matches the existing cached data, React Query optimizes performance by not marking the query as "updated." Since there’s no perceived data change, the onSuccess callback is not called. onSuccess: (data) => { console.log("call onSuccess") const dispatch = useDispatch(); //dispatch(setItems(data)); }, }); export const useAddItem = (): UseMutationResult<Item, Error, { title: string }> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (newItem: { title: string }): Promise<Item> => { const response = await apiClient.post('/items', newItem); return response.data; }, // This will only be called if the mutation is successful, // different with onSettled with will be called regardless of whether the mutation was successful or not onSuccess: (data) => { // Invalidate cache, refresh items after creating queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(addItemAction(data)); } }); }; export const useUpdateItem = (): UseMutationResult<Item, Error, { id: number; title: string }> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (updatedItem: { id: number; title: string }): Promise<Item> => { const response = await apiClient.put(`/items/${updatedItem.id}`, updatedItem); return response.data; }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(updateItemAction(data)); } }); }; export const useDeleteItem = (): UseMutationResult<void, Error, number> => { const queryClient = useQueryClient(); const dispatch = useDispatch(); return useMutation({ mutationFn: async (id: number): Promise<void> => { await apiClient.delete(`/items/${id}`); }, onSuccess: (_, id) => { queryClient.invalidateQueries({ queryKey: ['items'] }); dispatch(deleteItemAction(id)); } }); }; -
store.tsRedux商店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; -
App.tsx-包含 CRUD 操作的主要组件import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchItems, deleteItem as deleteItemAction } from './features/items/itemsSlice'; import { RootState, AppDispatch } from './store'; import { useFetchItems, useAddItem, useUpdateItem, useDeleteItem } from from './features/items/itemsApi'; import styles from './App.module.css'; const App: React.FC = () => { const dispatch = useDispatch<AppDispatch>(); const { items, loading, error } = useSelector((state: RootState) => state.items); const { data: queryItems } = useFetchItems(); const addItemMutation = useAddItem(); const updateItemMutation = useUpdateItem(); const deleteItemMutation = useDeleteItem(); const [editingItem, setEditingItem] = useState<{ id: number; title: string } | null>(null); const [isLoading, setIsLoading] = useState(false); const [loadingButton, setLoadingButton] = useState<string | null>(null); useEffect(() => { dispatch(fetchItems()); }, [dispatch]); const handleAddItem = () => { setIsLoading(true); setLoadingButton('add'); const newItem = { title: 'New Item' }; addItemMutation.mutate(newItem, { // onSettled is called regardless of whether the query or mutation was successful or resulted in an error. // It is always called after the request has completed. onSettled: () => { setIsLoading(false); setLoadingButton(null); }, }); }; const handleUpdateItem = (id: number, title: string) => { setIsLoading(true); setLoadingButton(`update-${id}`); updateItemMutation.mutate({ id, title }, { onSettled: () => { setIsLoading(false); setLoadingButton(null); setEditingItem(null); }, }); }; const handleDeleteItem = (id: number) => { setIsLoading(true); setLoadingButton(`delete-${id}`); // Optimistically update the UI const previousItems = items; dispatch(deleteItemAction(id)); deleteItemMutation.mutate(id, { onSettled: () => { setIsLoading(false); setLoadingButton(null); }, onError: () => { // Revert the change if the mutation fails dispatch(fetchItems()); }, }); }; if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <div className={styles.container}> <h1 className={styles.title}>Items</h1> <button onClick={handleAddItem} className={styles.button} disabled={isLoading}> Add Item {loadingButton === 'add' && <div className={styles.spinner}></div>} </button> <ul className={styles.list}> {(items).map((item) => ( <li key={item.id} className={styles.listItem}> {editingItem && editingItem.id === item.id ? ( <> <input type="text" value={editingItem.title} onChange={(e) => setEditingItem({ ...editingItem, title: e.target.value })} className={styles.input} /> <div className={styles.buttonGroup}> <button onClick={() => handleUpdateItem(item.id, editingItem.title)} className={styles.saveButton} disabled={isLoading}> Save {loadingButton === `update-${item.id}` && <div className={styles.spinner}></div>} </button> <button onClick={() => setEditingItem(null)} className={styles.cancelButton} disabled={isLoading}> Cancel </button> </div> </> ) : ( <> {item.title} <div className={styles.buttonGroup}> <button onClick={() => setEditingItem(item)} className={styles.editButton} disabled={isLoading}> Edit </button> <button onClick={() => handleDeleteItem(item.id)} className={styles.deleteButton} disabled={isLoading}> Delete {loadingButton === `delete-${item.id}` && <div className={styles.spinner}></div>} </button> </div> </> )} </li> ))} </ul> </div> ); }; export default App; -
样式(
App.module.css).container { max-width: 800px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif; } .title { color: #2c3e50; font-size: 2rem; margin-bottom: 20px; text-align: center; } .button { background-color: #3498db; color: white; padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 130px; } .button:hover { background-color: #2980b9; } .list { list-style-type: none; padding: 0; } .listItem { padding: 10px; margin: 10px 0; border: 1px solid #bdc3c7; border-radius: 8px; background-color: #ecf0f1; display: flex; justify-content: space-between; align-items: center; } .input { padding: 5px; border: 1px solid #bdc3c7; border-radius: 4px; flex-grow: 1; margin-right: 10px; } .buttonGroup { display: flex; gap: 10px; } .editButton { background-color: #f39c12; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .editButton:hover { background-color: #e67e22; } .deleteButton { background-color: #e74c3c; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .deleteButton:hover { background-color: #c0392b; } .saveButton { background-color: #2ecc71; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .saveButton:hover { background-color: #27ae60; } .cancelButton { background-color: #95a5a6; color: white; padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; max-width: 100px; } .cancelButton:hover { background-color: #7f8c8d; } .spinner { border: 4px solid rgba(0, 0, 0, 0.1); border-left-color: #3498db; border-radius: 50%; width: 16px; height: 16px; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -
main.tsx-为和设置提供商ReduxReact Queryimport 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> );
5. 说明与总结
- Redux 与 Thunks:管理项目的本地和乐观状态更新。
- React Query:高效处理服务器数据获取和同步。
- 组合使用:这两个工具可以无缝协作。
Redux一个管理本地状态,另一个React Query专门用于获取和缓存服务器状态,使前端能够与后端保持同步。
这种设置提供了一个功能齐全的应用程序,其中客户端Redux和React Query服务器通过有效地管理客户端和服务器状态相互补充。
文章来源:https://dev.to/truongpx396/reactjs-typescript-vite-with-redux-and-tanstack-react-query-in-practice-7eg如果您觉得这篇文章对您有帮助,请点赞👍或留言告诉我!如果您认为这篇文章对其他人也有帮助,欢迎分享!非常感谢!😃