跪拜 Guibai
← Back to the summary

Promisifying Modal Dialogs Ends State Bloat and Fragmented Business Logic

Author: Kr1s Li|Haoqianyi Frontend Technology Team

In B-side systems, modals are a very common interaction pattern: create, edit, details, import, approval, secondary confirmation, permission prompts—almost every business flow encounters them. When there are few modals, a few visible states can handle it; but when a single page has 3, 5, or even more modals simultaneously, and they have serial dependencies between them, the problems become obvious.

The real trouble isn't "how to maintain more visibles," but that modal interactions are often scattered across state, callbacks, component lifecycles, and request logic. The business flow is inherently a continuous process, but the code gets chopped into several segments.

render-promise aims to solve this problem: making a modal interaction awaitable, just like an asynchronous request. The caller simply needs to await openXxxModal(), continuing forward on confirmation and naturally interrupting on cancellation.

1. Problems with the Traditional Modal Approach

Pre-instantiating multiple modals within a page component is the most common approach:

Typically, several things are done in tandem:

A typical example looks something like this:

import React, { useCallback, useState } from 'react';
import { Button } from 'antd';
import EditModal from './EditModal';
import DetailModal from './DetailModal';
import ConfirmModal from './ConfirmModal';

export default function Page() {
  const [editVisible, setEditVisible] = useState(false);
  const [detailVisible, setDetailVisible] = useState(false);
  const [confirmVisible, setConfirmVisible] = useState(false);
  const [currentId, setCurrentId] = useState<string>();

  const openEdit = useCallback((id: string) => {
    setCurrentId(id);
    setEditVisible(true);
  }, []);
  const closeEdit = useCallback(() => setEditVisible(false), []);

  const openDetail = useCallback((id: string) => {
    setCurrentId(id);
    setDetailVisible(true);
  }, []);
  const closeDetail = useCallback(() => setDetailVisible(false), []);

  const openConfirm = useCallback(() => setConfirmVisible(true), []);
  const closeConfirm = useCallback(() => setConfirmVisible(false), []);

  const onEditOk = async () => {
    closeEdit();
    // Might also need to refresh list / pop up a secondary confirmation / open details...
    openConfirm();
  };

  return (
    <>
      <Button onClick={() => openEdit('1001')}>Edit</Button>
      <Button onClick={() => openDetail('1001')}>Details</Button>

      <EditModal open={editVisible} id={currentId} onOk={onEditOk} onClose={closeEdit} />
      <DetailModal open={detailVisible} id={currentId} onClose={closeDetail} />
      <ConfirmModal open={confirmVisible} onOk={() => { closeConfirm(); }} onClose={closeConfirm} />
    </>
  );
}

When the number of modals is small and the flow is simple, this approach works fine. But once the number of modals increases, or they start chaining together, the maintenance cost rises quickly.

State Bloat

The page will have a row of visibleXxx and a row of setVisibleXxx. Even if you later try to abstract them into generic open/close functions, it's easy to introduce more branching parameters, further complicating the management logic.

Fragmented Flow

A typical scenario: open Modal A, after user confirmation trigger validation or a request; after validation passes, open Modal B; after Modal B confirmation, refresh the list or navigate to another page.

This is inherently a complete business flow, but in code it often gets scattered across the page component, modal components, request functions, and even multiple files. The code runs, but the cost of reading and modifying it grows higher and higher.

Lifecycle Misalignment with Business Intent

When visible=false, the component is usually just hidden, not unmounted. This brings two problems:

For example, in the following case, the modal is not open, but the component has been instantiated, and useEffect will still execute:

import React, { useEffect, useState } from 'react';
import { Modal, Select } from 'antd';
import { queryEnums } from './api';

export default function EditModal(props: { open: boolean; onClose: () => void }) {
  const [options, setOptions] = useState<{ label: string; value: string }[]>([]);

  // In the old pattern: as long as the component is instantiated in the page root node, this effect will execute.
  // Even if open/visible is false at this point, the API call will be made prematurely.
  useEffect(() => {
    queryEnums().then(setOptions);
  }, []);

  return (
    <Modal open={props.open} onCancel={props.onClose} onOk={props.onClose}>
      <Select options={options} />
    </Modal>
  );
}

