기존의 React에서 Modal을 사용할 때 부모 컴포넌트에 종속되어 Props를 상속받고, 부모 컴포넌트 DOM 내부에서 렌더링되어야 했다. 하지만 Portal을 사용하면 부모 컴포넌트의 DOM 구조에 종속되지 않으면서 컴포넌트 렌더링을 할 수 있게 되었다.
modal 사용 시 다른 컴포넌트와 겹치거나 css속성인 z-index를 신경써야 한다는 문제점이 발생할 수 있다. 이러한 문제를 Portal을 통해 해결할 수 있다.
index.html
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<div id="root-portal"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
root 태그 밑에 root-portal을 태그를 만들어준다.
// ModalContext.tsx
import { createContext, useContext, useMemo, useState } from 'react'
import Modal from '@/components/shared/Modal'
import { createPortal } from 'react-dom'
type ModalProps = React.ComponentProps<typeof Modal>
type ModalOptions = Omit<ModalProps, 'open'>
interface ModalContextValue {
open: (options: ModalOptions) => void
close: () => void
}
const Context = createContext<ModalContextValue | undefined>(undefined)
const defaultValues: ModalProps = {
open: false,
body: null,
onRightButtonClick: () => {},
onLeftButtonClick: () => {},
}
export default function ModalContext({
children,
}: {
children: React.ReactNode
}) {
const [modalState, setModalState] = useState<ModalProps>(defaultValues)
const portalRoot = document.getElementById('portal-root') as HTMLElement
const open = (options: ModalOptions) => {
setModalState({ ...options, open: true })
}
const close = () => {
setModalState(defaultValues)
}
const values = useMemo(
() => ({
open,
close,
}),
[],
)
return (
<Context.Provider value={values}>
{children}
{portalRoot !== null
? createPortal(<Modal {...modalState} />, portalRoot)
: null}
</Context.Provider>
)
}
export function useModalContext() {
const values = useContext(Context)
if (values === null) {
throw new Error('ModalContext 안에서 사용해주세요')
}
return values
}
// AttendCountModal
import { useModalContext } from '@/contexts/ModalContext'
import { Wedding } from '@/models/wedding'
import { useEffect, useRef } from 'react'
export default function AttendCountModal({ wedding }: { wedding: Wedding }) {
const { open, close }: any = useModalContext()
const $input = useRef<HTMLInputElement>(null)
const haveSeenModal = localStorage.getItem('@have-seen-modal')
useEffect(() => {
console.log('hi')
if (haveSeenModal === 'true') {
return
}
open({
title: `현재 참석자: ${wedding.attendCount} 명`,
body: (
<div>
<input
ref={$input}
placeholder="참석 가능 인원을 추가해주세요"
style={{ width: '100%' }}
type="number"
/>
</div>
),
onLeftButtonClick: () => {
localStorage.setItem('@have-seen-modal', 'true')
close()
},
onRightButtonClick: async () => {
if ($input.current == null) {
return
}
await fetch('http://localhost:8888/wedding', {
method: 'PUT',
body: JSON.stringify({
...wedding,
attendCount: wedding.attendCount + Number($input.current.value),
}),
headers: {
'Content-Type': 'application/json',
},
})
localStorage.setItem('@have-seen-modal', 'true')
close()
},
})
}, [open, close, wedding, haveSeenModal])
return null
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import ModalContext from './contexts/ModalContext'
import reportWebVitals from './reportWebVitals'
import './scss/global.scss'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<ModalContext>
<App />
</ModalContext>
</React.StrictMode>,
)
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals()
import { useModalContext } from '@/contexts/ModalContext'
import { Wedding } from '@/models/wedding'
import { useEffect, useRef } from 'react'
export default function AttendCountModal({ wedding }: { wedding: Wedding }) {
const { open, close }: any = useModalContext()
const $input = useRef<HTMLInputElement>(null)
const haveSeenModal = localStorage.getItem('@have-seen-modal')
useEffect(() => {
console.log('hi')
if (haveSeenModal === 'true') {
return
}
open({
title: `현재 참석자: ${wedding.attendCount} 명`,
body: (
<div>
<input
ref={$input}
placeholder="참석 가능 인원을 추가해주세요"
style={{ width: '100%' }}
type="number"
/>
</div>
),
onLeftButtonClick: () => {
localStorage.setItem('@have-seen-modal', 'true')
close()
},
onRightButtonClick: async () => {
if ($input.current == null) {
return
}
await fetch('http://localhost:8888/wedding', {
method: 'PUT',
body: JSON.stringify({
...wedding,
attendCount: wedding.attendCount + Number($input.current.value),
}),
headers: {
'Content-Type': 'application/json',
},
})
localStorage.setItem('@have-seen-modal', 'true')
close()
},
})
}, [open, close, wedding, haveSeenModal])
return null
}