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

父组件状态未主动修改,子组件props为何发生异常变更?

父组件useEffect修改state后,子组件props多次重渲染后变为false的问题分析

问题描述

父组件通过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

原因分析

  1. Redux状态变更时序问题:父组件同时订阅了forbiddenAuthFlag、userAuthData、isBarsWrap三个Redux状态。当forbiddenAuthFlag变为true触发setModalVisible(true)后,可能userAuthData或isBarsWrap紧接着发生变更,导致父组件多次重渲染。如果forbiddenAuthFlag在触发打开模态框后又被快速重置为false,父组件的useEffect会再次执行,但当前逻辑只在forbiddenAuthFlag为true时设置模态框打开,不会处理false的情况——但如果此时有其他逻辑(比如登录成功后调用loginCloseHandler),会直接把isModalVisible设为false,导致子组件props变更。

  2. 多实例残留问题:由于Modal通过Portal挂载到document.body,如果父组件重渲染时没有正确管理Portal实例,可能会残留旧的Modal节点。旧实例不会跟随父组件当前的state更新,会一直沿用初始的isOpen=false,导致控制台出现多次false的日志。

  3. Modal内部副作用触发重渲染:Modal组件中setUnwrap(true)会触发自身重渲染,但这不会改变isOpen的props值,不过如果和其他状态变更叠加,可能会让日志看起来混乱。

解决方案

  1. 同步Redux状态与模态框状态:修改父组件的useEffect,直接将forbiddenAuthFlag的值同步给isModalVisible,避免状态不一致:
useEffect(() => {
    setModalVisible(forbiddenAuthFlag);
}, [forbiddenAuthFlag]);
  1. 避免多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>
  1. 检查登录逻辑的副作用:查看authByLogin方法是否在登录成功后调用了loginCloseHandler,同时确认forbiddenAuthFlag在登录后的状态变化,避免双重触发关闭模态框。

  2. 添加模态框卸载时的清理:确保Modal组件在isOpen变为false时,所有副作用(比如定时器、事件监听)都被正确清理,避免残留逻辑影响后续渲染。


内容的提问来源于stack exchange,提问作者Алексей Смирнов

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 03:58:10