So, the real problem to solve here isn't "how to more elegantly maintain N visibles," but how to make the expression of modal interactions closer to the business flow: readable from top to bottom, with continuous logic and clear boundaries.

2. Treating a Modal as an Awaitable Decision

From a product interaction perspective, a modal appearing usually means the user needs to make a decision. It temporarily isolates the main page interaction through an overlay, focusing the user's attention on the current task.

After a modal opens, the outcome generally falls into two categories:

This is very close to the semantics of a Promise: confirmation corresponds to resolve, cancellation or closing corresponds to reject.

If "opening a modal" can be abstracted into a function that returns a Promise, the business layer can use await to organize modal chains: wait for A's result, then decide whether to proceed to B; when the user cancels, the flow naturally interrupts.

// Business layer: await the modal result just like awaiting an async function
const flag = await aModalPromise();
if (flag) {
  // refreshPageList()
  // or
  // navToXxxxPage()
}

This idea seems simple, but to truly implement it, a key question must be answered:

If modal components are no longer pre-instantiated in the page JSX, where exactly do they render? How are they created, how are they unmounted, and how do you ensure no residual DOM or memory leaks?

3. Core Mechanism: container / render / destroy

The key mechanism for functionally opening modals can be summarized in three words: container, render, destroy.

This mechanism converges "when a modal is instantiated, rendered, and destroyed" out of the page component. It's worth noting that Ant Design's Modal itself also inserts DOM into body via a portal, but in the traditional approach, whether a component instance exists, how its state is reset, and when effects are triggered are still managed by the page-side visible.

Imperative rendering further consolidates these details into a unified mechanism: one open equals one instantiation, one close equals one destruction. The caller doesn't need to care where the container is or manage unmounting details; it just needs to wait for a result.

4. Looking at This Mechanism from Ant Design's Imperative APIs

In daily use of Ant Design, we've already encountered many imperative APIs, such as message, notification, and confirm. The caller doesn't need to pre-place a <Message /> or <ConfirmModal /> in the page JSX; they can appear on the interface on demand.

AntMessage.success('Added successfully');
AntMessage.success('Login failed');

AntModal.confirm({
  // ...
});

The pattern behind these capabilities typically also involves "creating a container, rendering into the container, destroying and cleaning up the container."

// Not the source code of a specific antd version, just an illustration of the same mechanism as antd
function openLikeAntd(
  renderIntoContainer: (el: any, container: HTMLElement) => void,
  element: any,
) {
  const container = document.createElement('div');
  document.body.appendChild(container);

  function destroy() {
    // ReactDOM.unmountComponentAtNode(container) or root.unmount()
    document.body.removeChild(container);
  }

  renderIntoContainer(element, container);
  return { destroy };
}

Now look at Ant Design v5.0.0's components/modal/confirm.tsx. The parts most relevant to the mechanism are kept below:

Source link: ant-design/[email protected]/components/modal/confirm.tsx

