Redux 狀態管理

Redux Toolkit (RTK) 完整實戰:Store 設計、Slice、非同步 Thunk、RTK Query 與 Selector 最佳化。

核心概念

Store

整個應用的單一資料來源。由 configureStore 建立,接收多個 reducer 組合成完整的 state 樹。

Slice

createSlice 將一個功能模組的 initialState、reducers、action creators 整合在一起,大幅減少樣板程式碼。

Immer(自動 Immutable)

RTK 內建 Immer,reducer 中可以直接 mutate state(如 state.value += 1),Immer 會在底層產生新的 immutable 物件,不需手動展開。

Dispatch

呼叫 dispatch(action) 將 action 送進 Store,Store 呼叫 reducer 計算新 state 並通知所有訂閱者(元件)重新 render。

Selector

透過 useSelector 從 Store 讀取資料。搭配 createSelector 可做 memoized 衍生計算,避免不必要的重新 render。

Middleware

位於 dispatch → reducer 之間的擴充層。RTK 預設加入 redux-thunk(處理非同步) 和 serializability check。可透過 getDefaultMiddleware 追加自訂 middleware。

完整初始設定(TypeScript)

RTK 與 TypeScript 整合的標準檔案結構,匯出型別化的 hooks 供全專案使用。

store.ts — 建立 Store 並匯出型別

import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './features/counter/counterSlice'
import userReducer from './features/user/userSlice'

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
})

// 從 store 推導出全域 RootState 型別,不需手動維護
export type RootState = ReturnType<typeof store.getState>
// AppDispatch 保留 thunk 型別資訊
export type AppDispatch = typeof store.dispatch

hooks.ts — 型別化的 useAppSelector / useAppDispatch

專案中永遠使用這兩個 hook,而非直接用 useSelector / useDispatch,確保型別安全。

import { useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'

// 使用 useAppDispatch 才能讓 dispatch(thunk) 有正確的型別
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()

Provider — 掛載到 React 根元件

// app/layout.tsx 或 _app.tsx
import { Provider } from 'react-redux'
import { store } from '@/lib/store'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Provider store={store}>{children}</Provider>
      </body>
    </html>
  )
}

createSlice 深入

initialState 型別設計 + 完整 Slice

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface Todo {
  id: string
  text: string
  completed: boolean
}

interface TodoState {
  items: Todo[]
  filter: 'all' | 'active' | 'completed'
}

const initialState: TodoState = {
  items: [],
  filter: 'all',
}

const todoSlice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTodo: {
      // prepare callback:在存入 state 前先加工 payload
      reducer(state, action: PayloadAction<Todo>) {
        state.items.push(action.payload)
      },
      prepare(text: string) {
        return {
          payload: { id: crypto.randomUUID(), text, completed: false },
        }
      },
    },
    toggleTodo(state, action: PayloadAction<string>) {
      const todo = state.items.find((t) => t.id === action.payload)
      if (todo) todo.completed = !todo.completed   // Immer 允許直接 mutate
    },
    removeTodo(state, action: PayloadAction<string>) {
      state.items = state.items.filter((t) => t.id !== action.payload)
    },
    setFilter(state, action: PayloadAction<TodoState['filter']>) {
      state.filter = action.payload
    },
    // 重置整個 slice 狀態
    resetTodos: () => initialState,
  },
})

export const { addTodo, toggleTodo, removeTodo, setFilter, resetTodos } = todoSlice.actions
export default todoSlice.reducer

reducers

定義同步 action。每個 key 自動產生對應的 action creator,呼叫時傳入的參數即為 action.payload

extraReducers

回應其他 slice 或 thunk 產生的 action,通常用來處理 createAsyncThunk 的三個狀態。

prepare callback

在 payload 存入 reducer 前對其加工,例如產生 id、加上 timestamp 等,讓 reducer 保持 pure function。

重置 reset 技巧

resetTodos: () => initialState:直接回傳 initialState 替換掉整個 slice state,不需展開。

createAsyncThunk — 非同步資料流

RTK 內建的非同步 action 工具,自動產生 pending / fulfilled / rejected 三個 action,並與 extraReducers 搭配處理 loading / error / data 狀態。

1. 定義 Thunk + 完整 Slice(含 loading state)

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

