React下拉框onChange状态更新延迟问题排查求助
我有一个包含四种货币的下拉列表,期望每次选择新货币时,屏幕上的货币标识同步更新。但实际运行时出现异常:首次选择(例如欧元)时,所有货币标识变为空白;选择英镑时,所有货币标识才切换为欧元符号。请问为何我的状态更新会出现延迟?
UI截图

相关代码
components/currency.js
import React, { useState,useContext,useEffect } from 'react';import { AppContext } from '../context/AppContext'; const Budget = () => {const [currency, setCurrency] = useState('');const { dispatch } = useContext(AppContext); const handleChange = (event) => { setCurrency(event.target.value); changeCurrency() }; const changeCurrency = () => { dispatch({ type: 'CHG_CURRENCY', payload: currency, }); } useEffect(() => {console.log(currency);}, [currency] ) return ( <div className="alert alert-secondary"> <select id="inputGroupSelect02" onChange={handleChange}> <option defaultValue>Currency</option> <option value="$" name="dollar">$ Dollar</option> <option value="£" name="pound">£ Pound</option> <option value="€" name="euro">€ Euro</option> <option value="₹" name="ruppee">₹ Ruppee</option> </select> </div> ); export default Budget;
context/AppContext.js
import React, { createContext, useReducer } from 'react'; // 5. The reducer - this is used to update the state, based on the action export const AppReducer = (state, action) => { let budget = 0; switch (action.type) { case 'ADD_EXPENSE': let total_budget = 0; total_budget = state.expenses.reduce( (previousExp, currentExp) => { return previousExp + currentExp.cost },0 ); total_budget = total_budget + action.payload.cost; action.type = "DONE"; if(total_budget <= state.budget) { total_budget = 0; state.expenses.map((currentExp)=> { if(currentExp.name === action.payload.name) { currentExp.cost = action.payload.cost + currentExp.cost; } return currentExp }); return { ...state, }; } else { alert("Cannot increase the allocation! Out of funds"); return { ...state } } case 'RED_EXPENSE': const red_expenses = state.expenses.map((currentExp)=> { if (currentExp.name === action.payload.name && currentExp.cost - action.payload.cost >= 0) { currentExp.cost = currentExp.cost - action.payload.cost; budget = state.budget + action.payload.cost } return currentExp }) action.type = "DONE"; return { ...state, expenses: [...red_expenses], }; case 'DELETE_EXPENSE': action.type = "DONE"; state.expenses.map((currentExp)=> { if (currentExp.name === action.payload) { budget = state.budget + currentExp.cost currentExp.cost = 0; } return currentExp }) action.type = "DONE"; return { ...state, budget }; case 'SET_BUDGET': action.type = "DONE"; state.budget = action.payload; return { ...state, }; case 'CHG_CURRENCY': action.type = "DONE"; state.currency = action.payload; return { ...state } default: return state; } }; // 1. Sets the initial state when the app loads const initialState = { budget: 2000, expenses: [ { id: "Marketing", name: 'Marketing', cost: 50 }, { id: "Finance", name: 'Finance', cost: 300 }, { id: "Sales", name: 'Sales', cost: 70 }, { id: "Human Resource", name: 'Human Resource', cost: 40 }, { id: "IT", name: 'IT', cost: 500 }, ], currency: '£' }; // 2. Creates the context this is the thing our components import and use to get the state export const AppContext = createContext(); // 3. Provider component - wraps the components we want to give access to the state // Accepts the children, which are the nested(wrapped) components export const AppProvider = (props) => { // 4. Sets up the app state. takes a reducer, and an initial state const [state, dispatch] = useReducer(AppReducer, initialState); let remaining = 0; if (state.expenses) { const totalExpenses = state.expenses.reduce((total, item) => { return (total = total + item.cost); }, 0); remaining = state.budget - totalExpenses; } return ( <AppContext.Provider value={{ expenses: state.expenses, budget: state.budget, remaining: remaining, dispatch, currency: state.currency }} > {props.children} </AppContext.Provider> ); };
Spent so far 组件
import React, { useContext } from 'react'; import { AppContext } from '../context/AppContext'; const ExpenseTotal = () => { const { expenses,currency } = useContext(AppContext); const totalExpenses = expenses.reduce((total, item) => { return (total += item.cost); }, 0); return ( <div className='alert alert-primary'> <span>Spent so far: {currency}{totalExpenses}</span> </div> ); }; export default ExpenseTotal;
核心问题
setState异步特性导致延迟:React的setCurrency是异步更新状态的,调用setCurrency(event.target.value)后,立刻访问currency拿到的还是更新前的旧值。首次选择欧元时,组件内的currency初始值是空字符串,所以changeCurrency里dispatch的payload是空,导致context中的currency被设为空,显示空白;第二次选择英镑时,组件内的currency才更新为上次选择的欧元值,此时dispatch过去,context中的currency变成欧元,这就出现了“延迟一步”的现象。Reducer直接修改原state违反Immutable原则:在
CHG_CURRENCY的case中,代码直接修改了state.currency = action.payload,这会导致React无法正确检测到状态变化,可能引发更新异常。React要求状态更新必须返回新的对象,而不是修改原state。
修复方案
方案一:移除组件内的本地状态(推荐)
不需要在Budget组件内维护currency状态,直接将下拉选择的值作为dispatch的payload:
const handleChange = (event) => { // 直接使用event.target.value,无需本地state dispatch({ type: 'CHG_CURRENCY', payload: event.target.value, }); };
同时删除组件内的useState、changeCurrency函数和对应的useEffect,简化代码。
方案二:保留本地状态,用useEffect触发dispatch
如果需要保留组件内的currency状态,通过useEffect监听状态变化,在状态更新后再dispatch:
const handleChange = (event) => { setCurrency(event.target.value); }; // 当currency变化时才dispatch useEffect(() => { if(currency) { // 避免初始空值触发 dispatch({ type: 'CHG_CURRENCY', payload: currency, }); } }, [currency, dispatch])
修复Reducer的Immutable问题
修改CHG_CURRENCY的case,返回新的state对象,不要直接修改原state:
case 'CHG_CURRENCY': // 不要修改原state,返回新对象 return { ...state, currency: action.payload }
同时注意其他case中直接修改state的问题(比如ADD_EXPENSE里修改state.expenses的元素,SET_BUDGET里修改state.budget),都应该遵循Immutable原则,返回新的状态对象。
内容的提问来源于stack exchange,提问作者JessicaI

