[React] Redux Toolkit Query(RTKQ) 基礎使用指南

RTKQ logo

Web應用中加載數據時需要處理的問題:

  1. 根據不同的加載狀態顯示不同的 UI 組件
  2. 減少對相同數據重複發送請求
  3. 使用樂觀更新,提升用戶體驗
  4. 在用戶與 UI 交互時,管理快取的生命週期

以上這些問題 RTKQ 都可以幫助我們處理。

首先,可以直接通過 RTKQ 向 Server 發送請求加載數據,並且 RTKQ 會自動對數據進行快取,避免重複發送不必要的請求。其次 RTKQ 在發送請求時會根據請求不同的狀態返回不同的值,我們可以通過這些值來監視請求發送的過程並隨時停止。

使用 RTKQ 之前需要了解如何使用 RTK

安裝 RTK

RTKQ 已經集成在 Redux Toolkit (RTK) 中,所以安裝 RTK 即可:

# NPM
npm install @reduxjs/toolkit

# Yarn
yarn add @reduxjs/toolkit

創建 API Service

創建一個 services/student.js,使用 createApi 創建一個 API service:

  • reducerPath: Api的標示, 確保唯一性即可, 默認值為 api
  • baseQuery: 搭配 fetchBaseQuery 指定查詢的基礎信息, 類似 axios.create 的作用。最基礎的是設置 baseUrl,也就是 API 的基礎網址。
  • endpoints: 定義 endpoints, 用來指定 Api 的路徑, 類似 axios.get, axios.post… builder 為請求的構建器。
// src/services/student.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const studentApi = createApi({
  // Api的標示, 確保唯一性即可, 默認值為 api
  reducerPath: 'studentApi',
  // 指定查詢的基礎信息, 類似 axios.create 的作用
  baseQuery: fetchBaseQuery({
    baseUrl: 'http://localhost:3001'
  }),
  // 定義 endpoints, 用來指定 Api 的路徑, 類似 axios.get, axios.post...
  // builder 為請求的構建器
  endpoints: (builder) => ({
    // ...
  })
})

endpoints 就是配置 API 路徑,GET 請求使用 builder.query,PUT, PATCH, DELETE, POST 請求使用 builder.mutation

