Next.js中useEffect清理函数触发致模态框关闭问题排查
问题
使用Next.js 12.3.1实现模态对话框,需求是请求执行时立即打开模态框显示加载状态,请求完成后展示结果,但目前请求结果返回后模态框会意外关闭。
已尝试的解决方法:
- 创建单独的useEffect处理清理逻辑
- 使用空依赖数组的useEffect检测组件挂载
- 对所有内容进行memo化处理
- 尝试使用useLayoutEffect(Next.js无明显效果)
- 在useEffect清理函数中添加复杂逻辑阻止模态框关闭
怀疑是对React基础概念理解不足或遗漏了未知细节,以下是相关代码:
soon.tsx页面代码
// lots of import statements here type SubscriptionResponse = { success: boolean; message: TeaserSubscriptionResponseMessage; }; export const SoonPage: NextPage = () => { const [subscriptionResponse, setSubscriptioResponse] = useState<SubscriptionResponse>(); const [isModalVisible, setIsModalVisible] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const debounceTimer = useRef<NodeJS.Timeout>(); useEffect(() => { if (!inputRef.current) { return; } inputRef.current.focus(); }, []); const registerEmail: FormEventHandler<HTMLFormElement> = useCallback((event) => { event.preventDefault(); if (debounceTimer.current) { return; } setIsModalVisible(true); const target = event.target as typeof event.target & { email: { value: string }; }; const email = target.email.value; fetch("/api/subscribe-to-newsletter", { method: "POST", body: JSON.stringify({ email }), headers: { "Content-Type": "application/json", Accept: "application/json", }, }) .then((response) => response.json()) .then((response) => { setSubscriptioResponse(response); }); debounceTimer.current = setTimeout(() => { debounceTimer.current = undefined; }, 1000); }, []); const closeModal = () => { console.log("SoonPage", "closeModal"); setSubscriptioResponse(undefined); setIsModalVisible(false); inputRef.current?.focus(); }; return ( <> <div className={styles.container}> <TitleAndMetaTags prependTitle="we're coming soon!" description="Em breve traremos novidades!" /> <main className={styles.mainContent}> <article> <p>shhhhhhh! 🤫</p> <p> yo! a gente tá quase chegando... com tudo! 🔥 <br /> você não perde por esperar o que vem por aí. </p> <p> enquanto espera, deixe seu e-mail mais cool aqui embaixo pra gente mandar um yo quando chegar: </p> <form action="/soon" method="post" onSubmit={registerEmail}> <Input autoFocus className={styles.emailInput} id="teaser-email-input" name="email" placeholder="seu e-mail" ref={inputRef} required tabIndex={0} type="email" /> <br /> <Button autoFocus={false} id="teaser-subscribe-button" shouldDebounce={false} tabIndex={1} > Cadastrar </Button> </form> <p>see ya soon. peace out.</p> <figure className={styles.logo}> <Image src={YoLogoWithTrademark} width={165} alt="Yo! Inglês" /> </figure> </article> </main> </div> <SubscriptionResponseModal isModalVisible={isModalVisible} onClose={closeModal} subscriptionResponse={subscriptionResponse} /> </> ); }; export default SoonPage; type SubscriptionResponseModalProps = { isModalVisible: boolean; onClose: () => void; subscriptionResponse?: SubscriptionResponse; }; const SubscriptionResponseModal = memo(function SubscriptionResponseModal({ isModalVisible, onClose, subscriptionResponse, }: SubscriptionResponseModalProps) { console.log("SubscriptionResponseModal", { isModalVisible, subscriptionResponse }); return ( <Modal className={modalStyles.modal} isOpen={isModalVisible} onClose={onClose}> {subscriptionResponse ? 'Loading' : 'Finished'} <form method="dialog"> <Button autoFocus>ok</Button> </form> </Modal> ); });
Modal.tsx组件代码
import { memo, ReactNode, useEffect, useState } from "react"; import { createPortal } from "react-dom"; type ModalProps = { children: ReactNode | ReactNode[]; className: string; isOpen: boolean; onClose: () => void; }; function Modal({ children, className, isOpen, onClose }: ModalProps) { const [dialogElementRef, setDialogElementRef] = useState<HTMLDialogElement>(); useEffect(() => { const dialogElementRef = document.getElementById("modal") as HTMLDialogElement; setDialogElementRef(dialogElementRef); }, []); useEffect(() => { if (dialogElementRef === undefined) { return; } dialogElementRef.addEventListener("close", onClose); dialogElementRef.classList.add(className); console.log("Modal.tsx", { isOpen }); if (isOpen) { dialogElementRef.showModal(); } else { console.log("Modal.tsx", "closing modal"); dialogElementRef.close(); } return () => { console.log("Modal.tsx", "useEffect cleanup"); dialogElementRef.close(); dialogElementRef.removeEventListener("close", onClose); dialogElementRef.classList.remove(className); }; }, [className, dialogElementRef, isOpen, onClose]); if (dialogElementRef && isOpen) { return createPortal(children, dialogElementRef); } return null; } export default memo(Modal);
问题原因分析
- Modal组件的Portal渲染逻辑问题:当
subscriptionResponse更新时,SubscriptionResponseModal触发重渲染,而原Modal组件仅在dialogElementRef && isOpen时返回Portal,否则返回null。这会导致Portal被卸载后重新挂载,触发useEffect清理函数执行dialogElementRef.close(),直接关闭模态框。 - useEffect清理函数的不合理操作:原Modal组件的useEffect依赖包含
isOpen,每次isOpen或其他依赖变化时,旧的useEffect会执行清理函数,其中的dialogElementRef.close()会强制关闭模态框,即使此时isOpen仍为true。 - 状态变量拼写错误:
setSubscriptioResponse应为setSubscriptionResponse,拼写错误会导致状态无法正确更新,间接影响模态框的状态判断。 - 模态内容逻辑反转:
{subscriptionResponse ? 'Loading' : 'Finished'}逻辑完全颠倒,虽不直接导致关闭,但会误导状态显示。
修复方案
1. 调整Modal组件的Portal渲染逻辑
始终返回Portal(只要dialog元素存在),避免因状态更新导致Portal卸载,进而触发清理函数关闭模态框。
2. 拆分Modal组件的useEffect逻辑
将事件监听、类名操作与模态框显示/隐藏逻辑拆分,仅在组件挂载/卸载时处理事件和类名,避免依赖变化时重复执行关闭操作。
3. 修正状态变量拼写和内容逻辑
- 修复
setSubscriptioResponse的拼写错误 - 调整模态内容的显示逻辑,正确区分加载和完成状态
修改后的soon.tsx代码
// lots of import statements here type SubscriptionResponse = { success: boolean; message: TeaserSubscriptionResponseMessage; }; export const SoonPage: NextPage = () => { // 修正状态变量拼写错误 const [subscriptionResponse, setSubscriptionResponse] = useState<SubscriptionResponse>(); const [isModalVisible, setIsModalVisible] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const debounceTimer = useRef<NodeJS.Timeout>(); useEffect(() => { inputRef.current?.focus(); }, []); const registerEmail: FormEventHandler<HTMLFormElement> = useCallback((event) => { event.preventDefault(); if (debounceTimer.current) { return; } setIsModalVisible(true); // 请求开始时重置响应状态,确保显示加载状态 setSubscriptionResponse(undefined); const target = event.target as typeof event.target & { email: { value: string }; }; const email = target.email.value; fetch("/api/subscribe-to-newsletter", { method: "POST", body: JSON.stringify({ email }), headers: { "Content-Type": "application/json", Accept: "application/json", }, }) .then((response) => response.json()) .then((response) => { setSubscriptionResponse(response); }); debounceTimer.current = setTimeout(() => { debounceTimer.current = undefined; }, 1000); }, []); const closeModal = () => { console.log("SoonPage", "closeModal"); setSubscriptionResponse(undefined); setIsModalVisible(false); inputRef.current?.focus(); }; return ( <> <div className={styles.container}> <TitleAndMetaTags prependTitle="we're coming soon!" description="Em breve traremos novidades!" /> <main className={styles.mainContent}> <article> <p>shhhhhhh! 🤫</p> <p> yo! a gente tá quase chegando... com tudo! 🔥 <br /> você não perde por esperar o que vem por aí. </p> <p> enquanto espera, deixe seu e-mail mais cool aqui embaixo pra gente mandar um yo quando chegar: </p> <form action="/soon" method="post" onSubmit={registerEmail}> <Input autoFocus className={styles.emailInput} id="teaser-email-input" name="email" placeholder="seu e-mail" ref={inputRef} required tabIndex={0} type="email" /> <br /> <Button autoFocus={false} id="teaser-subscribe-button" shouldDebounce={false} tabIndex={1} > Cadastrar </Button> </form> <p>see ya soon. peace out.</p> <figure className={styles.logo}> <Image src={YoLogoWithTrademark} width={165} alt="Yo! Inglês" /> </figure> </article> </main> </div> <SubscriptionResponseModal isModalVisible={isModalVisible} onClose={closeModal} subscriptionResponse={subscriptionResponse} /> </> ); }; export default SoonPage; type SubscriptionResponseModalProps = { isModalVisible: boolean; onClose: () => void; subscriptionResponse?: SubscriptionResponse; }; const SubscriptionResponseModal = memo(function SubscriptionResponseModal({ isModalVisible, onClose, subscriptionResponse, }: SubscriptionResponseModalProps) { console.log("SubscriptionResponseModal", { isModalVisible, subscriptionResponse }); return ( <Modal className={modalStyles.modal} isOpen={isModalVisible} onClose={onClose}> {/* 修正逻辑:有响应时显示完成,否则显示加载 */} {subscriptionResponse ? 'Finished' : 'Loading'} <form method="dialog"> <Button autoFocus>ok</Button> </form> </Modal> ); });
修改后的Modal.tsx代码
import { memo, ReactNode, useEffect, useState } from "react"; import { createPortal } from "react-dom"; type ModalProps = { children: ReactNode | ReactNode[]; className: string; isOpen: boolean; onClose: () => void; }; function Modal({ children, className, isOpen, onClose }: ModalProps) { const [dialogElementRef, setDialogElementRef] = useState<HTMLDialogElement>(); useEffect(() => { const dialog = document.getElementById("modal") as HTMLDialogElement; setDialogElementRef(dialog); // 组件挂载时添加事件监听和类名 dialog.addEventListener("close", onClose); dialog.classList.add(className); return () => { // 组件卸载时移除事件监听和类名 dialog.removeEventListener("close", onClose); dialog.classList.remove(className); }; }, [className, onClose]); useEffect(() => { if (!dialogElementRef) return; // 仅根据isOpen状态控制模态框显示/隐藏 if (isOpen) { dialogElementRef.showModal(); } else { dialogElementRef.close(); } }, [dialogElementRef, isOpen]); // 始终返回Portal,避免卸载重建 return dialogElementRef ? createPortal(children, dialogElementRef) : null; } export default memo(Modal);
内容的提问来源于stack exchange,提问作者André Marcondes Teixeira
相关产品推荐
相关产品推荐

