React中onClick首次渲染读取旧state的异常问题排查
问题分析:React Context中切换插槽回调首次渲染使用旧State的异常
问题描述
为实现模块化通用标签页与插槽功能,基于React Context+Provider搭建状态管理架构,提供标签页和插槽的操作方法。但遇到异常:切换插槽的回调函数仅在首次渲染时使用旧的state,控制台打印的state值不正确,但React DevTools显示状态正常。切换到其他标签页再返回后功能恢复正常。
关键代码片段
TabProvider核心代码
export const TabProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => { const [tabComponents, setTabComponents] = useState<TabComponent[]>([]); const [currentTab, setCurrentTab] = useState(""); const [currentSlot, setCurrentSlot] = useState<TabSlot>({ label: "", component: null, id: "" }) useEffect(() => { console.log("Use Effect", currentTab, tabComponents, currentSlot) }, [currentSlot, tabComponents, currentTab]) const setCurrentSlotHelper = (slotId: string) => { console.log("On Click", slotId, tabComponents, currentSlot, currentTab) tabComponents.forEach(component => { component.slots.forEach(slot => { if (slot.id == slotId) { setCurrentSlot(slot) } }) }) }
DevTools显示的正确状态
[ { "name": "State", "value": [ "{id: \"dashboard\", label: \"Dashboard\", onCurrent: ƒ …}", "{id: \"content\", label: \"Content\", onCurrent: ƒ onCu…}", "{id: \"studio\", label: \"Studio\", onCurrent: ƒ onCurr…}" ], "subHooks": [], ]
完整代码
完整TabProvider代码
const TabContext = createContext<TabContextProps>(defaultState); export const useTab = () => useContext(TabContext); export const TabProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => { const [tabComponents, setTabComponents] = useState<TabComponent[]>([]); const [currentTab, setCurrentTab] = useState(""); const [currentSlot, setCurrentSlot] = useState<TabSlot>({ label: "", component: null, id: "" }) useEffect(() => { console.log("Use Effect", currentTab, tabComponents, currentSlot) }, [currentSlot, tabComponents, currentTab]) const setCurrentSlotHelper = (slotId: string) => { console.log("On Click", slotId, tabComponents, currentSlot, currentTab) tabComponents.forEach(component => { component.slots.forEach(slot => { if (slot.id == slotId) { setCurrentSlot(slot) } }) }) } const setTabComponentsHelper = (components: TabComponent[]) => ... const setCurrentTabHelper = (tabId: string) =>... return ( <TabContext.Provider value={{ tabComponents, setTabComponents: setTabComponentsHelper, currentTab, setCurrentTab: setCurrentTabHelper, currentSlot, setCurrentSlot: setCurrentSlotHelper }} > {children} </TabContext.Provider> ); };
使用示例代码
export const Template = () => { const { sidebarComponents, setSidebarComponents } = useSidebar() const { setTabComponents, currentSlot, setCurrentSlot } = useTab(); useEffect(() => { let newTabs: TabComponent[] = []; // 切换标签页时更新侧边栏组件并设置默认插槽 const onTabChange = (tab: TabComponent) => { const newSlotId = tab.slots[0].id const sidebarComponents: SidebarComponent[] = [] tab.slots.forEach(slot => { const slotId = slot.label.toLowerCase() sidebarComponents.push({ id: slotId, label: slot.label, isCurrent: newSlotId == slotId, onClick: () => setCurrentSlot(slotId) }) }) setCurrentSlot(newSlotId) // 此处调用setCurrentSlot setSidebarComponents(sidebarComponents) } const tabs = ecommerceTabs; for (let i = 0; i < tabs.length; i++) { const tab = tabs[i] const tabComponent: TabComponent = { id: tab.label.toLowerCase(), label: tab.label, slots: tab.slots.map(slot => { return { ...slot, id: slot.label.toLowerCase() } }), onCurrent: () => { onTabChange(tabComponent) } } newTabs.push(tabComponent) } onTabChange(newTabs[0]) setTabComponents(newTabs) }, []) // 当前插槽变化时更新侧边栏组件 useEffect(() => { if (sidebarComponents.length == 0) return; setSidebarComponents( sidebarComponents.map(component => { component.isCurrent = component.label.toLowerCase() == currentSlot.id return component }) ); }, [currentSlot]) return (<></>) };
问题原因分析
本质还是闭包捕获旧state的问题,只是场景容易被忽略:
- 在
Template组件的空依赖useEffect中,创建的onTabChange函数里,setCurrentSlot是从useTab获取的初始版本——此时TabProvider中的tabComponents还是空数组。 - 随后调用
onTabChange(newTabs[0])设置初始插槽,再调用setTabComponents(newTabs)更新tabComponents,但之前创建的侧边栏onClick回调捕获的是旧版setCurrentSlotHelper,这个旧版函数引用的tabComponents是空数组,所以首次点击时找不到对应插槽。 - 切换标签页时,
onCurrent触发onTabChange,此时重新创建的侧边栏组件会捕获最新的setCurrentSlot,功能因此恢复正常。
解决方案
方案1:用useCallback包裹setCurrentSlotHelper并稳定Context值
在TabProvider中,用useCallback包裹setCurrentSlotHelper,确保依赖更新时函数同步更新:
const setCurrentSlotHelper = useCallback((slotId: string) => { console.log("On Click", slotId, tabComponents, currentSlot, currentTab) tabComponents.forEach(component => { component.slots.forEach(slot => { if (slot.id == slotId) { setCurrentSlot(slot) } }) }) }, [tabComponents]) // 依赖tabComponents,数组更新时函数重新创建
同时用useMemo包裹Context的value,保证引用稳定:
const contextValue = useMemo(() => ({ tabComponents, setTabComponents: setTabComponentsHelper, currentTab, setCurrentTab: setCurrentTabHelper, currentSlot, setCurrentSlot: setCurrentSlotHelper }), [tabComponents, currentTab, currentSlot, setTabComponentsHelper, setCurrentTabHelper, setCurrentSlotHelper]) return ( <TabContext.Provider value={contextValue}> {children} </TabContext.Provider> );
方案2:直接传递插槽对象,避免依赖tabComponents
修改setCurrentSlotHelper为直接接收插槽对象,不再通过id查找:
const setCurrentSlotHelper = useCallback((slot: TabSlot) => { setCurrentSlot(slot) }, [])
然后在调用时直接传入slot对象:
// 在Template的onTabChange中 sidebarComponents.push({ id: slotId, label: slot.label, isCurrent: newSlotId == slotId, onClick: () => setCurrentSlot(slot) // 直接传slot对象 })
方案3:调整Template中useEffect的依赖
在Template的useEffect中添加setCurrentSlot作为依赖,确保回调捕获最新的函数:
useEffect(() => { // ... 原有代码 }, [setCurrentSlot])
注意:此方案需配合方案1使用,否则setCurrentSlot每次渲染都会生成新函数,导致useEffect重复执行。
内容的提问来源于stack exchange,提问作者Nikola-Milovic
相关产品推荐
相关产品推荐