...
endpoints: (builder) => ({
    getStudents: builder.query({
      query: () => '/students', // 請求的 API 路徑
      transformResponse: (response) => {
        // 可以在這裡預先處理資料
        return response
      }
    }),
    getStudentById: builder.query({
      query: (id) => `/student/${id}`,
      keepUnusedDataFor: 0 // 快取數據沒在使用的有效期(秒),設為 0 代表不使用快取,默認是 60 秒。
    }),
    updateStudent: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/student/${id}`,
        method: 'PUT',
        body: patch
      })
    }),
    postStudent: builder.mutation({
      query: (body) => ({
        url: '/student',
        method: 'POST',
        body
      })
    }),
    deleteStudent: builder.mutation({
      query: (id) => ({
        url: `/student/${id}`,
        method: 'DELETE'
      })
    })
})

Api service 創建後 RTKQ 會根據各種方法自動生成對應的 hooks,通過這些 hooks 可以向 server 發送請求。

hooks 的命名規則:

  • getStudents => useGetStudentsQuery
  • getStudentById => useGetStudentByIdQuery
  • updateStudent => useUpdateStudentMutation
  • postStudent => usePostStudentMutation
const studentApi = ....

export const {
  useGetStudentsQuery,
  useGetStudentByIdQuery,
  useUpdateStudentMutation,
  usePostStudentMutation
} = studentApi

export default studentApi

hook 的名字是有命名規則的,不是自己想怎麼取就怎麼取,可以打印 studentApi 出來看一下包含哪些 hooks。

創建 Store

使用 configureStore 創建 store,將 RTKQ service studentApi 的 reducer 放入 store 中並且需要額外配置 middleware。

RTKQ service 自帶 middleware,在 store 的 middleware 中加入剛剛新增的 API middleware:

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit'
import studentApi from '../services/student'

const store = configureStore({
  reducer: {
    [studentApi.reducerPath]: studentApi.reducer
  },
  middleware: (getDefaultMiddleware) => {
    return getDefaultMiddleware().concat(studentApi.middleware)
  }
})

export default store

補充:

  1. Reducer 是 Redux 中用來管理狀態的函數。在 Redux 中所有的狀態更新都由 reducer 來處理。
  2. Middleware 允許你在 dispatch action 和 reducer 之間執行額外的邏輯,加入 API middleware 可以處理 API 的快取。
  3. getDefaultMiddleware 是一個函數,它返回 RTK 預設的 middleware 列表。所以這段的意思就是將 studentApi.middleware 添加到默認的 middleware 列表中。

打開 index.jsx (或 main.jsx) 在 App 組件之外添加 Provider,並傳入 剛剛創建好的 store:

// src/main.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App.jsx'
import store from './store'

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
)

使用 json-server 模擬 API

json-server 是一個可以快速模擬 REST API 的工具。

npm install -g json-server

在 src 底下建立 mock/db.json 檔案,然後貼上以下數據:

{
  "students": [
    { "id": 1, "name": "Tom", "age": 20, "gender": "male" },
    { "id": 2, "name": "Jelly", "age": 27, "gender": "female" },
    { "id": 3, "name": "Victoria", "age": 33, "gender": "female" },
    { "id": 4, "name": "Kevin", "age": 16, "gender": "male" },
    { "id": 5, "name": "May", "age": 27, "gender": "female" },
    { "id": 6, "name": "Peter", "age": 45, "gender": "male" }
  ]
}

students 就是 endpoint 名稱

接著啟動 json-server

cd src/mock
json-server --watch db.json

預設 port 是 3000,如果衝突了可以在後面加上 --port 3001

開啟 http://localhost:3000/ 就能看見我們模擬的 API

image 44

使用 hook 發送 CRUD 請求

GET 請求

引入 RTKQ 自動生成的 API hook,試著打印出來 useGetStudentsQuery() 看看回傳的內容:

import {
  useGetStudentsQuery,
  useGetStudentByIdQuery,
  useUpdateStudentMutation,
  usePostStudentMutation
} from './services/student'

const App = () => {
  const students = useGetStudentsQuery()
  console.log(students)
  return <></>
}

hook 會回傳一個物件,物件中主要包括:

  • currentData: 表示截至上次成功取得或查詢解析的數據,保存來自最新成功請求的資訊。
  • data: 表示 query 或 mutation 返回的最新數據,無論是來自快取還是網路請求。
  • isError, isFetching, isLoading, isSuccess… API 請求的狀態。
  • refetch: 重新請求的函數。
image 45

data 跟 currentData 比較類似,data 是最新的數據,無論請求是否已經成功。而 currentData 則是當前參數的最新一次請求成功的數據,因此如果要回退到當前參數最新成功的請求結果,需要用到 currentData。

根據 isLoading, isSuccess 可以為請求狀態做不同的 UI 顯示:

import {
  useGetStudentsQuery,
  useGetStudentByIdQuery,
  useUpdateStudentMutation,
  usePostStudentMutation
} from './services/student'

const App = () => {
  const students = useGetStudentsQuery()
  if (students.isLoading) {
    return (
      <div>loading...</div>
    )
  }
  return (
      <div>
      {students.isSuccess && students.data.map(student => (
        <p key={student.id}>{student.name}-{student.age}-{student.gender}</p>
      ))}
    </div>
  )
}

selectFromResult

useGetStudentsQuery 能傳入第二個參數,第二個參數為一個物件,物件中提供 selectFromResult 用於處理結果,類似於 API 中設置的 transformResponse,不過 selectFromResult 使用場景較少。

const students = useGetStudentsQuery(null, {
    selectFromResult: result => {
      if (result.data) {
        result.data = result.data.filter(student => student.age > 18)
      }
      return result
    }
  })

pollingInterval

如果想一段時間發送一次請求,可以設置 pollingInterval,單位為毫秒。

const students = useGetStudentsQuery(null, {
    pollingInterval: 2000
  })

skip

若希望在特定情況下不發送請求,可以使用 skip。比如若 id 不存在,則不發送請求:

const { data, isSuccess } = useGetStudentByIdQuery(id, {
    skip: !id
  }

refetchOnMountOrArgChange

是否每次都重新發送請求,設置 false 正常使用快取,設置 true 則不使用快取。

或者也可以設置成數值,代表請求有效期,若快取數據沒在使用的時間超過有效期(秒)則重新請求。

const { data, isSuccess } = useGetStudentByIdQuery(id, {
    refetchOnMountOrArgChange: true,
  })

refetchOnFocus & refetchOnReconnect

  • refetchOnFocus 用於設置是否在獲取焦點時重新發送請求
  • refetchOnReconnect 則是設置是否在重新連接時重新發送請求
const { data, isSuccess } = useGetStudentByIdQuery(id, {
    refetchOnFocus: false, // 是否在獲取焦點時重新發送請求
    refetchOnReconnect: false, // 是否在重新連接時重新發送請求
  })

這兩個選項需要搭配 setupListeners(store.dispatch) 才會生效。

// src/store/index.js
import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import studentApi from '../services/student'

const store = configureStore({
  reducer: {
    [studentApi.reducerPath]: studentApi.reducer
  },
  middleware: (getDefaultMiddleware) => {
    return getDefaultMiddleware().concat(studentApi.middleware)
  }
})

setupListeners(store.dispatch) // 設置以後 RTKQ 將會支持 refetchOnFocus, refetchOnReconnect

export default store

POST, PUT, DELETE 請求

mutation hook 會回傳一個陣列,第一個元素為 dispatch action,第二個元素則是 action 的結果:

import { Student, StudentForm } from './components'
import { useGetStudentsQuery, usePostStudentMutation, useUpdateStudentMutation, useDeleteStudentMutation } from './services/student'
import './App.css'

const App = () => {
  const students = useGetStudentsQuery()
  const [deleteStudent, deleteStudentResult] = useDeleteStudentMutation()
  const [addStudent, addStudentResult] = usePostStudentMutation()
  const [updateStudent, updateStudentResult] = useUpdateStudentMutation()

  const onSubmit = (type, data) => {
    try {
      if (type === 'add') {
        addStudent({ name: data.name, age: Number(data.age), gender: data.gender })
        console.log(addStudentResult);
      } else if (type === 'edit') {
        updateStudent({ ...data, age: Number(data.age) })
        console.log(updateStudentResult)
      } else if (type === 'delete') {
        deleteStudent(data.id)
        console.log(deleteStudentResult)
      }
    } catch (err) {
      console.error(err)
    }
  }
    ...
}

export default App

result 是一個物件,其中包括請求的狀態和 reset function:

image 46

一般來說新增、編輯、刪除之後,要重新加載才能看見最新的數據,但 RTKQ 有一個很妙的參數,只要設置之後不需要自行重新請求。

自動重新請求最新數據

getStudents API 設置 providesTags: ['Student'],然後在新增、編輯和刪除的 API 設置 invalidatesTags: ['Student'] 就可以在新增、編輯和刪除請求成功後自動重新調用 getStudents

  • providesTags: 設定 API 的快取 tag,值為陣列。
  • 元素可以是單純的字串,如 ['Student'],或物件 [{ type: 'Student', id: 'List' }],或者函數 (result, error, id) => [{ type: 'Student', id }]
  • invalidatesTags: 一旦請求成功 invalidatesTags 會使使用該 tag 的相關快取資料失效,使用該 tag 的 API 就會重新加載。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const studentApi = createApi({
  reducerPath: 'studentApi',
  baseQuery: fetchBaseQuery({
    baseUrl: 'http://localhost:3001',
  }),
  endpoints: (builder) => ({
    getStudents: builder.query({
      query: () => '/students',
      providesTags: (result) => result
        ? [
          { type: 'Student', id: 'LIST' },
          ...result.map(({ id }) => ({ type: 'Student', id })),
        ]
        : [{ type: 'Student', id: 'LIST' }]
    }),
    getStudentById: builder.query({
      query: (id) => `/students/${id}`,
      keepUnusedDataFor: 60,
      providesTags: (result, error, id) => [{ type: 'Student', id }]
    }),
    updateStudent: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/students/${id}`,
        method: 'PUT',
        body: patch
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Student', id }]
    }),
    postStudent: builder.mutation({
      query: (body) => ({
        url: '/students',
        method: 'POST',
        body
      }),
      invalidatesTags: [{ type: 'Student', id: 'LIST' }]
    }),
    deleteStudent: builder.mutation({
      query: (id) => ({
        url: `/students/${id}`,
        method: 'DELETE'
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Student', id }]
    })
  })
})

