Blog

/

Frontend

Redux Toolkit

Redux POS Redux Toolkit

Posted at

2026年3月23日

Posted on

Frontend

Redux 很適合管理大型應用的複雜狀態,舉凡 POS 系統的訂單流程、交易所的開單、高併發應用的數據同步,都非常適合用 Redux Toolkit。但如果狀態簡單、應用規模小,useContext 或 Zustand 更會更合適。

特性

Redux Toolkit

Zustand

useContext

Recoil

DevTools 支援

優秀

一般

非同步操作

Thunk/Saga

簡單

需自行

適用規模

大型(500+)

中型(100-500)

小型(<100)

中型

適合場景

POS、交易所、高併發

聊天室、LLM

簡單全局

細粒度

Redux Toolkit 基礎概念

Redux Toolkit 的核心是 createSlice。一個 slice 包含狀態、reducer 和 action,完全替代了原生 Redux 的冗長寫法。

import { createSlice } from '@reduxjs/toolkit'

const orderSlice = createSlice({
  name: 'orders',
  initialState: {
    items: [],
    total: 0,
    status: 'idle'
  },
  reducers: {
    addOrder: (state, action) => {
      // Immer 讓你可以直接修改 state
      state.items.push(action.payload)
      state.total += action.payload.amount
    },
    removeOrder: (state, action) => {
      state.items = state.items.filter(order => order.id !== action.payload)
    }
  }
})

export const { addOrder, removeOrder } = orderSlice.actions
export default orderSlice.reducer

Redux Toolkit 內建 Immer,所以 reducer 中你可以直接修改 state,不需要手動做不可變更新。

實例:交易所開單、撮合交易和實時持倉更新

開單的核心流程是:用戶開單 → 伺服器撮合 → WebSocket 推送成交結果 → 客戶端更新持倉。Redux 需要管理整個客戶端狀態:開單表單、待開訂單、已成交訂單、實時持倉、可用資金。

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

