React CRUD example with Redux Toolkit, Thunk middleware for Redux & REST API

Nay mình sẽ làm một ví dụ về Redux Toolkit + Thunk Middleware + Restful API. Trong bài này tương đối là dài, mong mọi người chịu khó xem nhé!

Ở các bài trước mình đã chia sẻ về Redux-Toolkit rồi nên ở phần này mình đi vào trọng tâm xử lý hành động thôi. Bạn nào chưa xem bài trước có thể xem lại tại đây : 

Okay bắt đầu thôi!

Video Demo:

# Tạo project Redux-Toolkit

npx create-react-app my-app --template redux

Tiếp theo ta cần cài thư viện Redux-Toolkit , bằng lệnh sau:

npm install @reduxjs/toolkit

Nếu mọi thứ tiến triển ok, bạn sẽ được một project chứa template redux sẵn, có sẵn mọi thứ, việc còn lại chỉ cần setup các component xử lý thôi

Các bước bên dưới sẽ tạo ra thư mục product  trong folder src/features như hình sau:

# Call Middleware Thunk in Redux-Toolkit

Như mình đã nói ở bài trước là, Redux-Toolkit đã mặt định thêm cho ta middleware Thunk rồi, nên ta chỉ cần khai báo và dùng thôi

Tạo file productSlice.js trong đường dẫn ./features/products, và import thư viện sau 

import { createAsyncThunk, createSlice, current } from '@reduxjs/toolkit';

# Tạo các actions từ hàm createAsyncThunk()

Tại file productSlice.js ta cấu hình các action từ hàm createAsyncThunk() , đoạn code dưới đây nó giúp ta request api , nó sẽ trả về một promise, chứa 3 tham số(pending, fulfilled, rejected) ta sẽ kiểm tra nó trong extraReducers của createSlice, mình sẽ làm ở các bước sau

Chúng ta cũng biết được là createAsyncThunk() có 2 tham số : (string, callback)

string : ta đặt name cho nó và nó được đặt theo cú pháp như sau: [slice name/ action name]

callback() : nó xử lý bất đồng bộ , trả về một promise, như mình mới nói ở bên trên

export const getProducts  = createAsyncThunk(
    'products/getProducts',
    async (thunkAPI) => {
      //call api
      const res = await fetch('https://5adc8779b80f490014fb883.mockapi.io/products').then(
      (data) => data.json()
    )
    return res
  })

thunkAPI: một đối tượng chứa tất cả các tham số thường được truyền cho chức năng thunk của Redux, cũng như các tùy chọn bổ sung: dispatch, getState, extra, rejectWithValue(value, [meta]), ...
Bạn có thể xem thêm ở link này :thunkAPI: một đối tượng chứa tất cả các tham số thường được truyền cho chức năng thunk của Redux, cũng như các tùy chọn bổ sung dispatch, getState, extra, rejectWithValue(value, [meta]), ... bạn có thể xem thêm ở link này : https://redux-toolkit.js.org/api/createAsyncThunk#payloadcreator Chính vì thế ta có thể dispatch action khác nhé dispatch(actionLoading()); https://jasonwatmore.com/post/2022/06/16/react-redux-toolkit-fetch-data-in-async-action-with-createasyncthunk"> https://redux-toolkit.js.org/api/createAsyncThunk#payloadcreator
Chính vì thế ta có thể dispatch action khác , VD: dispatch(actionLoading()) bên trong hàm aysnc()

Tương tự như tạo các action từ createAsyncThunk() bên trên ta sẽ tạo các action để dispatch xử lý các tác vụ( Create, Edit, Update,Delete), bạn chú ý đoạn code bên dưới, ta có chèn tham số nửa nhé