// Ant Design v5.0.0 - components/modal/confirm.tsx (excerpt, keeping key logic)
export default function confirm(config: ModalFuncProps) {
  const container = document.createDocumentFragment();
  let currentConfig = { ...config, close, open: true } as any;
  let timeoutId: NodeJS.Timeout;

  function destroy(...args: any[]) {
    const triggerCancel = args.some(param => param && param.triggerCancel);
    if (config.onCancel && triggerCancel) {
      config.onCancel(() => {}, ...args.slice(1));
    }
    for (let i = 0; i < destroyFns.length; i++) {
      const fn = destroyFns[i];
      if (fn === close) {
        destroyFns.splice(i, 1);
        break;
      }
    }
    reactUnmount(container);
  }

  function render({ okText, cancelText, prefixCls: customizePrefixCls, ...props }: any) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      const runtimeLocale = getConfirmLocale();
      const { getPrefixCls, getIconPrefixCls } = globalConfig();
      const rootPrefixCls = getPrefixCls(undefined, getRootPrefixCls());
      const prefixCls = customizePrefixCls || `${rootPrefixCls}-modal`;
      const iconPrefixCls = getIconPrefixCls();
      reactRender(
        <ConfirmDialog
          {...props}
          prefixCls={prefixCls}
          rootPrefixCls={rootPrefixCls}
          iconPrefixCls={iconPrefixCls}
          okText={okText}
          locale={runtimeLocale}
          cancelText={cancelText || runtimeLocale.cancelText}
        />,
        container,
      );
    });
  }

  function close(...args: any[]) {
    currentConfig = {
      ...currentConfig,
      open: false,
      afterClose: () => {
        if (typeof config.afterClose === 'function') {
          config.afterClose();
        }
        destroy.apply(this, args);
      },
    };
    if (currentConfig.visible) delete currentConfig.visible;
    render(currentConfig);
  }

  function update(configUpdate: ConfigUpdate) {
    currentConfig = typeof configUpdate === 'function'
      ? configUpdate(currentConfig)
      : { ...currentConfig, ...configUpdate };
    render(currentConfig);
  }

  render(currentConfig);
  destroyFns.push(close);
  return { destroy: close, update };
}

There are three key points in this source code:

  1. container = document.createDocumentFragment(): Creates the hosting node.
  2. reactRender(..., container): Imperative rendering.
  3. destroyFns.push(close) and reactUnmount(container): Centralized management of closing and unmounting.

The calling side doesn't care about the container; it only gets a destroyable handle. The rendering and destruction logic is centrally managed—this is the key to why imperative UI can run stably.

Returning to render-promise, what it does is push this mechanism one step further: binding the "modal end" to the Promise's resolve/reject, thereby smoothing out the business flow.

5. Source Code Design of render-promise

render-promise can be split into two layers by responsibility:

5.1 Render: Managing the Container Lifecycle

The most important point in the container lifecycle is: regardless of whether unmounting succeeds, the container must be removed. This avoids residual DOM and reduces the risk of memory leaks under long-running conditions.

Below is an excerpt of the key logic for Render in render-promise:

// src/render-special.ts (excerpt)
class Render {
  create() {
    this.div = document.createElement('div');
    this.div.setAttribute('tool-element', this.name);
    document.body.appendChild(this.div);
  }

  render(element: JSX.Element) {
    const container = this.ensureContainer();
    // React 18 prioritizes createRoot, otherwise falls back to ReactDom.render
    ReactDom.render(element, container);
  }

  unmountComponentAtNode() {
    const container = this.div;
    if (!container) return;
    try {
      ReactDom.unmountComponentAtNode(container);
    } finally {
      this.remove(); // finally cleans up the DOM container
    }
  }
}

The design focus here isn't the amount of code, but the clear lifecycle boundaries: creating the container, rendering the component, unmounting the component, removing the container—each step has a clear owner.

5.2 renderPromise: Wrapping a Component into a Promise

renderPromise receives an internal component InnerComponent and returns a function. When the business calls this function, it gets a Promise.

It mainly does three things:

  1. Creates a host, which is a Render instance, used to complete the imperative rendering.
  2. Injects onOk and onClose: onOk triggers resolve, onClose triggers reject.
  3. Provides a cleanup() fallback: ensures the unmount logic executes only once, and resources can be reclaimed even if rendering fails.

Among these, cleanup + idempotent switch is a significant source of stability. Idempotence here means that executing the same operation multiple times produces the same result as executing it once. In this context: no matter if cleanup() is called 1 time or 3 times, only one unmount and removal will ultimately occur, without exceptions from repeated cleanup.

// src/render-promise.tsx (near-complete implementation for understanding the overall structure)
import React from 'react';
import Render from './render-special';
import type { Options, ResolveValue } from './type';
import { normalizeResolvePayload } from './utils';

