가끔 특정 컴포넌트끼리 상태를 공유해야하는 일이 생긴다.
응집도가 높은 컴포넌트가 되는 것인데, 이를 하나의 모듈로 묶어서 사용하는 패턴이 바로 Compound pattern이다.
참고) flytout
란, dropdown
메뉴를 다르게 부르는 명칭이다.
예제 코드의 출처: https://www.patterns.dev/react/compound-pattern/
Flyout
컴포넌트 내에는 기본적으로 세개의 컴포넌트가 존재한다.
Flyout
: 래퍼. 토글버튼과 리스트를 포함함.Toggle
: 토글버튼. 리스트를 토글한다.List
: 메뉴 리스트이렇게 각 컴포넌트가 높은 응집도 안에서 상호작용이 필요할 경우, context api와 함께 compound pattern을 활용할 수 있다.
const FlyOutContext = createContext();
function FlyOut(props) {
const [open, toggle] = useState(false);
return (
<FlyOutContext.Provider value={{ open, toggle }}>
{props.children}
</FlyOutContext.Provider>
);
}
위 코드는 open, toggle
상태를 자식에게 전달하는 구조를 context api로 구성하였다.
Toggle
컴포넌트에서는 이렇게 사용하겠지?
function Toggle() {
const { open, toggle } = useContext(FlyOutContext);
return (
<div onClick={() => toggle(!open)}>
<Icon />
</div>
);
}
단순히 FlyOutContext.Provider
의 children
으로 넘겨줘서 사용할 수도 있지만, 아래와 같은 코드도 가능하다.
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; //이 부분
컴포넌트에 컴포넌트를 멤버로 넣어주는 코드는 JSX의 마법이 아니다. 함수는 일급 객체다. 객체는 프로퍼티를 가질 수 있다. 즉 FlyOut
컴포넌트의 멤버로 Toggle
컴포넌트를 가질 수 있다.
=> 응집도 높은 컴포넌트가 된다.
프로퍼티가 된 Toggle
컴포넌트를 사용할땐 아래처럼 사용할 수 있다.
import React from "react";
import { FlyOut } from "./FlyOut";
export default function FlyoutMenu() {
return (
<FlyOut>
<FlyOut.Toggle />
</FlyOut>
);
}
List
컴포넌트도 만들어본다. 이 역시 FlyOut
컴포넌트의 프로퍼티로 넣어준다.
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;
완성된 코드의 모습은 아래와 같다. 사용자가 상태를 선언할 필요가 없게 되었다.
import React from "react";
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>
);
}
리액트에서는 children
에 관련된 메서드가 몇 개 존재한다. 예전에 공부할때 잠깐 사용해봤었다. 그땐 왜 사용하는지 크게 와닿지 않았지만, 지금은 조금이나마 알 것 같다.
Compound pattern의 핵심은 응집도와 상태 선언이 필요없다는 장점이었다.
이중 상태 선언을 context api로 위임했던 것이었는데, props로 넘겨줄 수도 있다.
export const ToggleComponentType = (<Toggle />).type;
export const ListComponentType = (<List />).type;
export function FlyOut(props) {
const [open, toggle] = React.useState(false);
return (
<div>
{React.Children.map(props.children, (child) =>{
if(child.type === ToggleComponentType){
return React.cloneElement(child, { open, toggle }
}
if(child.type === ListComponentType){
return React.cloneElement(child, { open }
}
return console.error('자식으로 올 수 있는 컴포넌트에 제한이 있습니다')
})
)}
</div>
);
}
이렇게 코드를 작성하게되면, props를 통해 open, toggle
에 액세스할 수 있게된다.
아니 근데, 원래 자식컴포넌트는 props를 통해 데이터를 전달해주지 않나요?
=> 위와같이 children
관련 메서드를 활용하여 props를 전달해줄 경우, 컴포넌트를 사용할 때 자식 컴포넌트의 props를 신경쓰지 않아도 됨
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>
);
}
위처럼 List, Toggle컴포넌트에 어떤 props를 넘겨줄지 사용자가 몰라도 된다는 장점이 있다.
하지만 장점만 있는 건 아니다.
cloneElement
메서드는 얕은병합을 사용하기에 이미 존재하는 props네이밍과 충돌할 수 있다.
진행중인 토이프로젝트가있는데, 네비게이션바에 Accordion 메뉴를 넣고싶어졌다.
위와 같은 방식인데, 응집도가 높아보여서 compound pattern을 활용하기로 했다.
<Accordion>
<Accordion.Summary>요약 제목</Accordion.Summary>
<Accordion.Content>내용1</Accordion.Content>
<Accordion.Content>내용2</Accordion.Content>
<Accordion.Content>내용3</Accordion.Content>
</Accordion>
단순한 목표는 위와같은 모양을 만드는 것이다. accordion컴포넌트를 사용할때, 제목이 하나 존재하고 여러 내용(메뉴)가 쫘르륵 딸려나온다.
그러기위해선 먼저 내부 상태공유가 필요할 것이다.
내부상태공유를 위해 context api를 사용하였다.
const AccordionContext = createContext<AccordionContextProps>({});
const AccordionContextProvider = ({
children,
defaultExpanded = false,
}: AccordionContextProviderProps) => {
const [expandedState, setExpandedState] = useState(defaultExpanded);
const iconRef = useRef<HTMLElement>(null);
const onToggle = (e: SyntheticEvent) => {
setExpandedState((prev) => !prev);
if (iconRef.current) {
const deg = expandedState ? "" : "rotate(-90deg)";
iconRef.current.style.transform = deg;
}
};
return (
<AccordionContext.Provider
value={{ iconRef, expanded: expandedState, onToggle }}
>
{children}
</AccordionContext.Provider>
);
};
export const useAccordionContext = () => useContext(AccordionContext);
export default AccordionContextProvider;
iconRef
: 메뉴 토글시 아이콘을 회전시키기 위하여 useRef
를 활용함.expanded
: 메뉴가 펼쳐진 상태onToggle
: 메뉴 토글시마다 호출되는 함수다. 이전상태에 not
연산자를 사용해주고, 메뉴 아이콘을 회전시켜준다.이렇게 만든 provider를 사용할 컴포넌트를 만들어보자.
호출시 wrapper역할을 하는 컴포넌트다. provider로 감싸져 있어야하고, props도 받아와야 한다.
interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
defaultExpanded?: boolean;
}
const Accordion = ({
children,
defaultExpanded = false,
...rest
}: AccordionProps) => {
return (
<AccordionContextProvider
defaultExpanded={defaultExpanded}
>
<div className="text-white p-1" {...rest}>{children}</div>
</AccordionContextProvider>
);
};
여기서 뽀인트는 ...rest
파라미터다. 유저가 래퍼의 스타일을 수정하고 싶을 수 있으니, HTMLAttributes<HTMLDivElement>
인터페이스를 상속받아 만든 인터페이스를 사용하면, 타입 오류도 나지 않고 외부에서 입력할때 div
에 넘겨주던 프로퍼티를 넘겨줄 수 있다.
이제 Wrapper역할을 컴포넌트는 만들었으니, 제목과 내용을 채워넣어보자.
//AccordionSummary.tsx
const AccordionSummary = ({ children, icon = "◁" }: AccordionSummaryProps) => {
const { expanded, iconRef, onToggle } = useAccordionContext();
return (
<summary
onClick={(e) => {
onToggle?.(e);
}}
className="cursor-pointer list-none flex justify-between"
>
{children}
<span ref={iconRef}>{icon}</span>
</summary>
);
};
//AccordionContent.tsx
const AccordionContent = ({ children }: { children?: ReactNode }) => {
const { expanded } = useAccordionContext();
return expanded && <div>{children}</div>;
};
<summary/>
태그가 존재하길래 한번 갖다써봤다..ㅎㅎ
먼저 AccordionSummary
컴포넌트는 <summary/>
태그를 클릭시 onToggle
을 호출한다. onToggle
함수는 provider 부분에서 설명했듯, 이전 상태에 not
연산자를 사용하고 메뉴 아이콘을 회전시켜준다.
그리고 AccordionContent
컴포넌트는 단순히 exapnded
상태에 따라 조건부 렌더링을 한다.
Accordion
컴포넌트의 프로퍼티로 AccordionSummary
와 AccordionContent
를 넣어주면 비로소 끝난다.
const Accordion = ({
children,
defaultExpanded = false,
...rest
}: AccordionProps) => {
return (
<AccordionContextProvider
defaultExpanded={defaultExpanded}
>
<div className="text-white p-1" {...rest}>{children}</div>
</AccordionContextProvider>
);
};
Accordion.Summary = AccordionSummary;
Accordion.Content = AccordionContent;
//사용할 때
<Accordion>
<Accordion.Summary>메뉴2</Accordion.Summary>
<Accordion.Content>이동1</Accordion.Content>
<Accordion.Content>이동2</Accordion.Content>
<Accordion.Content>이동3</Accordion.Content>
</Accordion>
단순하지만, 잘 작동한다. 하지만 개선의 여지가 남았다.
아래와 같은 Accordion
컴포넌트가 있다고 해보자.
<Accordion>
<Accordion.Summary>메뉴1</Accordion.Summary>
<Accordion.Content>이동1</Accordion.Content>
<Accordion.Content>이동2</Accordion.Content>
<Accordion.Content>이동3</Accordion.Content>
</Accordion>
<Accordion>
<Accordion.Summary>메뉴2</Accordion.Summary>
<Accordion.Content>이동4</Accordion.Content>
<Accordion.Content>이동5</Accordion.Content>
<Accordion.Content>이동6</Accordion.Content>
</Accordion>
사용자는 메뉴1
을 눌렀을때 메뉴1
이 펼쳐지고 다른 Accordion
컴포넌트가 접히길 원한다.
하지만 지금까지 작성된 코드로는 위와 같은 방식을 구현할 수 없다.
왜냐하면 상태를 내부에서 관리하기때문이다. 그렇다면 내부에서 관리하는 상태 대신 외부에서 관리하는 상태를 사용하게 하면 어떨까?
즉 제어 컴포넌트방식을 사용하면 어떨까?
다음 편에서는 이번에 만든 Accordion
컴포넌트를 제어 컴포넌트방식으로도 사용할 수 있게 리팩토링을 해보겠습니다.
예전에는 뭔가 이름이 멋있어보이고 재사용이 가능하다길래 계속 찾아봤던 패턴이다. 하지만 지식이 미천했던 당시 수준에 이해하기란 어려웠고 그저 아~ 그냥 html의 select방식이구나? 하며 대충 이해하고 넘어갔다.
이번 토이프로젝트에 compound pattern을 도입하며 이해도가 한층 높아져서 좋았다. 뿐만 아니라 고도화까지 진행할 수 있을 것 같다.
역시 이론도 중요하지만 실전이 굉장히 중요하다!