banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Why I Prefer Command Modals More

Update 24.3: Packaged into a component library, welcome everyone to use it.

Modal dialog box.

A very common component. You can see it in both C-end and B-end applications. But do you really know how to use it?

What is a Declarative Modal#

Component libraries generally come with built-in components like this, with a typical definition of a declarative Modal.

For example, the declarative Modal in Antd 5 is defined like this.

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }

  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <Modal
        title="Basic Modal"
        open={isModalOpen}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <p>Some contents...</p>
        <p>Some contents...</p>
        <p>Some contents...</p>
      </Modal>
    </>
  )
}

The above is a controlled declarative Modal definition, which is quite bulky to write. You need to manually control the Modal's open state. You also need to first define a state and then bind the state to the UI.

With this writing style, we need to define a state and a trigger (like a Button) in the same component -> control the state -> flow to the Modal display. This not only makes it complex to write but also difficult to maintain later.

As the business grows, your page might look like this.

<>
  <Button type="primary" onClick={showModal}>
    Open Modal 1
  </Button>

  <Button type="primary" onClick={showModal}>
    Open Modal 2
  </Button>

  {/* More buttons */}
  <Modal
    title="Basic Modal"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>Some contents...</p>
  </Modal>

  <Modal
    title="Basic Modal 2"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>Some contents...</p>
  </Modal>
  <Modal
    title="Basic Modal 3"
    open={isModalOpen}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <p>Some contents...</p>
  </Modal>
</>

A component is filled with countless Modals and Buttons.

At this point, you might want to extract the Modal to an external component. Like this:

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }

  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
    </>
  )
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
  return (
    <Modal
      title="Basic Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>Some contents...</p>
    </Modal>
  )
}

Then you will find that the control of the Modal's state is still at the top level of the parent component. This leads to an increasing accumulation of states in the parent component.

const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [isModalOpen2, setIsModalOpen2] = useState(false)
  const [isModalOpen3, setIsModalOpen3] = useState(false)
  // ....
}

Then you think about it and decide to extract the Modal and Button together.

const App: React.FC = () => {
  return <BaseModal1 />
}
const BaseModal1 = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }
  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <Modal
        title="Basic Modal"
        open={isModalOpen}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <p>Some contents...</p>
      </Modal>
    </>
  )
}

Now, the Button and Modal are directly coupled, making it almost impossible to reuse the Modal independently later.

After thinking about it, you decide to separate the Modal again. Like this:

const App: React.FC = () => {
  return <BaseModal1WithButton />
}
const BaseModal1WithButton = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)

  const showModal = () => {
    setIsModalOpen(true)
  }

  const handleOk = () => {
    setIsModalOpen(false)
  }

  const handleCancel = () => {
    setIsModalOpen(false)
  }
  return (
    <>
      <Button type="primary" onClick={showModal}>
        Open Modal
      </Button>
      <BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
    </>
  )
}

const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
  return (
    <Modal
      title="Basic Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>Some contents...</p>
    </Modal>
  )
}

Wow, to decouple a Modal, you have to write so much code, and it’s still not reusable, with a messy state.

Imagine this is just one Modal, and you have to write so much.

Later, you will encounter such a problem: because the control of the Modal state has sunk, your Modal cannot be directly controlled in the parent component.

Then you will directly delegate this state to an external Store or Context.

import { atom } from 'jotai'
const BasicModal1OpenedAtomContext = createContext(atom(false))
const App: React.FC = () => {
  const ctxValue = useMemo(() => atom(false), [])

  return (
    <BasicModal1OpenedAtomContext.Provider value={ctxValue}>
      <button
        onClick={() => {
          jotaiStore.set(ctxValue, true)
        }}
      >
        Open Modal 1
      </button>
      <BaseModal1WithButton />
    </BasicModal1OpenedAtomContext.Provider>
  )
}

