이번 방학과 나의 취업 준비를 맞이하여, 진행중인 프로젝트에서 다뤄 볼 법한 주제 들을 꾸준한 블로깅을 해볼 생각입니다.
현재 프로젝트는 Next.js / Typescript / MUI 의 조합으로 이루어져 있으며, 연재 기간은 3-4일, 10개 이상의 주제들로 채워볼 예정입니다.지난 번 포스팅인 경로 탐색처럼 한번만 연재하고 끝내지 않습니다.
React 나 Next.js 를 아예 모르는 사람들을 대상으로 글을 작성하지는 않습니다. 저와 비슷한 수준의 고민을 하고 계신 저연차 프론트엔드 개발자분들에게 조금이라도 도움이 되길 바랍니다.
Modal 이 무엇인지부터 짚고 넘어가겠습니다. Modal
은 기존의 브라우저 페이지 위에 새로운 윈도우 창이 아닌, 레이어를 까는 것을 말합니다. 아래와 같은 UI 예시가 Modal 의 예시입니다.
이러한 Modal 의 가장 흔한 구현 방식을 살펴보면 아래와 같은 특징을 지닙니다.
Modal Component
자체를 상위 컴포넌트에서 선언하고 렌더링 합니다.Modal Component
의 상태관리를 상위 컴포넌트에서 직접 제어 합니다.무슨 말인지 직접 코드로 살펴보도록 합시다. 서론에 밝혔듯, 이번 프로젝트에서는 MUI 를 사용중입니다. Dialog
는 MUI에서 Modal Component 의 일종입니다.
참고로 MUI 의
Dialog
나Modal
을 사용하면 여러모로 좋은 점과 불편한 점이 공존합니다.
우선은open
이라는 boolean props 를 통해 열고 닫음을 간단하게 제어할 수 있음을 알려둡니다.
// Layout.tsx
export function CustomerClientLayout() {
const [openModal, setOpenModal] = useState(false);
const [openDrawer, setOpenDrawer] = useState(false);
return (
<>
{/* 중략 */}
<ExampleDialog open={openModal} onClose={() => setOpenModal(false)} />
<MenuDrawer open={openDrawer} onClose={() => setOpenDrawer(false)} />
</>
);
}
위의 코드는 아주 정상적인 코드이고 아주 정상적으로 작동하는 코드입니다. 그러면 위에서 서술했던 사항들에 대해서 풀어서 이야기를 해보고자 합니다.
Modal Component
자체를 상위 컴포넌트에서 선언하고 렌더링 합니다.Layout.tsx
에서 ExmpaleDialog
를 선언하고 렌더링 한 모습을 볼 수 있습니다.&&
연산자를 써서 openModal
이 true 일때만 렌더링 하는 조건부 렌더링으로 조금 더 최적화를 시킬 수는 있겠지만, 어쨌든 선언과 렌더링은 필수입니다.Modal Component
의 상태관리를 상위 컴포넌트에서 직접 제어합니다.참고로
Drawer
도 Modal 의 일종입니다. 결국 화면 위에 렌더링 된다는 점에서는 동일하기 때문입니다.
가장 흔한 방식은 다음과 같은 문제점을 가지고 있습니다. 이 문제점을 일목 요연하고 에니메이션 효과까지 첨부한 효율적인 modal 관리 with React (1) 를 참고하면 더 좋습니다.
1. 추상화 수준을 맞추기 위해 Modal Component 내부에서 state 관리를 하고 싶은 경우
openModal(false)
라는 추상화 수준을 낮추는 작업을 해야만 했습니다. 상위 컴포넌트에서 해야하는 작업은 Dialog
를 눌렀을 때 비동기 작업만 하는 것입니다 const App = () => {
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>
<MyModal1 isOpen={isOpen1} />
<MyModal2 isOpen={isOpen2} />
<MyModal3 isOpen={isOpen3} />
<MyModal4 isOpen={isOpen4} />
)
}
const App = () => {
const [isOpen, setOpen] = useState(false);
const openModal = () => {
setOpen(true);
};
return (
<div className="App">
<button onClick={openModal}>모달 열기</button>
<ChildComponent openModal={openModal} />
<ChildComponent2 openModal={openModal} />
<ChildComponent3 openModal={openModal} />
<ChildComponent4 openModal={openModal} />
<MyModal isOpen={isOpen} />
</div>
);
};
사실 이 경우의 수 때문에 이 글을 작성했다 해도 과언이 아닙니다.
특히 React-Native 개발자 분들은 더더욱 공감을 할 거라고 생각합니다.
export function ExampleComponent() {
const [selectedRowId, setSelectedRowId] = useState<string | undefined>(
undefined,
);
const [openExchangeDialog, setOpenExchangeDialog] = useState(false);
return (
<>
<Stack spacing={1} direction="column" width="100%">
{visibleRows.map(row => (
<>
<TokenExchangeDialog
open={openExchangeDialog}
onClose={() => setOpenExchangeDialog(false)}
name={row.title}
/>
<TokenRow
key={row.id}
{...row}
onRowClick={() => {
setSelectedRowId(row.id);
}}
/>
</>
))}
</Stack>
</>
);
}
아무런 문제가 없어보이나 실상은 큰 문제가 있습니다.
1. 일단 모든 리스트에 중복되서 모달이 생겨난다. 즉 렌더링 과정에서 매우 큰 손실을 입게 됩니다.
2. 정상적으로 동작하지도 않는다. 왜냐하면 모든 모달이 동시에 open 하기 때문입니다.
이런 문제점을 해결하기 위해서 아래와 같이 필자도 울며 겨자먹기로 코드를 작성해 왔습니다.
export function ExampleComponent() {
const [selectedRowTitle, setSelectedRowTitle] = useState<string | undefined>(
undefined,
);
const [openExchangeDialog, setOpenExchangeDialog] = useState(false);
useEffect(() => {
if (selectedRowTitle === undefined) return;
setOpenExchangeDialog(true);
}, [selectedRowId]);
return (
<>
<Stack spacing={1} direction="column" width="100%">
{visibleRows.map(row => (
<TokenRow
key={row.id}
{...row}
onRowClick={() => {
setSelectedRowTitle(row.title);
}}
/>
</>
))}
</Stack>
<TokenExchangeDialog
open={openExchangeDialog}
onClose={() => setOpenExchangeDialog(false)}
name={selectedRowTitle}
/>
</>
);
}
useEffect
를 이용하여 더욱 코드를 디버깅 하기 어렵게 해버렸습니다. 다시 말해 가독성이 매우 떨어지는 코드입니다. 여러분들은 저 코드를 읽고나서 한번에 state 의 흐름이 읽혀지십니까?모든 문제의 근간은 부모 컴포넌트가 Modal
의 열고 닫음 state 를 직접 관리해서 문제가 생깁니다. 따라서 직접 state 를 관리하지 않게 된다면 다음과 같이 코드를 작성할 수 있습니다. (마지막 부분의 코드만 가져와 보았습니다.)
export function ExampleComponent() {
const {openModal} = useModal('TokenExchangeDialog');
return (
<>
<Stack spacing={1} direction="column" width="100%">
{visibleRows.map(row => (
<TokenRow
key={row.id}
{...row}
onRowClick={() => {
openModal({
title: row.title,
});
}}
/>
</>
))}
</Stack>
</>
);
}
이제 모든 문제가 풀렸습니다.
1. 추상화 수준이 말끔해졌습니다. 상위 컴포넌트에서는 더이상 열었는가? 닫았는가? 자체를 관리할 필요 없이
openModal()
closeModal()
openModal()
을 선언해버리면 됩니다.forwardRef
)
와 이거 되게 힘들어서 헤맸는데, 좋은 글이네요. 다음 글도 기대해볼게요