1. Redux vs React Query (TanStack)
Using Redux with Thunk and React Query (TanStack) together might seem redundant at first glance, but each tool has distinct strengths:
Redux with Thunk:
State Management: Redux provides a centralized state container for your app, allowing you to manage and share state across components.
Custom Logic and Control: With Thunk, you can add custom logic to handle side effects (like asynchronous API calls) within your Redux actions, giving you control over when and how state updates.
Ideal for Complex UI States: Redux is well-suited for managing complex UI states that aren’t solely based on remote data—like UI toggles, forms, and authentication states.
React Query (TanStack):
Optimized Data Fetching: React Query is purpose-built for server state management (data that comes from a server and can be out of sync with the UI state). It automates fetching, caching, synchronizing, and updating data from APIs, saving a lot of boilerplate code.
Caching and Background Refetching: React Query automatically caches data and can refetch it in the background to keep it fresh, minimizing the need for manual updates.
Automatic Retrying and Stale Management: React Query includes features like automatic retries, error handling, and marking data as "stale" or "fresh" based on customizable policies.
When to Use Each:
Redux is excellent for managing client-side state and states tied to the UI itself.
React Query is best for server-side state (API data), as it simplifies the lifecycle management of asynchronous data.
In essence, Redux and React Query complement each other: Redux is ideal for UI-centric state management, while React Query is specialized for data fetching and synchronization with remote APIs, reducing boilerplate and enhancing data freshness.
2. Project Introduction
To create a fully comprehensive ReactJS + TypeScript + Vite example with Redux (using Thunk) and React Query (TanStack) for CRUD operations, we’ll set up a Node.js Express server with a JSON file as the data source. This server will provide API endpoints to simulate a real-world backend.
Here's an overview:
Frontend:React+Vite+TypeScript, usingReduxandReact Queryto handle CRUD operations.
Backend:Node.js+Expressto create endpoints for data retrieval, addition, update, and deletion from a .json file.
3. Setups
1. Set Up Backend with Express
Create a new directory for the backend,
server, and add adb.jsonfile for simulating data storage.
CODEserver/
├── db.json
└── server.js
Initialize a Node.js Project in
server/:
CODEcd server
npm init -y
npm install express body-parser fs cors
Create
db.json– a simple JSON file to store items (server/db.json):
CODE{
"items": [
{ "id": 1, "title": "Sample Item 1" },
{ "id": 2, "title": "Sample Item 2" }
]
}
Create
server.js– set up anExpressserver with endpoints for CRUD operations (server/server.js):
CODEconst 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}`);
});
Run the Backend Server:
CODEnode server.js
2. Set Up Frontend with Vite, TypeScript, Redux, and React Query
Initialize Vite Project:
CODEnpm create vite@latest react-redux-query-example --template react-ts
cd react-redux-query-example
Install Dependencies:
CODEnpm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query
Project Structure:
CODEsrc/
├── api/
│ └── apiClient.ts
├── features/
│ ├── items/
│ │ ├── itemsSlice.ts
│ │ └── itemsApi.ts
├── hooks/
│ └── useItems.ts
├── App.tsx
├── App.module.css
├── store.ts
└── main.tsx
4. Frontend Implementation
api/apiClient.ts- Axios Instance
CODEimport axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost:3001',
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;
features/items/itemsSlice.ts- Redux Slice with Thunks
Define a Redux slice to handle CRUD operations withRedux Thunk.
CODEimport { 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 Query Hooks
- Define CRUD operations using React Query.
CODEimport { 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'],
queryFn: async (): Promise<Item[]> => {
const response = await apiClient.get('/items');
return response.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;
},
onSuccess: (data) => {
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));
}
});
};
- Define CRUD operations using React Query.
store.ts- Redux Store
CODEimport { 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- Main Component with CRUD Operations
CODEimport 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: () => {
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;
Styling (
App.module.css)
CODE.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- Set Up Providers forReduxandReact Query
CODEimport 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. Explanation and Summary
Redux with Thunks: Manages local and optimistic state updates for items.
React Query: Efficiently handles server data fetching and synchronization.
Combined Usage: Both tools work together seamlessly.Reduxmanages local state, whileReact Queryspecializes in fetching and caching server state, allowing the frontend to stay in sync with the backend.
This setup provides a fully functioning app where Redux and React Query complement each other by managing client and server states effectively.
If you found this helpful, let me know by leaving a 👍 or a comment!, or if you think this post could help someone, feel free to share it! Thank you very much! 😃
SOCIAL SHARE CARD GENERATOR