const BaseModal1 = ({ handleOk, handleCancel }) => {
  const [isModalOpen, setIsModalOpen] = useAtom(
    useContext(BasicModal1OpenedAtomContext),
  )
  return (
    <Modal
      title="Basic Modal"
      open={isModalOpen}
      onOk={handleOk}
      onCancel={handleCancel}
    >
      <p>Some contents...</p>
    </Modal>
  )
}

In the end, the states in ctx or Store keep increasing, and you will find your code becomes harder to maintain. Eventually, you won't even know whether this Modal state is needed.

Moreover, even if the Modal is not displayed, it still exists in the React tree, and updates in ancestor components will also cause the Modal to re-render, resulting in performance overhead.

Try Imperative Modal#

Some component libraries also provide imperative Modals, like this in Antd 5.

function Comp() {
  const [modal, contextHolder] = Modal.useModal()

  return (
    <>
      <Button
        onClick={async () => {
          const confirmed = await modal.confirm(config)
          console.log('Confirmed: ', confirmed)
        }}
      >
        Confirm
      </Button>
      {contextHolder}
    </>
  )
}

Isn't the above writing much simpler? No longer needing external state to control visibility.

However, this imperative Modal definition seems overly simplistic and is generally only suitable for dialog prompts. It won't carry complex business logic.

Implementing an Imperative Modal#

Alright, following this idea, we can try to implement our own imperative Modal. The Modal we implement needs to minimize the migration from the original declarative Modal while also being able to carry complex business logic.

The overall idea is that we need to use a Context at the top level of the application to store the state of all Modals. When a Modal uses present, a Modal instance is created and recorded in the Context. When the Modal is closed, the Modal instance is destroyed. Therefore, the state in the top-level ModalStack should only contain the currently rendered Modal instances, maximizing memory resource savings.

Next, I will implement it using Antd Modal + Jotai. Other similar component implementations are basically the same.

First, we implement ModalStack.

import { ModalProps as AntdModalProps, Modal } from 'antd'

type ModalProps = {
  id?: string

  content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open'>

const modalStackAtom = atom([] as (Omit<ModalProps, 'id'> & { id: string })[])

const ModalStack = () => {
  const stack = useAtomValue(modalStackAtom)

  return (
    <>
      {stack.map((props, index) => {
        return <ModalImpl key={props.id} {...props} index={index} />
      })}
    </>
  )
}

Define useModalStack to invoke the Modal.

const modalIdToPropsMap = {} as Record<string, ModalProps>

export const presetModal = (
  props: ModalProps,
  modalId = ((Math.random() * 10) | 0).toString(),
) => {
  jotaiStore.set(modalStackAtom, (p) => {
    const modalProps = {
      ...props,
      id: props.id ?? modalId,
    } satisfies ModalProps
    modalIdToPropsMap[modalProps.id!] = modalProps
    return p.concat(modalProps)
  })

  return () => {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.filter((item) => item.id !== modalId)
    })
  }
}

export const useModalStack = () => {
  const id = useId()
  const currentCount = useRef(0)
  return {
    present(props: ModalProps) {
      const modalId = `${id}-${currentCount.current++}`
      return presetModal(props, modalId)
    },
  }
}

In the above code, we define modalStackAtom to store Modal instances. presetModal is used to invoke the Modal. The present method of useModalStack is used to invoke a new Modal.

Since we use Jotai external state to manage Modal instances, presetModal is extracted to the outside, allowing us to directly use it without React in the future.

Note this type definition; we basically inherit the original ModalProps, but filter out the open property. Because we do not need external control over the Modal's visibility, but directly control the Modal's visibility in ModalStack.

The content property is convenient for us to extend the props passed in later. For example, here we can pass a ModalActions as props. So in the future, when defining Content, we can directly accept a props and close the current Modal through the dismiss method.

type ModalContentProps = {
  dismiss: () => void
}

