DEV Community

Cover image for βš‘πŸš€ ReactJS, TypeScript, Vite with Redux and TanStack (React Query) In Practice βš›οΈ
Truong Phung
Truong Phung

Posted on • Edited on

βš‘πŸš€ ReactJS, TypeScript, Vite with Redux and TanStack (React Query) In Practice βš›οΈ

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:

  1. 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.
  2. 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. Quickly boost your React projects development productivity with Github Copilot

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. Quickly have a look on TypeScript features

Here's an overview:

  • Frontend: React + Vite + TypeScript, using Redux and React Query to handle CRUD operations.
  • Backend: Node.js + Express to create endpoints for data retrieval, addition, update, and deletion from a .json file.

3. Setups

1. Set Up Backend with Express

  1. Create a new directory for the backend, server, and add a db.json file for simulating data storage.

    server/
    β”œβ”€β”€ db.json
    └── server.js
    
  2. Initialize a Node.js Project in server/:

    cd server
    npm init -y
    npm install express body-parser fs cors
    
  3. Create db.json – a simple JSON file to store items (server/db.json):

    {
      "items": [
        { "id": 1, "title": "Sample Item 1" },
        { "id": 2, "title": "Sample Item 2" }
      ]
    }
    
  4. Create server.js – set up an Express server with endpoints for CRUD operations (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}`);
    });
    
  5. Run the Backend Server:

    node server.js
    

2. Set Up Frontend with Vite, TypeScript, Redux, and React Query

  1. Initialize Vite Project:

    npm create vite@latest react-redux-query-example --template react-ts
    cd react-redux-query-example
    
  2. Install Dependencies:

    npm install @reduxjs/toolkit react-redux redux-thunk axios @tanstack/react-query
    
  3. Project Structure:

    src/
    β”œβ”€β”€ api/
    β”‚   └── apiClient.ts
    β”œβ”€β”€ features/
    β”‚   β”œβ”€β”€ items/
    β”‚   β”‚   β”œβ”€β”€ itemsSlice.ts
    β”‚   β”‚   └── itemsApi.ts
    β”œβ”€β”€ hooks/
    β”‚   └── useItems.ts
    β”œβ”€β”€ App.tsx
    β”œβ”€β”€ App.module.css
    β”œβ”€β”€ store.ts
    └── main.tsx
    

4. Frontend Implementation

  1. api/apiClient.ts - Axios Instance

        import axios from 'axios';
    
        const apiClient = axios.create({
          baseURL: 'http://localhost:3001',
          headers: {
            'Content-Type': 'application/json',
          },
        });
    
        export default apiClient;
    
  2. features/items/itemsSlice.ts - Redux Slice with Thunks
    Define a Redux slice to handle CRUD operations with 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;
    
  3. features/items/itemsApi.ts - React Query Hooks

    • Define CRUD operations using React Query.
        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));
            }
          });
        };
    
  4. store.ts - Redux Store

        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;
    
  5. App.tsx - Main Component with CRUD Operations

         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;
    
  6. Styling (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);
          }
        }
    
  7. main.tsx - Set Up Providers for Redux and React Query

        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>
        );
    

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. Redux manages local state, while React Query specializes 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! πŸ˜ƒ

Top comments (7)

Collapse
 
philip_zhang_854092d88473 profile image
Philip

Integrating EchoAPI into my Redux workflow has greatly boosted both my development speed and the accuracy of API handling.

Collapse
 
truongpx396 profile image
Truong Phung • Edited

hello @philip_zhang_854092d88473 , thank you for your sharing, could you share me the link to EchoAPI that you mentioned, I'll come and have a look, thank you πŸ˜ƒ

Collapse
 
philip_zhang_854092d88473 profile image
Philip

Hello! 😊 Thanks for your interest! You can check out EchoAPI through this link echoapi.com/. I hope you find it as useful as I have! If you have any questions about it, feel free to ask.

Thread Thread
 
truongpx396 profile image
Truong Phung • Edited

Thank you πŸ˜ƒ

Collapse
 
xen1337 profile image
Sarfaraz Shaikh

thanks

Collapse
 
rakesh_saini profile image
Rakesh Saini

Great post

Collapse
 
truongpx396 profile image
Truong Phung

thank you