export const {
  useGetStudentsQuery,
  useGetStudentByIdQuery,
  useUpdateStudentMutation,
  usePostStudentMutation,
  useDeleteStudentMutation
} = studentApi

export default studentApi

如果希望手動重新請求,也是可以直接使用 students.refetch()

const students = useGetStudentsQuery()
const [deleteStudent] = useDeleteStudentMutation()
const [addStudent] = usePostStudentMutation()
const [updateStudent] = useUpdateStudentMutation()

const onSubmit = (type, data) => {
  try {
    if (type === 'add') {
      addStudent({ name: data.name, age: Number(data.age), gender: data.gender })
    } else if (type === 'edit') {
      updateStudent({ ...data, age: Number(data.age) })
    } else if (type === 'delete') {
      deleteStudent(data.id)
    }
    students.refetch()
  } catch (err) {
    console.error(err)
  }
}

這個快取 tag 稍微有點難理解,可以查看官方文檔,有比較詳細的說明:https://redux-toolkit.js.org/rtk-query/usage/automated-refetching

程式碼分離(Code splitting)

可以先使用 createApi 定義一個基礎的 API Service:

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"

export const baseApi = createApi({
  baseQuery: fetchBaseQuery({
    baseUrl: 'http://localhost:3001',
    // 如果需要設置所有 API 的 headers,可以使用 headers 或者 prepareHeaders
    prepareHeaders: (headers, { getState }) => {
      const token = getState().auth.token
      if (token) {
        headers.set('authorization', `Bearer ${token}`)
      }
      return headers
    },
    headers: {
      'Content-Type': 'application/json'
    }
  }),
  endpoints: () => ({})
})

