Skip to content

dohyeon-kr/react-flow-modal

Repository files navigation

react-flow-modal

Promise-based modal flows for React.

react-flow-modal lets you treat modals as async flows using Promise and async/await, without coupling your UI to state-driven logic.


Installation

pnpm add react-flow-modal
# or
npm install react-flow-modal
# or
yarn add react-flow-modal

Basic Usage

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ModalProvider, useModal, ModalHost } from "react-flow-modal";

function ConfirmModal({
  onConfirm,
  onCancel,
}: {
  onConfirm: () => void;
  onCancel: () => void;
}) {
  return (
    <div
      style={{
        position: "fixed",
        inset: 0,
        background: "rgba(0,0,0,0.5)",
        display: "grid",
        placeItems: "center",
        zIndex: 1000,
      }}
    >
      <div
        style={{
          background: "white",
          padding: 24,
          borderRadius: 8,
          minWidth: 300,
        }}
      >
        <h3>Are you sure?</h3>
        <p>This action cannot be undone.</p>

        <div style={{ display: "flex", gap: 8, marginTop: 16 }}>
          <button onClick={onCancel}>Cancel</button>
          <button onClick={onConfirm}>Confirm</button>
        </div>
      </div>
    </div>
  );
}

function App() {
  const modal = useModal();

  const onClick = async () => {
    // render modal and await resolve
    const result = await modal.open("confirm", (resolve) => (
      <ConfirmModal
        onConfirm={() => resolve(true)}
        onCancel={() => resolve(false)}
      />
    ));

    // flow resumed
    console.log("Result:", result);
  };

  return <button onClick={onClick}>Open Confirm Modal</button>;
}

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ModalProvider>
      <App />
      <ModalHost />
    </ModalProvider>
  </StrictMode>,
)

With AnimatePresence (Framer Motion)

To support exit animations, modals must be rendered inside the same React tree as AnimatePresence.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ModalHost, ModalProvider } from 'react-flow-modal';
import { AnimatePresence, motion } from 'motion/react';

function ConfirmModal({
  onConfirm,
  onCancel,
}: {
  onConfirm: () => void;
  onCancel: () => void;
}) {
  return (
    <motion.div
      key="confirm-modal-container"
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      style={{
        position: "fixed",
        inset: 0,
        background: "rgba(0,0,0,0.5)",
        display: "grid",
        placeItems: "center",
        zIndex: 1000,
      }}
    >
      <motion.div
        initial={{ scale: 0.9, opacity: 0 }}
        animate={{ scale: 1, opacity: 1 }}
        exit={{ scale: 0.95, opacity: 0 }}
        transition={{ duration: 0.2 }}
        style={{
          background: "white",
          padding: 24,
          borderRadius: 8,
          minWidth: 300,
          color: "black",
        }}
      >
        <h3>Are you sure?</h3>
        <p>This action cannot be undone.</p>

        <div style={{ display: "flex", gap: 8, marginTop: 16 }}>
          <button onClick={onCancel}>Cancel</button>
          <button onClick={onConfirm}>Confirm</button>
        </div>
      </motion.div>
    </motion.div>
  );
}

...

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ModalProvider>
      <App />
      <ModalHost>
        {(modals) => (
          <AnimatePresence>
            {modals}
          </AnimatePresence>
        )}
      </ModalHost>
    </ModalProvider>
  </StrictMode>,
)

API

useModal

const modal = useModal();

Returns an object that controls the modal flow.

{
  open<T>(
    key: string,
    render: (
      resolve: (value: T) => void,
      reject: (reason?: unknown) => void
    ) => React.ReactNode
  ): Promise<T>;
}

ModalHost

const ModalHost: FC<{
    children?: (modals: ReactElement[]) => React.ReactNode;
}>;

Renders the entire modal stack. This component should be rendered once in your React tree.


Important

⚠️ Always resolve or reject the promise. Leaving it pending will block the async flow.


Why react-flow-modal?

Most modal libraries are state-driven:

setOpen(true);

This makes modal control implicit and tightly coupled to rendering.

react-flow-modal treats modals as explicit async control points:

const result = await open(...);

This keeps control flow readable, composable, and testable.


Features

  • Headless API (no styles, no UI constraints)
  • Promise-based modal control
  • Internal stack management
  • Render location fully controlled by the user
  • Works naturally with async / await

License

MIT

About

Promise-based modal flows for React

Resources

Stars

Watchers

Forks

Packages