[FE 워크샵] NextJS에서 모달 제작

NextJS
3/19/2024

모달을 어떻게 재사용할까 ?

서비스에서 제공되는 모달은 크게 리스트형, 입력형, 버튼형 3가지로 나뉩니다. 그리고 공통으로 사용하는 기능은 아래와 같습니다
 
  1. 배경 클릭시 언마운트 합니다.
  1. createPortal을 활용해 최상단에 렌더링합니다.
  1. 자식 컴포넌트를 렌더링합니다.
 
notion image

👀 서비스 내 다양한 바텀 시트들

notion image
notion image
notion image
notion image
'use client'; import clsx from 'clsx'; import { ReactNode } from 'react'; type BottomSheetProps = { children: ReactNode; toggle?: () => void; className?: string; }; const BottomSheet = ({ children, toggle, className }: BottomSheetProps) => { const bottomSheetClasses = clsx( 'fixed', 'inset-0', 'bg-grey900', 'bg-opacity-50', 'z-100', 'flex', 'items-end' ); return ( React.createPortal( <div className={bottomSheetClasses} onClick={toggle}> <div className={`bg-white rounded-lg w-full py-8 px-4 ${className}`} onClick={(e) => { toggle && e.stopPropagation(); }} > {children} </div> </div> ), document.body) ) }; export default BottomSheet;
 

서브 컴포넌트

바텀 시트의 종류가 다양한 만큼, children으로 사용될 컴포넌트의 종류도 다양합니다. Title, List, Input, Button, Content 등 사실 모든 게 들어가는 것 같습니다.
 

BottomSheetWithInput, BottomSheetWithContents …

바텀시트의 종류마다 커스텀해야 했습니다. 그럼 팝업이 추후에 추가될 때마다 해당 컴포넌트도 With의 이름을 달고 증가하게 됩니다. 아래처럼 팝업의 type을 지정해두고, 필요할 때마다 필터해서 export 하는 방식 또한 유사합니다.
const filterPopup = (props,idx) => { const {data : {type}} = props; if (type === 'default') { return <Popup key={idx} {...props} />; } if( type === 'input'){ return <InputPopup key={idx} {...props} /> } if (type === 'list'){ return <ListPopup key={idx} {...props} /> } }
좀더 BottomSheet의 구성에 대한 가독성을 높이고, 종류마다 또 다른 커스텀 컴포넌트를 만들지 않는 방법을 찾고 싶었습니다.
 
 

NextJS에서 Provider로 모달 상태 사용하기

createPortal을 활용해 어디서든 바텀시트가 document.body 하위에서 렌더링되도록 만들고 싶습니다. 그러나 BottomSheet의 open 여부를 관리하려면 내부 state가 필요하고, 이는 앱 전체가 공유하는 전역상태가 됩니다.
보통 React 앱에서는 ContextAPI를 활용하는데요. 클라이언트의 상태관리와 관련있는 React context는 서버 컴포넌트에서 쓸 수 없습니다. 그래서 그냥 사용하면 아래처럼 에러를 일으킵니다.
// app/layout.tsx import { createContext } from 'react' // createContext is not supported in Server Components export const ThemeContext = createContext({}) export default function RootLayout({ children }) { return ( <html> <body> <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider> </body> </html> ) }
결국, NextJS의 App 라우터에서는 아래처럼 Client component 내부에서 provider를 만들고 렌더해야 합니다. 그러면 서버컴포넌트는 해당 provider가 Client Component로 마크되었으므로, 직접적으로 렌더할 수 없습니다.
//app/provider/modal-provider.tsx 'use client'; import { ReactNode, createContext } from 'react'; export const ModalContext = createContext(false); export default function ModalProvider({ children }: { children: ReactNode }) { return ( <ModalContext.Provider value={false}>{children}</ModalContext.Provider> ); }
다른 자식 클라이언트 컴포넌트는 이 context를 사용할 수 있습니다.
(항상 사용되는 자식과 가까운 부모에 context를 만드는 건 잊지마세요!)

Client Component

'use client'
직접적으로 ‘use client’를 모듈 내 최상단에서 선언하면, 이는 서버와 클라이언트를 구분짓는 Network 바운더리가 됩니다. NextJS는 이를 확인하면 해당 모듈에서 import된 파일과 자식 컴포넌트는 모두 client에서 처리합니다.
 

자주 보았던 Context Provider

 

Wrapper

// app/BottomSheetProvider.tsx 'use client' import BottomSheet from BottomSheet export default BottomSheet
 
 

레퍼런스

©JIYOUNG CHOI, All rights reserved