更新 24.3:コンポーネントライブラリにパッケージ化されました。皆さんのご利用をお待ちしています。
モーダルダイアログ。
非常に一般的なコンポーネントです。C 端でも B 端でもその姿を見ることができます。しかし、本当に使いこなせていますか?
宣言的モーダルとは#
コンポーネントライブラリには一般的にこのようなコンポーネントが組み込まれており、最も参照される宣言的モーダルの定義です。
例えば、Antd 5 の宣言的モーダルはこのように定義されています。
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}>
モーダルを開く
</Button>
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
<p>いくつかの内容...</p>
<p>いくつかの内容...</p>
</Modal>
</>
)
}
上記は制御された宣言的モーダルの定義で、非常に冗長です。モーダルのオープン状態を手動で制御する必要があります。また、最初に状態を定義し、その後 UI を作成し、状態と UI をバインドする必要があります。
このような書き方では、同じコンポーネント内で状態とトリガー(例えばボタン)を定義し、状態を制御し、モーダルの表示に流れ込む必要があります。書くのが複雑なだけでなく、後のメンテナンスも非常に困難です。
ビジネスが増えるにつれて、後にはあなたのページがこのようになるかもしれません。
<>
<Button type="primary" onClick={showModal}>
モーダルを開く 1
</Button>
<Button type="primary" onClick={showModal}>
モーダルを開く 2
</Button>
{/* さらにボタン */}
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
<Modal
title="基本モーダル 2"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
<Modal
title="基本モーダル 3"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
</>
1 つのコンポーネントに無数のモーダルとボタンが詰め込まれています。
この時、モーダルを外部に抽出したくなるでしょう。こんな感じで:
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}>
モーダルを開く
</Button>
<BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
)
}
そして、モーダルの状態を制御するのはまだ親コンポーネントの最上位にあります。これにより、親コンポーネントの状態がどんどん積み重なっていきます。
const App: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isModalOpen2, setIsModalOpen2] = useState(false)
const [isModalOpen3, setIsModalOpen3] = useState(false)
// ....
}
そして、考えを巡らせて、モーダルとボタンを一緒に抽出します。
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}>
モーダルを開く
</Button>
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
</>
)
}
さて、これでボタンとモーダルが直接結合され、後でモーダルを単独で再利用することはほぼ不可能になります。
考えを巡らせて、再びモーダルを分けます。こんな感じで:
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}>
モーダルを開く
</Button>
<BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
)
}
ああ、モーダルのデカップリングのためにこんなに多くのコードを書く必要があり、しかも再利用不可能で、状態が混乱しています。
想像してみてください、これが 1 つのモーダルで、こんなに多くのコードを書く必要があります。
その後、あなたはこのような問題に直面します。モーダルの状態を制御することが下に沈んでしまい、親コンポーネントでモーダルを直接制御できなくなります。
そして、外部のストアやコンテキストでこの状態を下に流すことになります。
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)
}}
>
モーダル 1 を開く
</button>
<BaseModal1WithButton />
</BasicModal1OpenedAtomContext.Provider>
)
}
const BaseModal1 = ({ handleOk, handleCancel }) => {
const [isModalOpen, setIsModalOpen] = useAtom(
useContext(BasicModal1OpenedAtomContext),
)
return (
<Modal
title="基本モーダル"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>いくつかの内容...</p>
</Modal>
)
}
最終的に ctx やストアの中の状態が増えていき、コードのメンテナンスがますます難しくなります。最終的には、このモーダルの状態が本当に必要なのかどうかわからなくなります。
ましてや、モーダルが表示されていなくても、モーダルは React ツリー上に存在し続け、祖先コンポーネントの状態更新もモーダルの再レンダリングを引き起こし、パフォーマンスのオーバーヘッドを生じさせます。
コマンド式モーダルを試してみよう#
一部のコンポーネントライブラリでは、コマンド式モーダルも提供されています。Antd 5 ではこのようになります。
function Comp() {
const [modal, contextHolder] = Modal.useModal()
return (
<>
<Button
onClick={async () => {
const confirmed = await modal.confirm(config)
console.log('確認された: ', confirmed)
}}
>
確認
</Button>
{contextHolder}
</>
)
}
上記の書き方は、ずっとシンプルになり、外部の状態で表示・非表示を制御する必要がなくなります。
しかし、このコマンド式モーダルの定義はあまりにもシンプルで、一般的にはダイアログの提示にしか適していません。複雑なビジネスロジックを担うことはありません。
コマンド式モーダルを実装する#
さて、この考え方に従って、コマンド式モーダルを自分で実装してみましょう。私たちが実装するモーダルは、元の宣言的モーダルの移行を最小限に抑えつつ、複雑なビジネスロジックを担えるようにする必要があります。
全体の考え方として、アプリケーションの最上位でコンテキストを使用してすべてのモーダルの状態を保存する必要があります。モーダルが present
を使用する際に、コンテキストにモーダルインスタンスを記録します。モーダルが閉じられたときに、モーダルインスタンスを破棄します。したがって、最上位の ModalStack
の状態には、現在レンダリングされているモーダルインスタンスのみが含まれるべきです。メモリリソースを最大限に節約します。
次に、Antd Modal + Jotai を使用して実装します。他の類似コンポーネントの実装方法は基本的に同じです。
まず、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} />
})}
</>
)
}
useModalStack
を定義してモーダルを呼び出します。
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)
},
}
}
上記のコードでは、modalStackAtom
を定義してモーダルインスタンスを保存します。presetModal
はモーダルを呼び出すために使用されます。useModalStack
の present
は新しいモーダルを呼び出すために使用されます。
Jotai の外部状態を使用してモーダルインスタンスを管理しているため、presetModal
は外部に抽出され、今後は React から直接切り離して使用できます。
この型定義に注意してください。私たちは基本的に元の ModalProps
を継承していますが、open
プロパティをフィルタリングしています。なぜなら、モーダルの表示・非表示を外部で制御する必要がなく、直接 ModalStack
でモーダルの表示・非表示を制御するからです。
また、content
プロパティは、後で渡すプロパティを拡張するのに便利です。例えば、ここでは ModalActions
をプロパティとして渡すことができます。したがって、今後コンテンツを定義する際には、直接プロパティを受け取ることができ、dismiss
メソッドを使用して現在のモーダルを閉じることができます。
type ModalContentProps = {
dismiss: () => void
}
type ModalProps = {
id?: string
content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open' | 'content'>
<ModalImpl />
の実装は非常にシンプルです。その前に、ModalActionContext
を定義して、後でモーダル内で直接呼び出して使用できるようにします。
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, [])
},
}
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
}
}
これで、useModalStack().dismiss
を使用して特定のモーダルを閉じることができ、useModalStack().dismissTop
を使用して最上位のモーダルを閉じることもできます。
次に、<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) // ここで時間差を制御し、モーダルが閉じた後のアニメーションが完了するのを待って、モーダルインスタンスを破棄します
}
return () => {
isCancelled = true
clearTimeout(timerId)
}
}, [open, removeFromStack])
const onCancel = useEventCallback(() => {
setOpen(false)
props.onCancel?.()
})
return (
<ModalActionContext.Provider // ここで現在のモーダルの上下文にいくつかのモーダルアクションを提供します
value={useMemo(() => ({ dismiss: onCancel }), [onCancel])}
>
<Modal {...props} open={open} destroyOnClose onCancel={onCancel}>
{typeof content === 'function'
? createElement(content, { dismiss: onCancel }) // ここでプロパティを通じてコンテンツにパラメータを渡すことができます
: content}
</Modal>
</ModalActionContext.Provider>
)
})
ModalImpl.displayName = 'ModalImpl'
これで全体の実装が完了しました。
次に、React アプリの最上位コンポーネントに <ModalStack />
をマウントします。
const App = document.getElementById('root')
const Root: FC = () => {
return (
<div>
<ModalStack />
</div>
)
}
そして、このように使用します:
<div>
<ModalStack />
+ <Page />
</div>
const Page = () => {
const { present } = useModalStack()
return (
<>
<div>
<button
onClick={() => {
present({
title: 'タイトル',
content: <ModalContent />,
})
}}
>
モーダルスタック
</button>
</div>
</>
)
}
const ModalContent = () => {
const { dismiss } = useCurrentModalAction() // 現在のモーダルのアクションを制御します
return (
<div>
このモーダルの内容。
<br />
<button onClick={dismiss}>閉じる</button>
</div>
)
}
もちろん、モーダル内でさらに useModalStack
を使用して新しいモーダルを呼び出すこともできます。
const ModalContent = () => {
const { dismiss } = useCurrentModalAction()
const { present, dismissAll } = useModalStack()
return (
<div>
このモーダルの内容。
<ButtonGroup>
<Button
onClick={() => {
present({
title: 'タイトル',
content: <ModalContent />,
})
}}
>
新しいモーダルを表示
</Button>
<Button onClick={dismiss}>これを閉じる</Button>
<Button onClick={dismissAll}>すべてを閉じる</Button>
</ButtonGroup>
</div>
)
}