function renderPromise<InnerProps extends Record<string, any>>(
  InnerComponent: React.ComponentType<InnerProps>,
  name: string,
  options?: Options,
) {
  type RecordProps = Omit<InnerProps, 'onOk' | 'onClose'>;

  return function (props?: RecordProps) {
    let host: Render | null = new Render(name, options);
    let cleaned = false;

    type PromiseResolveType = ResolveValue<InnerProps['onOk']>;

    const cleanup = () => {
      if (cleaned) return; // Idempotent switch: only clean up once
      cleaned = true;
      host?.unmountComponentAtNode();
      host = null;
    };

    return new Promise<PromiseResolveType>((resolve, reject) => {
      const _props = (props ?? {}) as InnerProps;

      const handleOk = (...args: any[]) => {
        cleanup();
        resolve(normalizeResolvePayload(args) as PromiseResolveType);
      };

      const handleClose = (reason: unknown) => {
        cleanup();
        reject(reason);
      };

      const element = (
        <InnerComponent {..._props} onOk={handleOk} onClose={handleClose} />
      );

      if (!host) {
        reject(new Error('Failed to create render instance'));
        return;
      }

      try {
        host.render(element);
      } catch (error) {
        cleanup();
        reject(error);
      }
    });
  };
}

export default renderPromise;

At this point, renderPromise has combined the "container mechanism" and "Promise semantics." The component is responsible for triggering onOk or onClose at the appropriate time, and the caller only cares about the result of await.

5.3 Type and Payload Normalization

To make the calling experience more natural, renderPromise also handles the return value type.

It infers the parameters of onOk as the return type of the Promise's resolve. This way, after await openXxx() on the business side, you get clearer type hints.

Also, onOk might be called with no parameters, one parameter, or multiple parameters, so the resolve parameters are normalized internally:

The relevant type inference and normalization logic is as follows:

// src/type.d.ts (excerpt)
export type ResolveValue<T> = NonNullable<T> extends (...args: infer Args) => any
  ? Args extends []
    ? void
    : Args extends [infer OnlyArg]
    ? OnlyArg
    : Args
  : void;
// src/utils.ts (excerpt)
export const normalizeResolvePayload = (args: unknown[]) => {
  if (args.length === 0) return undefined;
  if (args.length === 1) return args[0];
  return args;
};

These details may seem small, but they directly affect how smoothly the business side can write code. Stable types and return value shapes mean the caller doesn't need an extra compatibility layer on every modal result.

6. Four Types of Benefits It Brings to Business

6.1 Natural State Cleanup: Close Means Unmount

The traditional visible=false pattern usually just hides the component, not necessarily unmounting it. Internal modal state might persist, requiring a manual reset the next time it opens.

In the old approach, cleanup logic like this was often necessary:

const closeEdit = () => {
  setEditVisible(false);
  setFormValue(DEFAULT_VALUE); // manual cleanup
  setOptions([]);              // manual cleanup
};

With the imperative create-and-destroy approach, the component naturally unmounts on close, and internal state is released along with the component unmount:

await openEditModal({ id }); // After closing, the component is unmounted, state won't persist to the next open

This doesn't mean all cleanup logic disappears, but it at least reduces the maintenance burden of "writing a reset for every modal."

6.2 More Continuous Flow: Business Logic Expressed Top-to-Bottom

After promisifying modals, the business layer can use await to organize interactions: first wait for A's result, then decide the next step. The flow isn't fragmented by callbacks, and reads more like a complete business process.

In the old approach, callbacks and visible were often intertwined:

// =========================
// Page.tsx
// =========================
import React, { useCallback, useState } from 'react';
import { Button } from 'antd';
import AModal from './AModal';
import BModal from './BModal';