// 開單:提交新訂單到伺服器
export const submitOrder = createAsyncThunk(
  'exchange/submitOrder',
  async (orderData, { rejectWithValue }) => {
    try {
      const res = await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify(orderData)
      })
      if (!res.ok) throw new Error('Order submission failed')
      return await res.json()  // 返回 { orderId, symbol, side, price, quantity, status: 'open' }
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

// 取消訂單
export const cancelOrder = createAsyncThunk(
  'exchange/cancelOrder',
  async (orderId, { rejectWithValue }) => {
    try {
      const res = await fetch(`/api/orders/${orderId}`, { method: 'DELETE' })
      if (!res.ok) throw new Error('Cancel failed')
      return await res.json()  // 返回 { orderId, cancelledAt }
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

const exchangeSlice = createSlice({
  name: 'exchange',
  initialState: {
    // 開單表單狀態
    orderForm: {
      symbol: 'BTC/USDT',
      side: 'buy',  // 'buy' 或 'sell'
      price: 47000,
      quantity: 0.5,
      totalCost: 0,
      errors: {}
    },

    // 訂單和交易
    orders: {
      byId: {},  // { orderId: { orderId, symbol, side, price, quantity, status, filledQuantity } }
      allIds: []
    },

    trades: {
      byId: {},  // { tradeId: { tradeId, orderId, symbol, price, quantity, timestamp } }
      allIds: []
    },

    // 持倉和資金
    positions: {
      'BTC': { symbol: 'BTC', quantity: 2, averageEntryPrice: 45000, currentPrice: 47000 },
      'ETH': { symbol: 'ETH', quantity: 10, averageEntryPrice: 3000, currentPrice: 3100 }
    },

    // 賬戶資訊
    account: {
      totalBalance: 100000,
      availableCash: 23500,
      frozenCash: 0,  // 掛單凍結資金
      equity: 100000  // 實時權益
    },

    // 實時行情(來自 WebSocket)
    prices: {
      'BTC': 47000,
      'ETH': 3100
    },

    // 加載和錯誤狀態
    loading: false,
    error: null
  },

  reducers: {
    // 表單輸入
    setOrderFormField: (state, action) => {
      const { field, value } = action.payload
      state.orderForm[field] = value

      // 自動計算總成本
      if (field === 'price' || field === 'quantity') {
        const totalCost = state.orderForm.price * state.orderForm.quantity
        state.orderForm.totalCost = totalCost

        // 驗證餘額
        if (state.orderForm.side === 'buy' && totalCost > state.account.availableCash) {
          state.orderForm.errors.insufficientFunds = `需要 ${totalCost} USDT,可用 ${state.account.availableCash}`
        } else {
          state.orderForm.errors.insufficientFunds = null
        }
      }
    },

    // 清空表單
    resetOrderForm: (state) => {
      state.orderForm = {
        symbol: 'BTC/USDT',
        side: 'buy',
        price: 0,
        quantity: 0,
        totalCost: 0,
        errors: {}
      }
    },

    // WebSocket:訂單開啟
    orderOpened: (state, action) => {
      const order = action.payload
      state.orders.byId[order.orderId] = {
        ...order,
        status: 'open',
        filledQuantity: 0,
        createdAt: new Date().toISOString()
      }
      state.orders.allIds.push(order.orderId)

      // 凍結資金(買單)
      if (order.side === 'buy') {
        state.account.frozenCash += order.price * order.quantity
        state.account.availableCash -= order.price * order.quantity
      }
    },

    // WebSocket:訂單部分成交
    orderPartiallyFilled: (state, action) => {
      const { orderId, tradeId, price, quantity, timestamp } = action.payload
      const order = state.orders.byId[orderId]

      if (!order) return

      order.filledQuantity += quantity
      order.status = 'partially_filled'

      // 記錄成交
      state.trades.byId[tradeId] = {
        tradeId,
        orderId,
        symbol: order.symbol,
        side: order.side,
        price,
        quantity,
        timestamp
      }
      state.trades.allIds.push(tradeId)

      // 更新持倉
      const [baseSymbol] = order.symbol.split('/')
      if (!state.positions[baseSymbol]) {
        state.positions[baseSymbol] = { symbol: baseSymbol, quantity: 0, averageEntryPrice: 0, currentPrice: price }
      }

      const pos = state.positions[baseSymbol]
      if (order.side === 'buy') {
        const totalCost = pos.quantity * pos.averageEntryPrice + quantity * price
        pos.quantity += quantity
        pos.averageEntryPrice = totalCost / pos.quantity
      } else if (order.side === 'sell') {
        pos.quantity -= quantity
      }

      // 更新資金
      if (order.side === 'buy') {
        // 買單:實際成交時,凍結資金減少,現金也減少
        state.account.frozenCash -= price * quantity
      } else {
        // 賣單:收到現金
        state.account.availableCash += price * quantity
      }

      // 更新權益
      state.account.equity = state.account.availableCash + state.account.frozenCash +
        Object.values(state.positions).reduce((sum, pos) => sum + pos.quantity * state.prices[pos.symbol], 0)
    },

    // WebSocket:訂單全部成交
    orderFilled: (state, action) => {
      const { orderId } = action.payload
      const order = state.orders.byId[orderId]
      if (order) {
        order.status = 'filled'
      }
    },

    // WebSocket:訂單已取消
    orderCancelled: (state, action) => {
      const { orderId } = action.payload
      const order = state.orders.byId[orderId]
      if (order) {
        order.status = 'cancelled'
        // 解凍資金
        if (order.side === 'buy') {
          const unfrozenAmount = order.price * (order.quantity - order.filledQuantity)
          state.account.frozenCash -= unfrozenAmount
          state.account.availableCash += unfrozenAmount
        }
      }
    },

    // 實時價格更新(來自 WebSocket)
    updatePrice: (state, action) => {
      const { symbol, price } = action.payload
      state.prices[symbol] = price

      // 更新持倉的當前價格
      if (state.positions[symbol]) {
        state.positions[symbol].currentPrice = price
      }

      // 實時更新權益
      state.account.equity = state.account.availableCash + state.account.frozenCash +
        Object.values(state.positions).reduce((sum, pos) => sum + pos.quantity * (state.prices[pos.symbol] || 0), 0)
    }
  },

  extraReducers: (builder) => {
    builder
      .addCase(submitOrder.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(submitOrder.fulfilled, (state, action) => {
        state.loading = false
        // 伺服器返回訂單 ID,接下來靠 WebSocket 推送訂單狀態
      })
      .addCase(submitOrder.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload
      })
      .addCase(cancelOrder.fulfilled, (state, action) => {
        // 伺服器確認取消,WebSocket 會推送 orderCancelled
      })
  }
})

export const {
  setOrderFormField,
  resetOrderForm,
  orderOpened,
  orderPartiallyFilled,
  orderFilled,
  orderCancelled,
  updatePrice
} = exchangeSlice.actions

const store = configureStore({
  reducer: {
    exchange: exchangeSlice.reducer
  }
})

export default store

在組件中使用開單表單和實時更新:

'use client'

import { useDispatch, useSelector, useRef, useEffect } from 'react-redux'
import { useCallback } from 'react'
import {
  setOrderFormField,
  resetOrderForm,
  submitOrder,
  orderOpened,
  orderPartiallyFilled,
  orderCancelled,
  updatePrice
} from './store'

export function OrderForm() {
  const dispatch = useDispatch()
  const form = useSelector(state => state.exchange.orderForm)
  const account = useSelector(state => state.exchange.account)
  const loading = useSelector(state => state.exchange.loading)
  const wsRef = useRef(null)

  // 連接 WebSocket 監聽實時撮合和行情
  useEffect(() => {
    wsRef.current = new WebSocket('wss://api.exchange.com/ws')

    wsRef.current.onmessage = (event) => {
      const data = JSON.parse(event.data)

      switch (data.type) {
        case 'order_opened':
          dispatch(orderOpened(data.payload))
          break
        case 'order_partially_filled':
          dispatch(orderPartiallyFilled(data.payload))
          break
        case 'order_filled':
          dispatch(orderFilled(data.payload))
          break
        case 'order_cancelled':
          dispatch(orderCancelled(data.payload))
          break
        case 'price_update':
          dispatch(updatePrice(data.payload))
          break
        default:
          break
      }
    }

    return () => {
      wsRef.current?.close()
    }
  }, [dispatch])

  const handleSubmit = async (e) => {
    e.preventDefault()

    // 驗證表單
    if (form.quantity <= 0 || form.price <= 0) {
      alert('請輸入有效的數量和價格')
      return
    }

    if (form.side === 'buy' && form.totalCost > account.availableCash) {
      alert('餘額不足')
      return
    }

    // 提交訂單到伺服器
    await dispatch(submitOrder({
      symbol: form.symbol,
      side: form.side,
      price: form.price,
      quantity: form.quantity
    }))

    // 開單成功後清空表單
    dispatch(resetOrderForm())
  }

  return (
    <div>
      <h2>開單</h2>
      <form onSubmit={handleSubmit}>
        <select
          value={form.side}
          onChange={(e) => dispatch(setOrderFormField({ field: 'side', value: e.target.value }))}
        >
          <option value="buy">買入</option>
          <option value="sell">賣出</option>
        </select>

        <input
          type="number"
          placeholder="數量"
          value={form.quantity}
          onChange={(e) => dispatch(setOrderFormField({ field: 'quantity', value: parseFloat(e.target.value) }))}
          step="0.0001"
        />

        <input
          type="number"
          placeholder="價格"
          value={form.price}
          onChange={(e) => dispatch(setOrderFormField({ field: 'price', value: parseFloat(e.target.value) }))}
          step="0.01"
        />

        <p>總成本: {form.totalCost.toFixed(2)} USDT</p>
        <p>可用資金: {account.availableCash.toFixed(2)} USDT</p>

        {form.errors.insufficientFunds && (
          <p style={{ color: 'red' }}>{form.errors.insufficientFunds}</p>
        )}

        <button type="submit" disabled={loading || Object.keys(form.errors).length > 0}>
          {loading ? '提交中...' : '提交訂單'}
        </button>
      </form>
    </div>
  )
}

export function PositionSummary() {
  const positions = useSelector(state => state.exchange.positions)
  const prices = useSelector(state => state.exchange.prices)
  const account = useSelector(state => state.exchange.account)

  const unrealizedPnL = Object.values(positions).reduce((sum, pos) => {
    const currentValue = pos.quantity * (prices[pos.symbol] || 0)
    const entryValue = pos.quantity * pos.averageEntryPrice
    return sum + (currentValue - entryValue)
  }, 0)

  return (
    <div>
      <h2>持倉</h2>
      <p>權益: {account.equity.toFixed(2)} USDT</p>
      <p>可用資金: {account.availableCash.toFixed(2)} USDT</p>
      <p>凍結資金: {account.frozenCash.toFixed(2)} USDT</p>
      <p>未實現盈虧: {unrealizedPnL.toFixed(2)} USDT</p>

      <table>
        <thead>
          <tr>
            <th>資產</th>
            <th>數量</th>
            <th>成本價</th>
            <th>現價</th>
            <th>市值</th>
            <th>盈虧</th>
          </tr>
        </thead>
        <tbody>
          {Object.values(positions).map(pos => {
            const currentValue = pos.quantity * (prices[pos.symbol] || 0)
            const entryValue = pos.quantity * pos.averageEntryPrice
            const pnl = currentValue - entryValue
            return (
              <tr key={pos.symbol}>
                <td>{pos.symbol}</td>
                <td>{pos.quantity}</td>
                <td>{pos.averageEntryPrice}</td>
                <td>{prices[pos.symbol] || 0}</td>
                <td>{currentValue.toFixed(2)}</td>
                <td style={{ color: pnl >= 0 ? 'green' : 'red' }}>{pnl.toFixed(2)}</td>
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}

整個過程 Redux 管理:

  1. 開單:表單狀態管理、驗證、凍結資金計算

  2. 提交:createAsyncThunk 發送訂單到伺服器

  3. 撮合:WebSocket 接收伺服器推送的成交結果

  4. 更新:持倉、資金、權益實時更新

  5. 實時行情:價格變化自動更新持倉市值

Redux Toolkit 的強大功能

1. Redux DevTools 時間旅行除錯

Redux DevTools 是 Redux 最強大的功能,可以看到每一個 action 改變了什麼,還能回到任何一個狀態。在除錯複雜的狀態變化時,這是無價的。

const store = configureStore({
  reducer: {
    order: orderSlice.reducer,
    portfolio: portfolioSlice.reducer
  }
  // DevTools 自動整合,無需額外配置
})

2. 中間件系統

Redux Toolkit 的 middleware 讓你在 action 分發和 reducer 執行之間攔截和處理邏輯,對日誌、分析、錯誤追蹤很有用。

const customMiddleware = (store) => (next) => (action) => {
  console.log('Dispatching:', action)
  const result = next(action)
  console.log('New state:', store.getState())
  return result
}

const store = configureStore({
  reducer: {
    order: orderSlice.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(customMiddleware)
})

3. Immer 不可變更新

Redux Toolkit 內建 Immer,讓你在 reducer 中直接修改 state,無需手動做不可變複製。

// 不需要 ...state, ...state.items 這樣的複製
addItem: (state, action) => {
  state.items.push(action.payload)  // 直接修改
  state.total += action.payload.price  // Immer 會自動轉換成不可變更新
}

常見誤區

誤區 1:過度規範化導致複雜性

// 不好:過度規範化,邏輯複雜
{
  orderIds: [1, 2, 3],
  ordersById: {
    1: { id: 1, items: [1, 2], total: 100 },
    2: { ... }
  },
  itemIds: [1, 2, 3],
  itemsById: {
    1: { id: 1, name: 'Product' },
    2: { ... }
  }
}

誤區 2:在 reducer 中做非同步操作

// 不好
addOrder: async (state, action) => {
  const res = await fetch('/api/orders')  // 錯誤:reducer 必須是純函數
  state.items.push(res)
}

// 好:用 createAsyncThunk
export const submitOrder = createAsyncThunk(...)

Reducer 必須是純函數,所有非同步操作都用 createAsyncThunk 或 middleware 處理。

誤區 3:Selector 沒有記憶化導致性能問題

// 不好
const portfolioValue = useSelector(state => {
  return Object.values(state.portfolio.positions).reduce((...)  // 每次都重新計算
})

// 好:用 createSelector 記憶化
export const selectPortfolioValue = createSelector(
  [selectPositions, selectPrices],
  (positions, prices) => { ... }
)

參考資源