You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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的问题,只是场景容易被忽略:

  1. 在Template组件的空依赖useEffect中,创建的onTabChange函数里,setCurrentSlot是从useTab获取的初始版本——此时TabProvider中的tabComponents还是空数组。
  2. 随后调用onTabChange(newTabs[0])设置初始插槽,再调用setTabComponents(newTabs)更新tabComponents,但之前创建的侧边栏onClick回调捕获的是旧版setCurrentSlotHelper,这个旧版函数引用的tabComponents是空数组,所以首次点击时找不到对应插槽。
  3. 切换标签页时,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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 21:44:53