export const getProductId = createAsyncThunk(
    'products/getProductId',
    async (productId, { dispatch }) => {
        const response = await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${productId}`).then(
            (data) => data.json()
          )
        const finalPayload = response
        //dispatch(someOtherAction())
        return finalPayload; // will dispatch `fulfilled` action
       }
)

export const postAddProduct = createAsyncThunk(
  'products/postAddProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response = await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)


export const postUpdateProduct = createAsyncThunk(
  'products/postUpdateProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response =  await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${product.id}`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)


export const postDeleteProduct = createAsyncThunk(
  'products/postDeleteProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response =  await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${product.id}`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)

Bạn có thể tạo một file riêng để viết các request api cho dễ nhìn nhé, tại ở đây mình làm chung nên nó hơi nhiều code

# Tạo hàm từ createSlice(), để kiểm trả các promise trong extraReducers

Trước tiên mình sẽ tạo một initialState để chưa data 

//setup state 
const initialState = {
    loading:false,
    products:[],
    isFetchProductID:false,
    product:{}
  };

Okay tiếp theo là tạo hàm từ createSlice() thôi

 export const productSlice = createSlice({
    name: 'products',
    initialState,
    reducers: {},
    extraReducers: {

      //set get all product
      [getProducts.pending]: (state) => {
        state.loading = true
      },
      [getProducts.fulfilled]: (state, { payload }) => {
        state.loading = false
        state.products = payload
      },
      [getProducts.rejected]: (state) => {
        state.loading = false
      },

      //set get product Id
      [getProductId.pending]: (state) => {
        state.isFetchProductID = true
      },
      [getProductId.fulfilled]: (state, { payload }) => {
        state.isFetchProductID = false
        state.product = payload
      },
      [getProductId.rejected]: (state) => {
        state.isFetchProductID = false
      },
      
      //set post Product
      [postAddProduct.fulfilled]:(state,{payload})=>{
         state.products.push(payload);
      },

      //set update Product
      [postUpdateProduct.fulfilled]:(state,{payload})=>{
        const index = state.products.findIndex(product => product.id === payload.id);
        //console.log(index)
       // console.log(payload)
        state.products[index] = payload;
      },

       //set delete Product
       [postDeleteProduct.fulfilled]:(state,{payload})=>{
        const index = state.products.findIndex(product => product.id === payload.id);
        state.products.splice(index, 1);
      }

    },
  });

Mình sẽ nói qua về đoạn mã trên cho mọi người hiểu thế này. Bạn chú ý ở chổ extraReducers mình cần xác nhận các giai đoạn sau : 

getProducts.pending : hành động của ta đang thực thi, có nghĩa là nó đang request api

getProducts.fulfilled : nếu xử lý thành công, ta tiến hành cập nhật state của ta

getProducts.rejected : nếu xử lý không thành công

Okay, mấy đoạn code phía trên cũng tương đối đơn giản, nên mình sẽ không nói nhiều sẽ làm bài viết dài thêm

Đây là fullcode của file productSlice.js

import { createAsyncThunk, createSlice, current } from '@reduxjs/toolkit';

export const getProducts  = createAsyncThunk(
    'products/getProducts',
    async (thunkAPI) => {
      //call api
      const res = await fetch('https://5adc8779b80f490014fb883.mockapi.io/products').then(
      (data) => data.json()
    )
    return res
  })

export const getProductId = createAsyncThunk(
    'products/getProductId',
    async (productId, { dispatch }) => {
        const response = await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${productId}`).then(
            (data) => data.json()
          )
        const finalPayload = response
        //dispatch(someOtherAction())
        return finalPayload; // will dispatch `fulfilled` action
       }
)

export const postAddProduct = createAsyncThunk(
  'products/postAddProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response = await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)


export const postUpdateProduct = createAsyncThunk(
  'products/postUpdateProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response =  await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${product.id}`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)


export const postDeleteProduct = createAsyncThunk(
  'products/postDeleteProduct',
  async (product, { dispatch }) => {
    const requestOptions = {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product)
    };
    const response =  await fetch(`https://5adc8779b80f490014fb883.mockapi.io/products/${product.id}`,requestOptions).then(
        (data) => data.json()
    )
    const finalPayload = response
    return finalPayload; 
   }
)

  
//setup state 
const initialState = {
    loading:false,
    products:[],
    isFetchProductID:false,
    product:{}
  };
  export const productSlice = createSlice({
    name: 'products',
    initialState,
    reducers: {},
    extraReducers: {

      //set get all product
      [getProducts.pending]: (state) => {
        state.loading = true
      },
      [getProducts.fulfilled]: (state, { payload }) => {
        state.loading = false
        state.products = payload
      },
      [getProducts.rejected]: (state) => {
        state.loading = false
      },

      //set get product Id
      [getProductId.pending]: (state) => {
        state.isFetchProductID = true
      },
      [getProductId.fulfilled]: (state, { payload }) => {
        state.isFetchProductID = false
        state.product = payload
      },
      [getProductId.rejected]: (state) => {
        state.isFetchProductID = false
      },
      
      //set post Product
      [postAddProduct.fulfilled]:(state,{payload})=>{
         state.products.push(payload);
      },

      //set update Product
      [postUpdateProduct.fulfilled]:(state,{payload})=>{
        const index = state.products.findIndex(product => product.id === payload.id);
        //console.log(index)
       // console.log(payload)
        state.products[index] = payload;
      },

       //set delete Product
       [postDeleteProduct.fulfilled]:(state,{payload})=>{
        const index = state.products.findIndex(product => product.id === payload.id);
        state.products.splice(index, 1);
      }

    },
  });

  export default productSlice.reducer;
  

# Cài đặt configureStore từ Redux-Toolkit

Hãy mở file app/store.js và chỉnh sửa như sau

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import productReducer from '../features/products/productSlice';

export const store = configureStore({
  reducer: {
   // counter: counterReducer,
    products: productReducer
  },
 // middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(customMiddleware),
});

Các bạn nhìn trong code trên ta cần gọi productReducer từ '../features/products/productSlice' , sau đó config nó trong hàm configureStore(), nếu bạn không làm điều này, thì các dispatch action sẽ không được thực thi

Mình có note lại middleware bên trên , ta có thể custom middleware nhé, bài trước mình đã có giới thiệu rồi, bạn nào chưa xem , thì xem lại tại đây: Creating custom Middleware in React/Redux

# Cài đặt Component Product

Giờ là lúc ta triển khai ứng dụng của ta thôi, hãy tạo file Layout.js trong đường dẫn như hình project bên trên, à quên bạn cần cài đặt thư viện react-router-dom để thiết lập các Route cho ứng dụng nhé

Bạn có thể xem bài hướng dẫn cài đặt React Router Dom ở link này : REACT ROUTE DOM V6 . Còn bạn nào sử dụng phiên bản thấp hơn thì xem link này:  URL Router in React

import React from 'react'
import { Outlet } from 'react-router-dom'
export default function ProductLayout() {
  return (
    <div>
        <Outlet />
    </div>
  )
}

+ File List.js : hiển thị danh sách product

import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { getProducts, postDeleteProduct } from '../products/productSlice';

export default function ProductsList() {
    const dispatch = useDispatch();
    const { products, loading } = useSelector((state) => state.products)
    let fetchMount = true;
    useEffect(() => {
        if(fetchMount){
            dispatch(getProducts())
        }
        return ()=>{
            fetchMount = false;
        }
    }, [])

  if(loading) return <div>Loading...</div>
  return (
    <div>
        <h2>All Products</h2>
        <ul>
            {products.map((product) => (
                <li key={product.id}>
                    <Link to={`/products/${product.id}`} style={{fontSize:20}}>{product.title}</Link>---
                    [<span style={{color:"red",cursor:"pointer"}} onClick={()=>dispatch(postDeleteProduct(product))}>Delete</span>
                    |
                    <Link to={`/products/${product.id}/edit`} style={{color:"green",cursor:"pointer"}}>Edit</Link>]
                </li>
            ))}
        </ul>
        <br/>
        <Link to={`/products/new`} style={{backgroundColor:'blue',color:"#fff",padding:5}}>Add Product</Link>
    </div>
  )
}

Mấy cái dispatch() thì chắc mọi người cũng đã hiểu qua rồi, bạn nào chưa hình dung , hãy qua Redux trước nhé

+ File Item.js : Hiển thị bài viết theo ID

import React, { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux';
import { Link, useParams } from 'react-router-dom'
import { getProductId } from './productSlice';

export default function ProductItem() {
  const {productId} = useParams();
  const dispatch  = useDispatch();
  const {product, isFetchProductID} = useSelector((state)=>state.products)
  let fetchMount = true;
  useEffect(()=>{
    if(fetchMount)  dispatch(getProductId(productId))
    //unmount
    return ()=>fetchMount = false;
  },[])
  if(isFetchProductID) return <div>Loading...!</div>
  return (
    <div>
      <img src={product.image} style={{width:100,height:100}} /><br/>
      <span>Id: {productId}</span><br/>
      <span>Title: {product.title}</span><br />
      <span>Price: {product.price}</span><br />
      <Link to={"/"}>Back Home</Link>
    </div>
  )
}

+ Firle Form.js : dùng để create product

import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom';
import { postAddProduct } from './productSlice';

export default function ProductForm() {
  const dispatch  = useDispatch();
  const navigate = useNavigate();
  const [title,setTitle] = useState("");
  const [image,setImage] = useState("");
  const [price,setPrice] = useState(0);
  const add_product = async ()=>{
     let product = {
        title:title,
        image:image,
        price:price
     }
     console.log(product)
     //dispatch action postAddProduct từ createAsyncThunk 
    await dispatch(postAddProduct(product))
    navigate(`/products`);
  }
  return (
    <div>
        <h1>New Product</h1>
        <input type="text" name="title" placeholder="Title" onChange={(e)=>setTitle(e.target.value)}/> <br/>
        <input type="text" name="image" placeholder="Link Image" onChange={(e)=>setImage(e.target.value)}/><br/>
        <input type="price" name="price" placeholder="Price" onChange={(e)=>setPrice(e.target.value)}/><br/>
        <button onClick ={()=>add_product()}>Send Form</button>
    </div>
  )
}

+ File Edit.js : dùng cập nhật product

import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useParams, useNavigate  } from 'react-router-dom';
import { postUpdateProduct } from './productSlice';

export default function ProductEdit() {
  //Get Params productId từ URL
  const {productId} = useParams();
  const navigate = useNavigate();
  const dispatch  = useDispatch();
  const {products,loading} = useSelector((state)=>state.products)
  const [title,setTitle] = useState("");
  const [image,setImage] = useState("");
  const [price,setPrice] = useState(0);
  useEffect(()=>{
    products.map(item=>{
        if(item.id === productId){
            setTitle(item.title)
            setImage(item.image)
            setPrice(item.price)
        }
    })
  },[loading])

  const update_product = async ()=>{
     let product = {
        id:productId,
        title:title,
        image:image,
        price:price
     }
     console.log(product)
     //dispatch action postUpdateProduct từ createAsyncThunk 
    await dispatch(postUpdateProduct(product))
    navigate(`/products/${productId}`);
  }
  if(loading) return <div>Loading...!</div>
  return (
    <div>
        <h1>Update Product</h1>
        <input type="text" name="title" value={title} placeholder="Title" onChange={(e)=>setTitle(e.target.value)}/> <br/>
        <input type="text" name="image" value={image} placeholder="Link Image" onChange={(e)=>setImage(e.target.value)}/><br/>
        <input type="price" name="price" value={price} placeholder="Price" onChange={(e)=>setPrice(e.target.value)}/><br/>
        <button onClick ={()=>update_product()}>Update Product</button>
    </div>
  )
}

# Cầu hình Route cho ứng dụng

Tại file src/App.js ta cần cấu hình đường dẫn Route cho các component , nảy mình có cài thư viện React Router Dom rồi, thì việc còn lại chỉ cấu hình điều hướng các Route thôi

import React from 'react';
import './App.css';
import { Routes, Route, Link } from "react-router-dom";
import ProductsList from './features/products/List';
import ProductLayout from './features/products/Layout';
import ProductForm from './features/products/Form'
import ProductItem from './features/products/Item';
import ProductEdit from './features/products/Edit';
function App() {
  return (
    <div className="App">
        <header>
          <h1>Welcome to Hoanguyenit!</h1>
        </header>
        <Routes>
            <Route path="/" element={<ProductsList />} />
            <Route path="products" element={<ProductLayout />}>
                <Route index element={<ProductsList />} />
                <Route path="new" element={<ProductForm />} />
                <Route path=":productId" element={<ProductItem />} />
                <Route path=":productId/edit" element={<ProductEdit />} />
            </Route>
        </Routes>
    </div>
  );
}

export default App;

# Chỉnh sửa tập tin index.js

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css';
import { BrowserRouter } from "react-router-dom";
const container = document.getElementById('root');
const root = createRoot(container);

root.render(
  <React.StrictMode>
    <Provider store={store}>
       <BrowserRouter>
           <App />
        </BrowserRouter>
    </Provider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

# Hình ảnh Demo

Hình 1: Load danh sách product, đó mặc định chạy comonent List.js

Hình 2 : Form tạo product, component Form.js

 

Hình 3 :  Sau khi thêm product thành công, thì redirect về trang danh sách, bạn nhìn mấy chổ console nhé

 

Hình 4 : hiển thị product theo id được click, component Item.js

 

 

 

Bài Viết Liên Quan

Messsage

Ủng hộ tôi bằng cách click vào quảng cáo. Để tôi có kinh phí tiếp tục phát triển Website!(Support me by clicking on the ad. Let me have the money to continue developing the Website!)