interface User {
  id: number
  name: string
  email: string
}

interface UserState {
  data: User | null
  status: 'idle' | 'loading' | 'succeeded' | 'failed'
  error: string | null
}

const initialState: UserState = { data: null, status: 'idle', error: null }

// createAsyncThunk<回傳型別, 參數型別>
export const fetchUser = createAsyncThunk<User, number>(
  'user/fetchById',
  async (userId, { rejectWithValue }) => {
    try {
      const res = await fetch(`/api/users/${userId}`)
      if (!res.ok) throw new Error('Server error')
      return (await res.json()) as User
    } catch (err) {
      // rejectWithValue 讓 rejected.payload 有型別(非 Error 物件)
      return rejectWithValue((err as Error).message)
    }
  }
)

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    clearUser: (state) => { state.data = null; state.status = 'idle' },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading'
        state.error = null
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.data = action.payload        // 型別安全:User
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.payload as string ?? 'Unknown error'
      })
  },
})

export const { clearUser } = userSlice.actions
export default userSlice.reducer

2. 在元件中 dispatch Thunk + 處理 UI 狀態

'use client'
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '@/lib/hooks'
import { fetchUser } from '@/lib/features/user/userSlice'

export default function UserProfile({ userId }: { userId: number }) {
  const dispatch = useAppDispatch()
  const { data, status, error } = useAppSelector((state) => state.user)

  useEffect(() => {
    dispatch(fetchUser(userId))
  }, [dispatch, userId])

  if (status === 'loading') return <p>Loading...</p>
  if (status === 'failed') return <p className="text-red-500">{error}</p>
  if (!data) return null

  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.email}</p>
    </div>
  )
}

取消 Thunk(AbortController)

dispatch(thunk) 回傳一個帶有 .abort() 方法的 Promise,搭配 signal 參數可在元件 unmount 時取消進行中的請求,防止 race condition。

// thunk 內部
async (userId, { signal }) => {
  const res = await fetch(`/api/users/${userId}`, { signal })
  ...
}

// 元件中
useEffect(() => {
  const promise = dispatch(fetchUser(userId))
  return () => promise.abort()   // unmount 時取消
}, [dispatch, userId])

RTK Query — 資料請求與快取

RTK Query 是 RTK 內建的強力資料請求解決方案,自動處理 loading / caching / refetching / invalidation,大幅減少手寫 thunk + extraReducers 的樣板。

1. 定義 API Slice(apiSlice.ts)

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

interface Post { id: number; title: string; body: string }

export const postsApi = createApi({
  reducerPath: 'postsApi',   // store 裡的 key
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],         // 用於 cache invalidation

  endpoints: (builder) => ({
    // query:GET 請求
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    getPostById: builder.query<Post, number>({
      query: (id) => `/posts/${id}`,
      providesTags: (result, error, id) => [{ type: 'Post', id }],
    }),

    // mutation:POST / PUT / DELETE
    createPost: builder.mutation<Post, Partial<Post>>({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      // 建立成功後,使所有 'Post' 快取失效 → 自動 refetch
      invalidatesTags: ['Post'],
    }),
    deletePost: builder.mutation<void, number>({
      query: (id) => ({ url: `/posts/${id}`, method: 'DELETE' }),
      invalidatesTags: (result, error, id) => [{ type: 'Post', id }],
    }),
  }),
})

// 自動產生的 hooks
export const {
  useGetPostsQuery,
  useGetPostByIdQuery,
  useCreatePostMutation,
  useDeletePostMutation,
} = postsApi

2. 將 RTK Query reducer 加入 Store

import { configureStore } from '@reduxjs/toolkit'
import { postsApi } from './features/posts/apiSlice'

export const store = configureStore({
  reducer: {
    [postsApi.reducerPath]: postsApi.reducer,  // RTK Query 管理的快取
    // ...其他 slice reducer
  },
  // 加入 RTK Query middleware(負責快取生命週期管理)
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(postsApi.middleware),
})

3. 在元件中使用(自動快取)

'use client'
import {
  useGetPostsQuery,
  useCreatePostMutation,
  useDeletePostMutation,
} from '@/lib/features/posts/apiSlice'