export default function Page() {
  const [aVisible, setAVisible] = useState(false);
  const [bVisible, setBVisible] = useState(false);
  const [id, setId] = useState<string>();
  const [aResult, setAResult] = useState<{ token: string } | null>(null);

  const openAModal = useCallback((nextId: string) => {
    setId(nextId);
    setAVisible(true);
  }, []);
  const closeAModal = useCallback(() => setAVisible(false), []);

  const openBModal = useCallback(() => setBVisible(true), []);
  const closeBModal = useCallback(() => setBVisible(false), []);

  const refreshList = useCallback(async () => {
    // ...refresh list
  }, []);

  return (
    <>
      <Button onClick={() => openAModal('1001')}>Start Flow</Button>

      <AModal
        open={aVisible}
        id={id}
        onClose={closeAModal}
        onOk={async (res) => {
          // A modal confirm: first close A, then decide to open B
          closeAModal();
          setAResult(res);
          openBModal();
        }}
      />

      <BModal
        open={bVisible}
        token={aResult?.token}
        onClose={closeBModal}
        onOk={async () => {
          // B modal confirm: first close B, then refresh page data
          closeBModal();
          await refreshList();
        }}
      />
    </>
  );
}

// =========================
// AModal.tsx
// =========================
import React, { useCallback, useState } from 'react';
import { Modal } from 'antd';
import { preCheck } from './api';

export default function AModal(props: {
  open: boolean;
  id?: string;
  onClose: () => void;
  onOk: (res: { token: string }) => void;
}) {
  const [loading, setLoading] = useState(false);

  const handleOk = useCallback(async () => {
    setLoading(true);
    try {
      // Inside A modal: do pre-check / request
      const token = await preCheck(props.id);
      props.onOk({ token }); // throw the result back to Page
    } finally {
      setLoading(false);
    }
  }, [props]);

  return (
    <Modal open={props.open} confirmLoading={loading} onOk={handleOk} onCancel={props.onClose}>
      A Modal Content
    </Modal>
  );
}

// =========================
// BModal.tsx
// =========================
import React, { useCallback } from 'react';
import { Modal } from 'antd';

export default function BModal(props: {
  open: boolean;
  token?: string | null;
  onClose: () => void;
  onOk: () => void;
}) {
  const handleOk = useCallback(() => {
    // Inside B modal: might also need to handle submission/confirmation
    props.onOk(); // ultimately goes back to Page to refresh/navigate
  }, [props]);

  return (
    <Modal open={props.open} onOk={handleOk} onCancel={props.onClose}>
      B Modal Content (token={String(props.token)})
    </Modal>
  );
}

After switching to the Promise-based approach, the flow becomes much more converged:

try {
  const aResult = await openAModal({ id });
  const bResult = await openBModal({ aResult });
  await refreshList(bResult);
} catch (e) {
  // User cancelled/closed, the flow naturally interrupts
}

In more complex flows, a top-to-bottom reading order can also be maintained:

async function onClick() {
  await preCheck();
  await openConfirmModal({ text: 'Are you sure you want to execute?' });
  const result = await openProgressModal({ taskId: await startTask() });
  await openResultModal({ result });
}

For team collaboration, this is often more important than "writing a few fewer lines of code." Clear entry points and continuous flow allow maintainers to locate problems faster and more easily assess the impact of changes.

6.3 More Reasonable Initialization: On-Demand Modal Module Import

When modals exist in a functionally opened form, it's easier to do on-demand imports: load the relevant module when the business triggers it, rather than importing all modal components and dependencies at page initialization.

The old approach typically imports at page initialization:

// Old: imported at page initialization (modal dependencies also enter the bundle)
import { openBigModal } from './big-modal';

The new approach can load only when truly needed:

// New: on-demand import (load the modal module when the business triggers it)
const openBigModal = async (props: any) => {
  const mod = await import('./big-modal');
  return mod.openBigModal(props);
};

Whether this brings significant performance benefits depends on the page size and modal complexity. But from an engineering structure perspective, it provides a more reasonable way to split code and reduces modal logic that isn't needed on the first screen load.

6.4 Not Just for Modals

The core of renderPromise is "imperatively rendering a React node and destroying it when finished," so it's not limited to just Modals.

If a fixed-position notification block, a temporary operation panel, or a lightweight interaction layer also needs "awaitable/closable" capabilities, the same mechanism can be used.

import React from 'react';
import renderPromise from 'render-promise';

type FixedTipProps = {
  onOk: () => void;
  onClose: () => void;
  text: string;
};

