React 19内联Ref清理函数在延迟渲染过渡中触发TypeError问题
React 19回调Ref清理函数在useDeferredValue+条件渲染场景崩溃原因解析
近期将应用升级至React 19,使用新的回调Ref返回清理函数特性重构了原有的useEffect DOM清理逻辑,大部分场景运行正常,但在结合useDeferredValue和条件渲染的仪表盘组件中出现崩溃问题。
问题代码
import { useState, useDeferredValue } from 'react'; import { ExternalChartEngine } from 'awesome-charts'; export default function Dashboard({ searchTerms }) { const [showMetrics, setShowMetrics] = useState(true); // 延迟搜索词更新以保持UI响应 const deferredSearch = useDeferredValue(searchTerms); if (!showMetrics) return <div>Metrics Hidden</div>; return ( <div className="dashboard-container"> <div ref={(element) => { if (!element) return; // 初始化第三方图表 const chartInstance = ExternalChartEngine.init(element, { query: deferredSearch }); // React 19清理函数 return () => { if (chartInstance) { chartInstance.destroy(); } }; }} /> </div> ); }
触发场景与错误日志
当searchTerms快速变化(触发延迟过渡)且用户同时切换setShowMetrics(false)时,应用崩溃,控制台报错:
Uncaught TypeError: Cannot read properties of null (reading 'destroy') at safelyCallDestroy (react-dom.development.js:22943) at commitDeletionEffectsOnFiber (react-dom.development.js:24210) at recursivelyTraverseDeletionEffects (react-dom.development.js:24120)
已尝试的有效方案
- 将回调Ref改为标准
useRef,结合useEffect处理初始化/清理逻辑,崩溃消失。 - 移除
useDeferredValue,崩溃消失。
崩溃原因解析
这是React 19并发渲染机制与回调Ref清理函数特性的交互导致的:
- 并发渲染与过渡状态:
useDeferredValue会触发React的并发过渡渲染,此时React会保留旧的UI快照以实现平滑过渡,新旧渲染任务会并行处理。 - 回调Ref的清理时机:React 19中,回调Ref的清理函数会在渲染更新(回调函数变化)或组件卸载时同步执行。当
searchTerms快速变化时,组件会频繁重新渲染,每次都会生成新的回调Ref函数,旧的清理函数会被立即调用以销毁旧图表实例。 - 竞争条件:当用户同时切换
showMetrics=false触发组件卸载时,React需要销毁旧过渡快照中的DOM节点及其清理函数。但由于并发渲染的异步特性,某个清理函数执行时,对应的chartInstance可能已经被之前的过渡更新销毁,或者因闭包捕获的是已失效的变量引用,导致chartInstance变为null——即使代码中有if(chartInstance)判断,也可能因并发执行的时序问题触发错误。
对比useRef+useEffect的方案:useEffect的清理逻辑是异步执行于DOM提交之后,且会等待过渡完成后再处理,避免了并发场景下的状态不一致问题。而内联回调Ref的清理是同步执行于渲染过程中,在快速更新+卸载的场景下更容易出现竞争条件。
修复方案
方案1:回归useRef+useEffect(推荐,已验证有效)
将图表实例的初始化和清理逻辑放在useEffect中,通过依赖项控制执行时机:
import { useState, useDeferredValue, useRef, useEffect } from 'react'; import { ExternalChartEngine } from 'awesome-charts'; export default function Dashboard({ searchTerms }) { const [showMetrics, setShowMetrics] = useState(true); const deferredSearch = useDeferredValue(searchTerms); const chartContainerRef = useRef(null); const chartInstanceRef = useRef(null); useEffect(() => { if (!showMetrics || !chartContainerRef.current) return; // 初始化图表 chartInstanceRef.current = ExternalChartEngine.init(chartContainerRef.current, { query: deferredSearch }); // 清理函数 return () => { if (chartInstanceRef.current) { chartInstanceRef.current.destroy(); chartInstanceRef.current = null; } }; }, [deferredSearch, showMetrics]); if (!showMetrics) return <div>Metrics Hidden</div>; return ( <div className="dashboard-container"> <div ref={chartContainerRef} /> </div> ); }
方案2:优化回调Ref避免闭包问题
如果坚持使用回调Ref,将图表实例存储在useRef中,避免闭包捕获失效的变量:
import { useState, useDeferredValue, useRef } from 'react'; import { ExternalChartEngine } from 'awesome-charts'; export default function Dashboard({ searchTerms }) { const [showMetrics, setShowMetrics] = useState(true); const deferredSearch = useDeferredValue(searchTerms); const chartInstanceRef = useRef(null); if (!showMetrics) return <div>Metrics Hidden</div>; return ( <div className="dashboard-container"> <div ref={(element) => { // 卸载或ref移除时清理 if (!element) { if (chartInstanceRef.current) { chartInstanceRef.current.destroy(); chartInstanceRef.current = null; } return; } // 已有实例先销毁 if (chartInstanceRef.current) { chartInstanceRef.current.destroy(); } // 初始化新实例 chartInstanceRef.current = ExternalChartEngine.init(element, { query: deferredSearch }); }} /> </div> ); }
内容的提问来源于stack exchange,提问作者ttboy
相关产品推荐
相关产品推荐

