TIL 105 - compound pattern, Accordion 컴포넌트

김영현·2024년 6월 20일
0

TIL

목록 보기
115/129

Compound pattern

가끔 특정 컴포넌트끼리 상태를 공유해야하는 일이 생긴다.
응집도가 높은 컴포넌트가 되는 것인데, 이를 하나의 모듈로 묶어서 사용하는 패턴이 바로 Compound pattern이다.

예시) Flyout

참고) 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.Providerchildren으로 넘겨줘서 사용할 수도 있지만, 아래와 같은 코드도 가능하다.

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>
  );
}

context api 없이 사용하기

리액트에서는 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 컴포넌트를 만들어보자

진행중인 토이프로젝트가있는데, 네비게이션바에 Accordion 메뉴를 넣고싶어졌다.

출처 : https://dribbble.com/shots/6462890-Animated-CSS-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컴포넌트를 사용할때, 제목이 하나 존재하고 여러 내용(메뉴)가 쫘르륵 딸려나온다.
그러기위해선 먼저 내부 상태공유가 필요할 것이다.

AccordionContextProvider.tsx

내부상태공유를 위해 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를 사용할 컴포넌트를 만들어보자.

Accordion.tsx

호출시 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, AccordionContent.tsx

//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컴포넌트의 프로퍼티로 AccordionSummaryAccordionContent를 넣어주면 비로소 끝난다.

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을 도입하며 이해도가 한층 높아져서 좋았다. 뿐만 아니라 고도화까지 진행할 수 있을 것 같다.
역시 이론도 중요하지만 실전이 굉장히 중요하다!

profile
모르는 것을 모른다고 하기

0개의 댓글