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

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>
  );
});
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);

问题原因分析

  1. Modal组件的Portal渲染逻辑问题:当subscriptionResponse更新时,SubscriptionResponseModal触发重渲染,而原Modal组件仅在dialogElementRef && isOpen时返回Portal,否则返回null。这会导致Portal被卸载后重新挂载,触发useEffect清理函数执行dialogElementRef.close(),直接关闭模态框。
  2. useEffect清理函数的不合理操作:原Modal组件的useEffect依赖包含isOpen,每次isOpen或其他依赖变化时,旧的useEffect会执行清理函数,其中的dialogElementRef.close()会强制关闭模态框,即使此时isOpen仍为true。
  3. 状态变量拼写错误:setSubscriptioResponse应为setSubscriptionResponse,拼写错误会导致状态无法正确更新,间接影响模态框的状态判断。
  4. 模态内容逻辑反转:{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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.15 16:21:11