function FixedTip(props: FixedTipProps) {
  return (
    <div
      style={{
        position: 'fixed',
        top: 20,
        left: '50%',
        transform: 'translateX(-50%)',
        padding: 12,
        borderRadius: 8,
        background: '#111',
        color: '#fff',
        zIndex: 9999,
      }}
      onClick={() => props.onOk()}
    >
      {props.text} (Click me to close)
    </div>
  );
}

export const openFixedTip = renderPromise(FixedTip, 'fixed-tip');

Business-side call:

await openFixedTip({ text: 'Saved successfully' });

As long as the interaction fits the "open, wait for result, destroy after completion" chain, it can use a similar abstraction.

7. A Minimal End-to-End Example

Below is a README-style example showing the complete approach.

my-modal.tsx:

import React, { useEffect, useState } from 'react';
import { Modal } from 'antd';
import renderPromise from 'render-promise';

type MyModalProps = {
  onOk: (payload: { id: string }) => void;
  onClose: (reason?: unknown) => void;
  id: string;
};

function MyModal(props: MyModalProps) {
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Only triggers when the modal is actually opened (because the component is only rendered then)
    // ...fetch detail by props.id
  }, [props.id]);

  return (
    <Modal
      open
      confirmLoading={loading}
      title="Example Modal"
      onOk={() => props.onOk({ id: props.id })}
      onCancel={() => props.onClose('cancel')}
    >
      Content goes here
    </Modal>
  );
}

export const openMyModal = renderPromise(MyModal, 'my-modal');
export default MyModal;

page.tsx:

import React from 'react';
import { Button } from 'antd';
import { openMyModal } from './my-modal';

export default function Page() {
  return (
    <Button
      onClick={async () => {
        try {
          const res = await openMyModal({ id: '1001' });
          // res has type hints (inferred from onOk's parameters)
          console.log(res.id);
          // ...refresh
        } catch (e) {
          // User cancelled/closed
        }
      }}
    >
      Open Modal
    </Button>
  );
}

In this example, the page no longer maintains visible, nor does it need to pre-mount the modal. The modal is only created when openMyModal is called and destroyed when finished. The caller gets the result via await, and cancellation enters catch.

8. Three Conventions to Unify When Adopting

If a team wants to use similar capabilities long-term in business, it's best to establish consistent patterns first.

First, modal components uniformly receive onOk and onClose. The modal internally decides when to trigger resolve/reject, and the caller no longer maintains visible.

Second, uniformly export an open function, e.g., openXxx = renderPromise(XxxModal, 'xxx'). The creation, rendering, and destruction mechanisms are all encapsulated within the open function.

Third, the business layer only cares about the result of await openXxx() and organizes subsequent flows based on it. Cancellation or closing uniformly enters catch; if the business needs to differentiate reasons, then agree on the shape of reason.

The handling of reason can be simply divided into two categories.

Uniformly treat reject as cancellation or closing, without differentiation:

try {
  await openMyModal({ id });
} catch {
  // ignore
}

Or branch based on reason:

try {
  await openMyModal({ id });
} catch (reason) {
  if (reason === 'cancel') return;
  // Other exceptions: report or prompt
  console.error(reason);
}

Which approach to choose doesn't depend on the tool, but on the team's agreement on interaction boundaries. The important thing is to stay consistent and not mix multiple semantics across different pages.

9. Summary

render-promise doesn't solve "whether a modal can be opened," but rather "how modal interactions can be expressed in a way closer to the business flow."

The mechanism behind it isn't complex: container / render / destroy, plus the Promise resolve/reject semantics, along with engineering fallbacks like cleanup and type normalization.

When a page has only one or two simple modals, the traditional visible approach is still sufficient. But when modals start chaining, state starts bloating, and callback chains start scattering, abstracting a modal into an awaitable interaction makes business code more continuous and easier to maintain.

More importantly, this approach reminds us to first see the essence of the interaction: a modal is a process of waiting for a user decision. When the abstraction is right, the implementation actually becomes smaller.