React Modal Portal

Take!·2023년 12월 26일
0

React

목록 보기
3/3
post-custom-banner

Modal Portals 사용하기

  • 기존의 React에서 Modal을 사용할 때 부모 컴포넌트에 종속되어 Props를 상속받고, 부모 컴포넌트 DOM 내부에서 렌더링되어야 했다. 하지만 Portal을 사용하면 부모 컴포넌트의 DOM 구조에 종속되지 않으면서 컴포넌트 렌더링을 할 수 있게 되었다.

  • modal 사용 시 다른 컴포넌트와 겹치거나 css속성인 z-index를 신경써야 한다는 문제점이 발생할 수 있다. 이러한 문제를 Portal을 통해 해결할 수 있다.

코드 구현

1. modal이 생성될 위치에 div 태그 생성

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을 태그를 만들어준다.

2. modal 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
}

3. 실제 사용할 Modal 생성

// 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
}

4. ModalContext로 감싸주기

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()

5. useModalContext 사용하기

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
}
profile
확장 및 유지 보수가 가능한 설계를 지향하는 프론트엔드 개발자!
post-custom-banner

0개의 댓글