들어가기에 앞서
https://www.patterns.dev/posts/compound-pattern 에 있는 내용을 바탕으로 정리한 포스팅임을 밝힙니다.
- 여러 컴포넌트들이 모여 하나의 동작을 할 수 있게 한다.
- select, dropdown, menu 컴포넌트에서 사용할 수 있다.
예제 코드) 사진 목록에 버튼을 추가하여 각각의 사진을 수정하거나 삭제할 수 있게 한다.
FlyOut 컴포넌트를 위해서 세 가지 구현이 필요하다.
- 토글 버튼과 메뉴 리스트를 포함한 Wrapper
- 메뉴를 토글할 수 있는 Toggle 버튼
- 메뉴를 포함한 List 컴포넌트
React의 Context API와 컴파운드 패턴을 활용해 예제를 구현해 보자.
Step 1) FlyOut 컴포넌트
- 토글 여부에 대한 상태를 포함하고, 자식 컴포넌트들이 받게 될 토글 값을 가진 FlyoutProvider 컴포넌트를 return 한다.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
const providerValue = { open, toggle };
return (
<FlyOutContext.Provider value={providerValue}>
{props.children}
</FlyOutContext.Provider>
)
}
Step 2) Toggle 컴포넌트
- 토글 컴포넌트는 사용자가 토글 버튼을 눌렀을 때 나타날 메뉴를 렌더링한다.
function Toggle() {
const { open, toggle } = useContext(FlyOutContext)
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
Toggle 컴포넌트가 FlyOutContext Provider에 접근할 수 있으려면 해당 컴포넌트는 FlyOut의 자식 컴포넌트여야 한다. 단순히 자식 컴포넌트로 렌더링되게 할 수도 있지만 여기서는 FlyOut 컴포넌트의 static property로 만들어 보자.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
)
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
FlyOut.Toggle = Toggle;
이렇게 하면 FlyOut 컴포넌트를 사용할 때 토글 버튼이 필요한 경우에도 그냥 FlyOut 컴포넌트만 import해서 사용할 수 있다.
// 사용부
import { FlyOut } from './FlyOut';
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
)
}
Step 3) List 컴포넌트
- 토글 버튼을 클릭했을 때 보이는 메뉴를 렌더링한다. Toggle 컴포넌트와 마찬가지로 컨텍스트를 통해 상태를 가져와 처리할 수 있다.
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>
}
function Item({ children }) {
return <li>{children}</li>
}
List 컴포넌트는 컨텍스트의 open 여부에 따라 메뉴를 보여주거나 감춘다. List와 Item도 FlyOut 컴포넌트의 static property로 추가해 보자.
const FlyOutContext = createContext()
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
)
}
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
)
}
function List({ children }) {
const { open } = useContext(FlyOutContext);
return open && <ul>{children}</ul>
}
function Item({ children }) {
return <li>{children}</li>
}
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;
지금까지 구현한 것을 모두 FlyOut 컴포넌트만 import 해서 사용할 수 있다. 예제에서는 '수정' 메뉴와 '삭제' 메뉴를 제공해야 하므로 FlyOut.Item을 두 개 가진 FlyOut.List 컴포넌트를 사용하면 된다.
import { FlyOut } from './FlyOut'
export default function FlyOutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
<FlyOut.List>
<FlyOut.Item>Edit</FlyOut.Item>
<FlyOut.Item>Delete</FlyOut.Item>
</FlyOut.List>
</FlyOut>
)
}
FlyOutMenu 컴포넌트 자체는 아무런 상태도 가지고 있지 않다.
컴파운드 패턴은 컴포넌트 라이브러리를 만들 때 유용하게 사용할 수 있다. 실제로 회사 프로젝트에서 ant design 라이브러리를 사용하고 있는데, 굉장히 자주 볼 수 있는 패턴이다. static property를 구조분해할당으로 미리 꺼내서 사용하는 경우가 많다. 내부 작동 원리는 숨겨져 있지만 어떻게 동작하는지 바로 유추할 수 있어서 가독성이 굉장히 좋다.
import { FlyOut } from './FlyOut'
const { Toggle, List, Item } = FlyOut;
export default function FlyOutMenu() {
return (
<FlyOut>
<Toggle />
<List>
<Item>Edit</Item>
<Item>Delete</Item>
</List>
</FlyOut>
)
}
위의 예제 코드를 활용해 모달의 open과 toggle에 대한 상태를 사용부에서는 노출하지 않는 모달 컴포넌트를 컴파운드 패턴으로 작성해 보았다. 예제 코드에서 Toggle과 List, Item을 static property로 가진 것처럼, 모달을 오픈할 수 있는 Button과 모달의 Wrapper를 static property로 작성했다.
import { useState, createContext, useContext } from 'react';
import { Modal as AntdModal, Button as AntdButton } from 'antd';
export const ModalContext = createContext({
isOpen: false,
setIsOpen: (value: boolean) => {},
});
type Props = {
children: React.ReactNode;
};
export function Modal({ children }: Props) {
const [isOpen, setIsOpen] = useState(false);
const providerValue = { isOpen, setIsOpen };
return <ModalContext.Provider value={providerValue}>{children}</ModalContext.Provider>;
}
function Button({ children }: Props) {
const { setIsOpen } = useContext(ModalContext);
return <AntdButton onClick={() => setIsOpen(true)}>{children}</AntdButton>;
}
type WrapperProps = {
title: string;
[property: string]: any; // all other props
};
function Wrapper({ title, children, ...rest }: WrapperProps & Props) {
const { isOpen, setIsOpen } = useContext(ModalContext);
return (
<AntdModal title={title} open={isOpen} onCancel={() => setIsOpen(false)} {...rest}>
{children}
</AntdModal>
);
}
Modal.Button = Button;
Modal.Wrapper = Wrapper;
이렇게 작성한 모달 컴포넌트를 사용부에서는 다음과 같이 호출한다. 모달을 구성할 내용이 바뀔 때마다 Inner의 컨텐츠만 바꿔서 사용할 수 있다.
import { ConfigProvider, Calendar } from 'antd';
import type { Dayjs } from 'dayjs';
import koKR from 'antd/locale/ko_KR';
import { Modal } from '@/components/ui/Modal';
import { disallowPastDates } from '@/utils/dates';
function Inner() {
const onSelect = (date: Dayjs) => {
console.log(date.format('YYYY-MM-DD'));
};
return (
<ConfigProvider locale={koKR}>
<Calendar fullscreen={false} onSelect={onSelect} disabledDate={disallowPastDates} />
</ConfigProvider>
);
}
export default function DueDateModal() {
return (
<Modal>
<Modal.Button>추가</Modal.Button>
<Modal.Wrapper title="마감일 선택" centered width={360} footer={null}>
<Inner />
</Modal.Wrapper>
</Modal>
);
}
장점
- 컴파운드 패턴은 동작 구현에 필요한 상태를 내부적으로 가지고 있고, 사용부에서는 해당 로직이 드러나지 않는다.
- 자식 컴포넌트들을 일일이 import 할 필요가 없다.
- 가독성이 좋아진다.
'배워서 남 주자' 카테고리의 다른 글
[TypeScript] VS Code에서 interface 형태를 바로 확인하는 방법 (0) | 2023.11.25 |
---|---|
디자인 패턴 - Presentational/Container 패턴 (0) | 2023.11.22 |
[TypeScript] React에서 rest props의 타입 지정은 어떻게 할 수 있을까? (1) | 2023.11.01 |
디자인 패턴 - Flyweight 패턴 (2) | 2023.10.29 |
디자인 패턴 - Mixin 패턴 (1) | 2023.10.26 |