type ModalProps = {
  id?: string

  content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open' | 'content'>

The implementation of <ModalImpl /> is very simple. Before that, we define ModalActionContext, which can be directly called and used in the Modal.

const actions = {
  dismiss(id: string) {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.filter((item) => item.id !== id)
    })
  },
  dismissTop() {
    jotaiStore.set(modalStackAtom, (p) => {
      return p.slice(0, -1)
    })
  },
  dismissAll() {
    jotaiStore.set(modalStackAtom, [])
  },
}

Improve useModalStack

export const useModalStack = () => {
  const id = useId()
  const currentCount = useRef(0)
  return {
    present: useCallback((props: ModalProps) => {
      const modalId = `${id}-${currentCount.current++}`
      return presetModal(props, modalId)
    }, []),
+    ...actions
  }
}

Now you can close a specific Modal using useModalStack().dismiss, or close the topmost Modal using useModalStack().dismissTop, and so on.

Now let's write <ModalImpl />:

const ModalActionContext = createContext<{
  dismiss: () => void
}>(null!)

export const useCurrentModalAction = () => useContext(ModalActionContext)

const ModalImpl: FC<
  Omit<ModalProps, 'id'> & {
    id: string
    index: number
  }
> = memo((props) => {
  const { content } = props
  const [open, setOpen] = useState(true)
  const setStack = useSetAtom(modalStackAtom)

  const removeFromStack = useEventCallback(() => {
    setStack((p) => {
      return p.filter((item) => item.id !== props.id)
    })
  })

  useEffect(() => {
    let isCancelled = false
    let timerId: any
    if (!open) {
      timerId = setTimeout(() => {
        if (isCancelled) return
        removeFromStack()
      }, 1000) // Here we control a time difference to wait for the Modal closing animation to complete before destroying the Modal instance
    }
    return () => {
      isCancelled = true
      clearTimeout(timerId)
    }
  }, [open, removeFromStack])
  const onCancel = useEventCallback(() => {
    setOpen(false)
    props.onCancel?.()
  })

  return (
    <ModalActionContext.Provider // Here we provide some Modal Actions in the current Modal context
      value={useMemo(() => ({ dismiss: onCancel }), [onCancel])}
    >
      <Modal {...props} open={open} destroyOnClose onCancel={onCancel}>
        {typeof content === 'function'
          ? createElement(content, { dismiss: onCancel }) // Here we can pass parameters to content through props
          : content}
      </Modal>
    </ModalActionContext.Provider>
  )
})
ModalImpl.displayName = 'ModalImpl'

OK, this completes the overall implementation.

Now we come to the top-level component of the React App, mounting <ModalStack />.

const App = document.getElementById('root')
const Root: FC = () => {
  return (
    <div>
      <ModalStack />
    </div>
  )
}

Then use it like this:

  <div>
      <ModalStack />
+      <Page />
  </div>
const Page = () => {
  const { present } = useModalStack()
  return (
    <>
      <div>
        <button
          onClick={() => {
            present({
              title: 'Title',
              content: <ModalContent />,
            })
          }}
        >
          Modal Stack
        </button>
      </div>
    </>
  )
}

const ModalContent = () => {
  const { dismiss } = useCurrentModalAction() // Control the actions of the current Modal

  return (
    <div>
      This Modal content.
      <br />
      <button onClick={dismiss}>Dismiss</button>
    </div>
  )
}

Of course, you can also continue to use useModalStack to invoke new Modals inside the Modal.

const ModalContent = () => {
  const { dismiss } = useCurrentModalAction()
  const { present, dismissAll } = useModalStack()

  return (
    <div>
      This Modal content.
      <ButtonGroup>
        <Button
          onClick={() => {
            present({
              title: 'Title',
              content: <ModalContent />,
            })
          }}
        >
          Present New
        </Button>
        <Button onClick={dismiss}>Dismiss This</Button>
        <Button onClick={dismissAll}>Dismiss All</Button>
      </ButtonGroup>
    </div>
  )
}
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.