모달을 어떻게 재사용할까 ? 👀 서비스 내 다양한 바텀 시트들 서브 컴포넌트BottomSheetWithInput, BottomSheetWithContents … NextJS에서 Provider로 모달 상태 사용하기Client Component 자주 보았던 Context Provider Wrapper레퍼런스
모달을 어떻게 재사용할까 ?
서비스에서 제공되는 모달은 크게 리스트형, 입력형, 버튼형 3가지로 나뉩니다. 그리고 공통으로 사용하는 기능은 아래와 같습니다
- 배경 클릭시 언마운트 합니다.
- createPortal을 활용해 최상단에 렌더링합니다.
- 자식 컴포넌트를 렌더링합니다.
👀 서비스 내 다양한 바텀 시트들
'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