19.1 什麼是 Redux?

Redux 是一個可預測的狀態管理工具,適合管理大型應用的全域狀態

Redux 的三大原則

  1. 單一資料源:整個應用的 state 存在一個 Store 中
  2. State 是唯讀的:只能透過 dispatch action 來改變
  3. 純函式更新:Reducer 是純函式

何時需要 Redux?

用 Context + useReducer 用 Redux Toolkit
中小型應用 大型應用
少量全域狀態 大量全域狀態
簡單的更新邏輯 複雜的更新邏輯
不需要中介軟體 需要非同步處理、日誌

19.2 安裝 Redux Toolkit

npm install @reduxjs/toolkit react-redux

19.3 核心概念

UI → dispatch(action) → Middleware → Reducer → Store → UI 更新

建立 Slice(Redux Toolkit 的核心)

// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
    history: [],
  },
  reducers: {
    increment: (state) => {
      state.value += 1; // Redux Toolkit 使用 Immer,可以「直接修改」state
      state.history.push(`+1 → ${state.value}`);
    },
    decrement: (state) => {
      state.value -= 1;
      state.history.push(`-1 → ${state.value}`);
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
      state.history.push(`+${action.payload} → ${state.value}`);
    },
    reset: (state) => {
      state.value = 0;
      state.history = [];
    },
  },
});

// 導出 action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;

// 導出 reducer
export default counterSlice.reducer;

建立 Store

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export default store;

在應用中提供 Store

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

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

在元件中使用

// features/counter/Counter.jsx
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount, reset } from './counterSlice';

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const history = useSelector((state) => state.counter.history);
  const dispatch = useDispatch();

  return (
    <div style={{ maxWidth: '400px', margin: '20px auto', textAlign: 'center' }}>
      <h1 style={{ fontSize: '4rem' }}>{count}</h1>

      <div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
        <button onClick={() => dispatch(decrement())}>-1</button>
        <button onClick={() => dispatch(increment())}>+1</button>
        <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
        <button onClick={() => dispatch(reset())}>重置</button>
      </div>

      <div style={{ marginTop: '20px', textAlign: 'left' }}>
        <h3>操作歷史</h3>
        <ul>
          {history.map((entry, i) => (
            <li key={i}>{entry}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

export default Counter;

19.4 非同步操作 — createAsyncThunk

// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// 建立非同步 Thunk
export const fetchPosts = createAsyncThunk(
  'posts/fetchPosts',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
      if (!response.ok) throw new Error('載入失敗');
      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

export const createPost = createAsyncThunk(
  'posts/createPost',
  async (postData, { rejectWithValue }) => {
    try {
      const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(postData),
      });
      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [],
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
  },
  reducers: {
    deletePost: (state, action) => {
      state.items = state.items.filter((post) => post.id !== action.payload);
    },
  },
  extraReducers: (builder) => {
    builder
      // fetchPosts
      .addCase(fetchPosts.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload;
      })
      // createPost
      .addCase(createPost.fulfilled, (state, action) => {
        state.items.unshift(action.payload);
      });
  },
});

export const { deletePost } = postsSlice.actions;
export default postsSlice.reducer;

使用非同步 Thunk 的元件

// features/posts/PostList.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPosts, deletePost } from './postsSlice';

function PostList() {
  const dispatch = useDispatch();
  const { items: posts, status, error } = useSelector((state) => state.posts);

  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchPosts());
    }
  }, [status, dispatch]);

  if (status === 'loading') return <p>載入中...</p>;
  if (status === 'failed') return <p style={{ color: 'red' }}>錯誤:{error}</p>;

  return (
    <div>
      <h2>文章列表(Redux Toolkit)</h2>
      {posts.map((post) => (
        <div key={post.id} style={{
          padding: '12px',
          borderBottom: '1px solid #eee',
          display: 'flex',
          justifyContent: 'space-between',
        }}>
          <div>
            <strong>{post.title}</strong>
            <p style={{ color: '#666', margin: '4px 0 0' }}>
              {post.body?.substring(0, 80)}...
            </p>
          </div>
          <button onClick={() => dispatch(deletePost(post.id))}>刪除</button>
        </div>
      ))}
    </div>
  );
}

export default PostList;

19.5 多個 Slice 整合

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import postsReducer from '../features/posts/postsSlice';
import authReducer from '../features/auth/authSlice';

const store = configureStore({
  reducer: {
    counter: counterReducer,
    posts: postsReducer,
    auth: authReducer,
  },
});

export default store;

// State 結構:
// {
//   counter: { value: 0, history: [] },
//   posts: { items: [], status: 'idle', error: null },
//   auth: { user: null, token: null, status: 'idle' },
// }

19.6 練習題

練習:建立一個完整的 Todo App with Redux Toolkit

  • todosSlice:管理待辦事項(CRUD)
  • filterSlice:管理過濾條件
  • 非同步載入初始資料
  • 樂觀更新(先更新 UI,失敗再還原)

本課重點回顧

  1. Redux Toolkit 大幅簡化了 Redux 的使用
  2. createSlice 自動產生 action creators 和 reducer
  3. 可以在 reducer 中「直接修改」state(Immer 處理不可變性)
  4. createAsyncThunk 處理非同步操作
  5. useSelector 讀取 state,useDispatch 發送 action
  6. extraReducers 處理非同步 thunk 的三種狀態