export default function PostList() {
  // 自動 fetch、快取、loading/error 狀態一次搞定
  const { data: posts, isLoading, isError, refetch } = useGetPostsQuery()

  const [createPost, { isLoading: isCreating }] = useCreatePostMutation()
  const [deletePost] = useDeletePostMutation()

  if (isLoading) return <p>Loading...</p>
  if (isError) return <button onClick={refetch}>重試</button>

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>
          {post.title}
          <button onClick={() => deletePost(post.id)}>刪除</button>
        </li>
      ))}
      <button
        disabled={isCreating}
        onClick={() => createPost({ title: 'New Post', body: '...' })}
      >
        新增
      </button>
    </ul>
  )
}

providesTags / invalidatesTags

Cache invalidation 機制。mutation 執行後帶有 invalidatesTags,所有 query 中標記了相符 providesTags 的快取會被清除並自動重新請求。

Polling(輪詢)

useGetPostsQuery(undefined, { pollingInterval: 30000 }):每 30 秒自動 refetch,適合需要即時更新的場景,無需手動 setInterval。

Skip 與條件式請求

useGetPostByIdQuery(id, { skip: !id }):當 id 為 falsy 時跳過請求,避免多餘 API 呼叫。

vs createAsyncThunk

RTK Query 適合標準 CRUD API(自動快取、去重複請求);createAsyncThunk 適合複雜非同步副作用(WebSocket、批次操作、依賴其他 state 的邏輯)。

createSelector — Memoized Selector

來自 Reselect 库(RTK 已內建),透過 memoization 避免 useSelector 每次 render 都重新計算衍生資料,有效減少不必要的 re-render。

基礎用法 vs 問題場景

import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '@/lib/store'

// ❌ 每次 render 都重算,產生新陣列 → 元件必然 re-render
const badSelector = (state: RootState) =>
  state.todos.items.filter((t) => t.completed)

// ✅ createSelector:只有在 state.todos.items 或 state.todos.filter 變更時才重算
const selectTodosItems = (state: RootState) => state.todos.items
const selectFilter = (state: RootState) => state.todos.filter

export const selectFilteredTodos = createSelector(
  [selectTodosItems, selectFilter],          // input selectors
  (items, filter) => {                        // result function(memoized)
    if (filter === 'all') return items
    return items.filter((t) =>
      filter === 'completed' ? t.completed : !t.completed
    )
  }
)

// 帶參數的 selector(工廠模式,每個元件有獨立 memoize 快取)
export const makeSelectTodoById = () =>
  createSelector(
    [(state: RootState) => state.todos.items, (_: RootState, id: string) => id],
    (items, id) => items.find((t) => t.id === id)
  )

// 在元件中
const filteredTodos = useAppSelector(selectFilteredTodos)

// 工廠模式使用
const selectTodoById = useMemo(makeSelectTodoById, [])
const todo = useAppSelector((state) => selectTodoById(state, todoId))

Redux 資料流全覽

UI 互動
  │
  ▼  dispatch(action) / dispatch(thunk)
Middleware(redux-thunk、logger 等)
  │  同步 action 直接到 Reducer
  │  非同步 thunk 執行副作用後再 dispatch action
  ▼
Reducer(createSlice reducers / extraReducers)
  │  pure function:(currentState, action) => newState
  │  Immer 讓你寫 mutating 語法,底層仍產生新物件
  ▼
Store(單一 state 樹)
  │  state 更新後通知所有 useSelector 訂閱者
  ▼
useSelector(+ createSelector memoization)
  │  只有選取的資料真正變化才觸發 re-render
  ▼
UI 重新渲染

適合放入 Redux

  • 多元件共享的全域狀態
  • 伺服器資料快取(RTK Query)
  • 使用者認證 / 權限資訊
  • 複雜的跨元件互動狀態

不適合放入 Redux

  • 本地 UI 狀態(modal 開關)
  • 表單暫存輸入值
  • 只有單一元件使用的資料
  • 可從其他 state 衍生的資料

效能最佳化要點

  • 使用 createSelector memoize 衍生資料
  • Selector 顆粒度要細(避免選整個 slice)
  • mutation 用 RTK Query 取代手寫 thunk
  • 搭配 React.memo 減少子元件 re-render