리액트에서 일반적으로 Modal은 상태를 선언하고 이를 조작하여 열고 닫는 형태로 구현된다.
const App = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>모달 열기</button>
{isOpen && <Modal onClose={() => setIsOpen(false)} />}
</div>
);
};
이 방식은 직관적이고 간단하지만, Modal이 늘어날수록 같은 상태와 조작 로직을 중복 작성하게 된다.
특히 Modal을 여러 곳에서 사용해야 하는 경우, 유지 보수성이 떨어지고 상태 관리가 번거로워진다.
Compound Component Pattern과 React Portal을 사용하면 Modal을 보다 유연하고 재사용 가능하며 유지 보수성이 높은 컴포넌트로 개선할 수 있다.
Compound Component Pattern은 부모 컴포넌트와 여러 자식 컴포넌트가 하나의 상태를 공유하여 협력적으로 동작하도록 만드는 패턴이다. 예를 들어 HTML의 select
와 option
처럼 동작한다.
<label for="pet-select">Choose a pet:</label>
<select name="pets" id="pet-select">
<option value="">--Please choose an option--</option>
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="hamster">Hamster</option>
<option value="parrot">Parrot</option>
<option value="spider">Spider</option>
<option value="goldfish">Goldfish</option>
</select>
컴포넌트 간 상태를 공유하기 위해 Context를 생성한다.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
const providerValue = { open, toggle };
return (
<FlyOutContext.Provider value={providerValue}>
{props.children}
</FlyOutContext.Provider>
);
}
FlyOut에서 사용할 자식 컴포넌트를 구현한다. 자식 컴포넌트는 Context를 통해 부모의 상태를 공유한다.
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<span>Toggle</span>
</div>
);
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>;
}
function Item({ children }) {
return <li>{children}</li>;
}
부모 컴포넌트의 static 속성으로 자식 컴포넌트를 추가하여 부모 컴포넌트만 import하면 자식 컴포넌트를 사용할 수 있게 한다.
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
FlyOut 컴포넌트를 사용하여 FlyoutMenu를 구성한다. FlyoutMenu 컴포넌트는 자체적인 상태를 가지지 않는다.
import { FlyOut } from './FlyOut'
function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
)
}
React Portal은 컴포넌트를 부모 컴포넌트의 계층 구조 밖에 있는 DOM 노드의 자식 컴포넌트로 렌더링하는 방법을 제공한다.
부모 컴포넌트의 계층 구조 밖에서 컴포넌트를 렌더링하기 부모 컴포넌트의 스타일에 영향을 받지 않는다.
상위 컴포넌트의 overflow: hidden
과 같은 설정과의 충돌을 피할 수 있고 z-index
와 같은 속성을 쉽게 제어할 수 있어 스타일링이 간단해진다.
Portal로 렌더링된 컴포넌트는 시각적으로 부모 컴포넌트 외부에서 렌더링되지만, 여전히 리액트의 상태나 이벤트와 연결되어 있어 이벤트 버블링이 정상적으로 동작한다.
ReactDOM의 createPortal
함수를 사용하여 JSX와 렌더링할 위치의 부모 DOM node 를 인수로 전달한다.
import React from 'react';
import { createPortal } from 'react-dom';
function Portal({ children }) {
return createPortal(
<div className="modal">{children}</div>,
document.body
);
}
const ModalContext = createContext({
openName: '',
open: (name: string) => { },
close: () => { }
});
Modal의 상태와 함수를 Context Provider로 관리한다.
export const Modal = ({ children }: { children: ReactNode }) => {
const [openName, setOpenName] = useState('')
const close = () => setOpenName('')
const open = (name: string) => setOpenName(name);
return <ModalContext.Provider value={{ openName, open, close }}>
{children}
</ModalContext.Provider>
}
Modal을 열기 위한 트리거 컴포넌트로, 버튼 같은 트리거 요소를 감싼다.
const Open = ({ children, name }: { children: ReactElement, name: string }) => {
const { open } = useContext(ModalContext)
return cloneElement(children, { onClick: () => open(name) })
}
Modal UI를 렌더링하며 Portal을 활용한다.
const Window = ({ children, name }: { children: ReactElement, name: string }) => {
const { openName, close } = useContext(ModalContext)
if (openName !== name) return null
return createPortal(
<div className="absolute inset-0 z-50 flex items-center justify-center" onClick={handleClose}>
<div
className="relative z-50 flex items-center justify-center h-full w-web min-w-mobile bg-black/50"
>
{cloneElement(children, { onCloseModal: close })}
</div>
</div >,
document.body,
);
};
Modal에 Open과 Window를 연결한다.
Modal.Open = Open;
Modal.Window = Window;
버튼을 클릭하면 ConfirmModal을 띄우는 예제이다.
interface ConfirmProps {
children: ReactNode
onCloseModal?: () => void
}
// Modal 컴포넌트에서 onCloseModal 속성에 close 함수를 정의한다.
const Confirm = ({ children, onCloseModal }: ConfirmProps) => {
return (
<div onClick={(e) => e.stopPropagation()} >
<div>
Modal 내용...
</div>
<div>
<button type='button' onClick={onCloseModal} >
닫기
</button>
{children}
</div>
</div>
);
}
const Details = () => {
const { mutate: cancelBid } = useCancelBid()
const clickCancel = () => cancelBid()
return (
<Modal>
<Modal.Open name="cancelBid">
<button>
참여 취소
</button>
</Modal.Open>
<Modal.Window name="cancelBid">
<Confirm type="cancelBid" >
<button onClick={clickCancel}>
참여 취소
</button>
</Confirm>
</Modal.Window>
</Modal>
);
}
개발자 도구에서 확인하면 body
태그에 Modal이 렌더링된 것을 볼 수 있다.
Compound Pattern
MDN HTML select
What are React Portals?
React Portals 공식문서