其餘的 API Service 則可以使用 injectEndpoints 將 endpoints 注入到原始 API 中:

import { baseApi } from "./api"

const studentApi = baseApi.injectEndpoints({
  endpoints: (builder) => ({
    getStudents: builder.query({
      query: () => '/students',
      providesTags: (result) => result
        ? [
          { type: 'Student', id: 'LIST' },
          ...result.map(({ id }) => ({ type: 'Student', id })),
        ]
        : [{ type: 'Student', id: 'LIST' }]
    }),
    getStudentById: builder.query({
      query: (id) => `/students/${id}`,
      keepUnusedDataFor: 60,
      providesTags: (result, error, id) => [{ type: 'Student', id }]
    }),
    updateStudent: builder.mutation({
      query: ({ id, ...patch }) => ({
        url: `/students/${id}`,
        method: 'PUT',
        body: patch
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'Student', id }]
    }),
    postStudent: builder.mutation({
      query: (body) => ({
        url: '/students',
        method: 'POST',
        body
      }),
      invalidatesTags: [{ type: 'Student', id: 'LIST' }]
    }),
    deleteStudent: builder.mutation({
      query: (id) => ({
        url: `/students/${id}`,
        method: 'DELETE'
      }),
      invalidatesTags: (result, error, id) => [{ type: 'Student', id }]
    })
  }),
})

export const {
  useGetStudentsQuery,
  useGetStudentByIdQuery,
  useUpdateStudentMutation,
  usePostStudentMutation,
  useDeleteStudentMutation
} = studentApi

錯誤處理中間件

可以使用 isRejectedWithValue 建立錯誤處理的函數:

import { isRejectedWithValue } from '@reduxjs/toolkit'

export const errorHandling =
  (api) => (next) => (action) => {
    if (isRejectedWithValue(action)) {
      const { error, status } = action.payload
      console.log(status, error)
    }

    return next(action)
  }

並且將錯誤處理函數加入 middleware 列表:

import { configureStore } from '@reduxjs/toolkit'
import { setupListeners } from '@reduxjs/toolkit/query'
import { baseAPI } from '../services/api'
import { errorHandling } from '../services/middleware'

const store = configureStore({
  reducer: {
    [baseAPI.reducerPath]: baseAPI.reducer
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(errorHandling, baseAPI.middleware),
})

setupListeners(store.dispatch)

export default store

如此一來當 API 請求失敗時,就能獲取到該請求相關的資料,以作相應的處理。

image 47
image 48

留言

目前沒有留言。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *