父组件状态未主动修改,子组件props为何发生异常变更?
问题描述
父组件通过isModalVisible这个state控制子组件模态框的显示状态,点击按钮打开模态框功能正常,但通过useEffect监听Redux状态forbiddenAuthFlag来修改isModalVisible后,子组件的isOpen props会在多次重渲染后变为false。明明props直接依赖父组件state,却出现这种异常变更,求原因及解决办法。
父组件(Login.tsx)
export const Login = (props: LoginProps) => { const { test, className } = props; const isBarsWrap = useSelector(getBarsIsWrap); const forbiddenAuthFlag = useSelector(getForbiddenAuthFlag); const [isModalVisible, setModalVisible] = useState(false); console.log('isModalVisible ', isModalVisible); console.log('Login render'); useEffect(() => { if (forbiddenAuthFlag) { setModalVisible(true); } }, [forbiddenAuthFlag]); const dispatch = useAppDispatch(); const loginOpenHandler = useCallback(() => { setModalVisible(true); }, []); const loginCloseHandler = useCallback(() => { setModalVisible(false); }, []); const loginFn = useCallback(() => { dispatch(authByLogin(loginCloseHandler)); }, [dispatch, loginCloseHandler]); const userAuthData = useSelector(getUserAuthData); return ( <> {userAuthData ? ( <NavLink data-testid="loginLinkId" to={getRouteProfile(userAuthData.id)} > <Button justify="start" mb="mb30" prev={ <Avatar src={userAuthData.avatar} test={{ testSuccess: test?.testSuccess, testTd: 'loginAsProfileLinkAvatarId', }} /> } square={isBarsWrap} test={{ testTd: 'loginAsProfileLinkId' }} variant="clear" > {!isBarsWrap && 'Профиль'} </Button> </NavLink> ) : ( <> <Button justify="start" mb="mb30" onClick={loginOpenHandler} prev={ <Svg Image={KeyIcon} size={40} test={{ testTd: 'loginAsLoginButtonSvgId' }} /> } square={isBarsWrap} test={{ testTd: 'loginAsLoginButtonId' }} variant="clear" > {!isBarsWrap && 'Войти'} </Button> </> )} <Portal> <Modal closeFn={loginCloseHandler} isOpen={isModalVisible} size="flex" test={{ testTd: 'loginModalId', testTd2: 'loginOverlayId' }} > <LoginForm className={className} loginFn={loginFn} /> </Modal> </Portal> </> ); };
子组件(Modal.tsx)
export const Modal = (props: ModalProps) => { const { test, className, children, size = 'md', isOpen, closeFn, closeButtonName, } = props; console.log('isOpen ', isOpen); console.log('Modal render'); const { theme } = useTheme(); const [unwrap, setUnwrap] = useState<boolean>(false); const refTimeout = useRef<ReturnType<typeof setTimeout>>(); const handleClose = useCallback(() => { setUnwrap(false); refTimeout.current = setTimeout(closeFn, DELAY_CLOSE_MODAL); }, [closeFn]); const handleCloseEsc = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { handleClose(); } }, [handleClose] ); const windowStopPropagationFn = useCallback((e: MouseEvent) => { e.stopPropagation(); }, []); useEffect(() => { window.addEventListener('keydown', handleCloseEsc); if (isOpen) { setUnwrap(true); } return () => { clearTimeout(refTimeout.current); window.removeEventListener('keydown', handleCloseEsc); }; }, [handleClose, handleCloseEsc, isOpen]); useEffect(() => { if (closeButtonName) { const closeButtons = document.getElementsByName( closeButtonName ) as NodeListOf<HTMLButtonElement>; closeButtons.forEach((elem: HTMLButtonElement) => { elem.onclick = () => { handleClose(); }; }); } }, [closeButtonName, handleClose]); return ( <Overlay child={ <div className={classNames([cls.modal, theme, cls[size]], { [cls.unwrap]: unwrap, })} data-testid={test?.testTd ?? 'modalId'} onClick={windowStopPropagationFn} > {children} </div> } className={className} isOpen={isOpen} onClick={handleClose} test={{ testTd: test?.testTd2 ?? 'modalOverlayId' }} /> ); };
Portal组件
import { ReactNode } from 'react'; import { createPortal } from 'react-dom'; interface PortalProps { children: ReactNode; intoElement?: Element | null; } export const Portal = ({ children, intoElement }: PortalProps) => { return createPortal(children, intoElement ?? document.body); };
控制台日志
Login.tsx:29 Login render Modal.tsx:36 isOpen false Modal.tsx:37 Modal render Login.tsx:28 isModalVisible true Login.tsx:29 Login render Modal.tsx:36 isOpen true Modal.tsx:37 Modal render Modal.tsx:36 isOpen true Modal.tsx:37 Modal render Modal.tsx:36 isOpen false Modal.tsx:37 Modal render Modal.tsx:36 isOpen false Modal.tsx:37 Modal render Modal.tsx:36 isOpen false Modal.tsx:37 Modal render
原因分析
Redux状态变更时序问题:父组件同时订阅了
forbiddenAuthFlag、userAuthData、isBarsWrap三个Redux状态。当forbiddenAuthFlag变为true触发setModalVisible(true)后,可能userAuthData或isBarsWrap紧接着发生变更,导致父组件多次重渲染。如果forbiddenAuthFlag在触发打开模态框后又被快速重置为false,父组件的useEffect会再次执行,但当前逻辑只在forbiddenAuthFlag为true时设置模态框打开,不会处理false的情况——但如果此时有其他逻辑(比如登录成功后调用loginCloseHandler),会直接把isModalVisible设为false,导致子组件props变更。多实例残留问题:由于Modal通过Portal挂载到
document.body,如果父组件重渲染时没有正确管理Portal实例,可能会残留旧的Modal节点。旧实例不会跟随父组件当前的state更新,会一直沿用初始的isOpen=false,导致控制台出现多次false的日志。Modal内部副作用触发重渲染:Modal组件中
setUnwrap(true)会触发自身重渲染,但这不会改变isOpen的props值,不过如果和其他状态变更叠加,可能会让日志看起来混乱。
解决方案
- 同步Redux状态与模态框状态:修改父组件的useEffect,直接将
forbiddenAuthFlag的值同步给isModalVisible,避免状态不一致:
useEffect(() => { setModalVisible(forbiddenAuthFlag); }, [forbiddenAuthFlag]);
- 避免多Modal实例:给Portal中的Modal添加固定key,确保每次父组件重渲染时复用同一个Modal实例:
<Portal> <Modal key="login-modal" closeFn={loginCloseHandler} isOpen={isModalVisible} size="flex" test={{ testTd: 'loginModalId', testTd2: 'loginOverlayId' }} > <LoginForm className={className} loginFn={loginFn} /> </Modal> </Portal>
检查登录逻辑的副作用:查看
authByLogin方法是否在登录成功后调用了loginCloseHandler,同时确认forbiddenAuthFlag在登录后的状态变化,避免双重触发关闭模态框。添加模态框卸载时的清理:确保Modal组件在
isOpen变为false时,所有副作用(比如定时器、事件监听)都被正确清理,避免残留逻辑影响后续渲染。
内容的提问来源于stack exchange,提问作者Алексей Смирнов

