개인설정 페이지를 작업하는 중 일반적으로 모달을 적용하려면 아래와 같은 불편함이 있었습니다.
const [isOpen1, setOpen1] = useState(false);
const [isOpen2, setOpen2] = useState(false);
const [isOpen3, setOpen3] = useState(false);
const [isOpen4, setOpen4] = useState(false);
const handleClick1 = () => {
setOpen1(true);
};
const handleClick2 = () => {
setOpen2(true);
};
const handleClick3 = () => {
setOpen3(true);
};
const handleClick4 = () => {
setOpen4(true);
};
return (
<div className="App">
<button onClick={handleClick1}>모달1 열기</button>
<button onClick={handleClick2}>모달2 열기</button>
<button onClick={handleClick3}>모달3 열기</button>
<button onClick={handleClick4}>모달4 열기</button>
<Modal1 isOpen={isOpen1} />
<Modal2 isOpen={isOpen2} />
<Modal3 isOpen={isOpen3} />
<Modal4 isOpen={isOpen4} />
한 컴포넌트안에 여러개의 모달을 띄워야 할때 다음과 같이 반복되는 현상이 일어날 수 있습니다.
이러한 코드의 불필요한 반복을 피하기 위해 Context API를 활용해 useModal이라는 커스텀훅을 작성하려합니다.
Context Provider는 다음과 같이 코드를 구성했습니다.
function ModalProvider({ children }: PropsWithChildren) {
const [openModalState, setModalState] = useState<ModalStateContextType[]>([])
const open = (element: ReactNode, id: number) => {
setModalState((pre) => {
return [...pre, { Component: element, modalId: id }]
})
}
const close = (id: number) => {
setModalState((pre) => {
return pre.filter((element) => element.modalId !== id)
})
}
const dispatch = useMemo(() => ({ open, close }), [])
return (
<ModalDispatchContext.Provider value={dispatch}>
{children}
{openModalState.map(({ Component, modalId }) => (
<React.Fragment key={modalId}>{Component}</React.Fragment>
))}
</ModalDispatchContext.Provider>
)
}
일단 모달을 전역적으로 관리하기위해 배열형태로 만들기로 생각했습니다. 그 배열은 컴포넌트와 모달 ID 객체로 이루어진 배열입니다. 코드를 보시면 Modal들을 children옆에 map을 이용하여 렌더링해주고 있습니다. 이러한 이유는 간단히 말하자면 먼저 children이 무엇인가를 알아야합니다. children으로 전달된 컴포넌트는 React.createElement의 리턴값인 element 즉 불변 객체입니다. 그러므로 openModalState에 의해서 리렌더링이 되어도 하위컴포넌트들은 렌더링이 되지 않습니다. 만약 Provider value prop에 openModalState를 넣었다면 그 값을 사용하는 컴포넌트의 하위 컴포넌트까지 렌더링이 됩니다. 이러한 이유로 children 옆에 값을 넣어놨습니다. children과 관련된 렌더링은 제가 정리한 children, 자식 컴포넌트의 리렌더링에 관하여를 참고하시면 될 것 같습니다.
const useModal = () => {
const context = useContext(ModalDispatchContext)
if (context == null) {
throw new Error('ModalDispatProvider 안에서만 사용할 수 있습니다.')
}
const { open, close } = context
const openModal = (modalComponent: CreateModalElement) => {
const modalId = Date.now()
open(
<ModalController
modalElement={modalComponent}
exitFromModalArr={() => close(modalId)}
/>,
modalId,
)
}
return { openModal }
}
openModal은 인자로 활성화할 모달컴포넌트를 받습니다.
open함수를 이용해서 ModalController를 exitFromModalArr에는 저희가 아까 Context provider에서 작성한 close를 실행시킬 함수를 넘깁니다.
function ModalController(
{ modalElement: ModalElement, exitFromModalArr }: ModalPropsType,
) {
const [isOpen, setIsOpen] = useState(true)
const handleModalClose = useCallback(() => {
setIsOpen(false)
// 메모리에서 완전히 모달을 삭제 하기 위함이고 아래 코드를 추가 안하면 모달 animation이 바로 적용이 안된다.
setTimeout(() => {
exitFromModalArr()
}, 100)
}, [exitFromModalArr])
return <ModalElement isOpen={isOpen} close={handleModalClose} />
}
ModalController는 모달을 열고 닫는 기능을 담당하기 위한 컴포넌트 입니다. 이렇게 Controller 컴포넌트로 한단계 추상화하면서 일정한 틀안에서 자유롭게 각각의 모달들을 선언할 수 있었습니다. 또한 모달의 열고 닫는 상태 관리에 대한 반복적인 코드의 사용을 줄일 수 있습니다. 모달을 사용하는 코드 예시는 다음과 같습니다.
function Modal () {
const {openModal} = useModal()
const openNicknameHandler = () => {
openModal(({ isOpen, close }) => (
<UserSettingModal
isOpen={isOpen}
title="닉네임 변경"
submitHandler={(value: unknown) => {
mutate({ value, type: 'nickname', email: userEmail })
close()}}
closeHandler={close}
/>
)}
/>
))
}
const openJobHandler = () => {
openModal(({ isOpen, close }) => (
<UserSettingModal
isOpen={isOpen}
title="산업분야 변경"
submitHandler={(value: unknown) => {
mutate({ value, type: 'occupation', email: userEmail })
close()
}}
closeHandler={close}
renderItem={(setPostValue) => (
<UserSettingList
listData={USER_INFO_OCCUPATION}
wrap
setPostValue={setPostValue}
initialItem={userProfile?.occupation as string}
/>
)}
/>
))
}
return <button onclick={openNicknameHandler}>모달 열기</button>
}
ModalController와 같은 기능이 없다면 현재 열린 모달의 상태를 추적할 수 없습니다. 왜냐하면 저희는 리렌더링을 피하기 위해 children 옆에 Modal state를 설정해주었기 때문입니다. 물론 추적할 수 있는 다른 방식도 있습니다 예를 들면 아래와 같습니다.
export default function ModalProvider({ children }: PropsWithChildren) {
const [openModalState, setModalState] = useState<ModalStateContextType[]>([])
const open = (element: ReactNode, id: number) => {
setModalState((pre) => {
return [...pre, { Component: element, modalId: id }]
})
}
const close = (id: number) => {
setModalState((pre) => {
return pre.filter((element) => element.modalId !== id)
})
}
const dispatch = useMemo(() => ({ open, close }), [])
return (
<ModalDispatchContext.Provider value={dispatch}>
<ModalStateContext.Provider value={openModalState}>
{children}
</ModalStateContext.Provider>
</ModalDispatchContext.Provider>
)
}
위와같이 작성하면 ModalState 컨텍스트를 따로 만들어주고 사용하면 추적할 수 있습니다.
export default Page () {
retunr <Modals/>
}
// Modals.tsx
export default Modals () {
const state = useContext(ModalStateContext)
return
<div>
{openModalState.map(({ Component, modalId }) => (
<React.Fragment key={modalId}>{Component}</React.Fragment>
))}
<div>
}
// Modal
function Modal () {
const {open, close} = useContext(ModalDispatch)
const openNicknameHandler = () => {
const modalId = Date.now()
open(
<UserSettingModal
modalId={modalId}
isOpen={isOpen}
title="닉네임 변경"
submitHandler={(value: unknown) => {
mutate({ value, type: 'nickname', email: userEmail })
close()
}}
closeHandler={close}
/>, modalId)
}
}
// UserSettingModal
function UserSettingModal({modalId}) {
const state = useContext(ModalState)
const [isOpen, setIsOpen] = useState(() => {
return state.filter((el) => el.modalId === modalId).length !== 0
})
return <div> 모달과 관련된 코드</div>
}
ModalController를 사용하지 않을때는 다음과 같은 불편함이 있습니다.
위와 같은 이유로 ModalController 같은 컴포넌트로 모달의 열고 닫는 기능을 추상화 해주면 위와같은 문제를 해결할 수 있습니다.