프로젝트에서 아래와 같은 레이아웃을 사용하는 사이드 페이지들이 있다.
페이지보다 더 앞에서 렌더링되어야 하기 때문에 모달을 렌더링 하듯이 usePortal을 사용해서 id="portal"인 DOM 요소의 하위 요소로 렌더링 되도록 했다.
그리고 SideBarHeader의 닫기 버튼은 공통으로 쓰이지만 SidePageLayout에 고정적으로 넣기는 어려웠다.
그 이유는, 페이지의 종류의 따라서 그 페이지의 타이틀이 적히기도 하고, 케밥 버튼이 추가되기도 했기 때문이다.
그래서 나는 합성 컴포넌트로 SidePageLayout을 구현하기로 마음 먹었다 !
합성 컴포넌트 (Composed Component) 란?
컴포넌트 기반 아키텍처 핵심 개념 중 하나로, 작은 단위의 컴포넌트를 조합해서 복잡한 컴포넌트를 구성하는 방식을 말한다.
합성 컴포넌트를 이용하면 재사용 가능한 작은 컴포넌트들을 조합해서 만들기 때문에, 각 컴포넌트들을 재사용할 수 있고 재사용이 가능해지면서 코드 중복을 줄어들고, 유지보수성이 향상된다.
내가 구현하려는 사이드 페이지들을 합성 컴포넌트로 구현하게 되면, 공통되는 부분은 공통적으로 가져가고 달리지는 부분만 각 각의 사이드 페이지들에서 children으로 넘겨주면 되기 때문에 앞서 말한 합성 컴포넌트의 장점을 가져갈 수 있다.
그럼 바로 가보자잇~!
아래는 구조를 보기 쉽게 정리한 컴포넌트 파일이다.
export const SidePageLayout = ({
handleClose,
children,
addStyle,
}: {
handleClose: () => void;
children: ReactNode;
addStyle?: string;
}) => {
const { isTrue: isAnimationClose, handleTrue: handleAnimationClose } = useToggle();
const sidePageRef = useRef<HTMLDivElement>(null);
// 애니메이션이 끝나기 전에 바로 portal에서 바로 요소를 삭제해버려서 setTimeout을
// 걸어서 애니메이션이 끝난 후 지워주도록 지연시켜주었다.
function handleClosing(){
handleAnimationClose();
setTimeout(()=>{
handleClose();
}, 500);
}
useOutsideClick(sidePageRef, handleClosing);
return (
<Portal>
<S.SidePageContainer
isClose={isAnimationClose}
ref={sidePageRef}
addStyle={addStyle}>
{children}
</S.SidePageContainer>
</Portal>
);
};
export const SidePageHeader = ({
handleClose,
children,
addStyle,
}: {
handleClose: () => void;
children?: ReactNode;
addStyle?: string;
}) => {
return (
<S.SidePageHeader addStyle={addStyle}>
<Button type="button" onClick={handleClose}>
<ArrowBackwardIcon width={22} height={22} />
</Button>
{children}
</S.SidePageHeader>
);
};
export const SidePageBody = ({ children, addStyle }: { children: ReactNode; addStyle?: string }) => {
return <S.SidePageBody addStyle={addStyle}>{children}</S.SidePageBody>;
};
export const SidePageContainer = styled.div<{ isClose: boolean; addStyle?: string }>`
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: 60%;
height: 100%;
background-color: ${theme.color.white};
box-shadow: 0rem 2rem 2rem 0rem rgba(90, 90, 90, 0.5);
display: grid;
grid-template-rows: 7.1rem 1fr;
grid-template-areas:
"a"
"b";
animation: ${({ isClose }) => (isClose ? slideOut : slideIn)} 0.5s forwards;
${({ addStyle }) => addStyle}
@media ${theme.device.tablet} {
width: 100%;
overflow-y: scroll;
${ScrollCustom};
}
@media ${theme.device.mobile} {
width: 100%;
overflow-y: scroll;
${ScrollCustom};
}
`;
export const SidePageHeader = styled.header<{ addStyle?: string }>`
padding: 1.5rem 2.3rem;
box-sizing: border-box;
grid-area: a;
border: 0.1rem solid ${theme.color.gray600};
display: flex;
justify-content: space-between;
align-items: center;
${({ addStyle }) => addStyle}
`;
export const SidePageBody = styled.div<{ addStyle?: string }>`
padding: 2.3rem;
grid-area: b;
${({ addStyle }) => addStyle}
`;
이렇게 정의를 해두었으니 바로 적용해보자 !
const CardDetail = () => {
return (
<SidePageLayout handleClose={handleClose}>
<SidePageHeader handleClosing={handleClose}>
<S.Button type="button" onClick={handleToggleMenu}>
{isOpenMenu && (
<EditingDashboard
handleClose={handleCloseMenu}
columnId={currentIdList.columnId}
cardId={currentIdList.cardId}
handleDetailClose={handleClose}
handleStartEdit={handleStartEdit}
/>
)}
<KebabIcon width={13} height={20} />
</S.Button>
</SidePageHeader>
<SidePageBody addStyle={S.cardDetailLayout}>
{isEditing ? (
<CreateCard initialData={cardDetail} currentIdList={currentIdList} handleClosePage={handleCloseEdit} />
) : (
<TodoDetailContent todoDetailData={cardDetail} addStyle={S.CardContentStyle} />
)}
<CommentSection commentList={cardComment.comments.reverse()} currentIdList={currentIdList} />
</SidePageBody>
</SidePageLayout>
);
};
이 사이드 페이지에는 Header에 케밥 버튼을 추가되어야하는 페이지여서, 버튼을 Header의 children으로 전달하여 사용해주었다.
스타일 컴포넌트도 거의 재사용하고, 스타일 관련해서만 추가 스타일을 주입해주었다.
export const cardDetailLayout = `
display: grid;
grid-template-columns: 60% 40%;
padding : 0;
@media ${theme.device.tablet}{
display : flex;
flex-direction : column;
}
@media ${theme.device.mobile}{
display : flex;
flex-direction : column;
}
`;
export const CardContentStyle = `
padding : 2.3rem;
border-right : 0.1rem solid ${theme.color.gray600};
@media ${theme.device.tablet}{
border-bottom : 0.1rem solid ${theme.color.gray600};
}
@media ${theme.device.mobile}{
border-bottom : 0.1rem solid ${theme.color.gray600};
}
`;
export const Button = styled.button`
position: relative;
cursor: pointer;
`;
그렇다면 다른 페이지에서는 어떻게 사용해야 할까?
const EditAccount = () => {
return (
<SidePageLayout handleClose={handleClose} addStyle={S.PageSetting}>
<SidePageHeader handleClosing={handleClose} addStyle={S.AddHeaderStyle}>
<S.PageTitleStyle>계정 관리</S.PageTitleStyle>
</SidePageHeader>
<S.Container>
<EditProfile userProfileData={userProfileData} />
<EditPassword />
</S.Container>
</SidePageLayout>
);
};
다른 페이지에서는 페이지 헤더의 타이틀이 추가되어야 해서 헤더를 저렇게 추가해주었다.
이렇게, 공통되지만 각 각의 차이점을 추가할 수 있어서 사이드 페이지를 합성 컴포넌트로 구현한 것은 잘 생각했던 것 같다.
합성 컴포넌트 사용 후기
이렇게 합성 컴포넌트를 활용해보니, 이전에 다른 프로젝트에서 고민했던 모달 구조에도 적용할 수 있다는 생각이 들었다. 그 당시 막혔던 부분은 모달에서 공통되는 레이아웃을 활용하면서, 각 각의 차이점도 반영하는 것이었다. 이 문제는 아마 지금과 같은 구조로 활용한다면 바로 해결될 것으로 보인다.
또 다른 문제로는 각 모달에서 사용되는 데이터들이 다 달라서 prop이 과하게 늘어나는 문제점도 해결해야 했다. 근데 그것 역시 children을 적극적으로 활용하는 이 구조로는 의외로 간단하게 해결될 문제다.
이전에 children을 적극적으로 활용하라는 이야기를 들은 적이 있다.
그때는 어떻게 활용하라는 건지 이해가 잘 되지 않았지만, 위와 같이 합성 컴포넌트를 활용하다보니 어떤 식으로 children을 활용해야 하는지 조금은 알겠다.
앞으로 비슷한 문제를 만났을 때 개선할 수 있는 하나의 방식을 알게되서 아주 기쁘다
오늘은 이만~
안뇽 !
'💡뚝딱뚝딱 만들어보자 ~! :)' 카테고리의 다른 글
a 태그냐 button 태그냐 그것이 문제로다 (polymorphic한 컴포넌트 만들기) (1) | 2024.11.28 |
---|---|
[EP.01] 상품소개서를 만들어보자 (3) | 2024.11.10 |
필요했던 걸 만들 수 있게 되었다면? 만들어보자 ! (with. 조아용 상품소개서) (0) | 2024.11.07 |
[타임세이버] 끝났다고 생각할 때가 시작이다 - 리팩토링 항목 리스트업 (1) | 2024.09.08 |
[타임세이버] Husky와 함께하는 React 프로젝트 세팅 (2) | 2024.08.16 |