[React] Headless Component 패턴으로 Accordion 만들기

배준형·2024년 1월 10일
5
post-thumbnail

서문

기획에 따라 웹 페이지를 구성할 때 다양한 컴포넌트를 만들어 사용하고 있습니다. 여기에 이곳저곳 공통으로 사용할 수 있는 컴포넌트라면 공통 컴포넌트로 분류하여 작업하고 있는데, 초기엔 간단한 기능만 필요로 하다가 점점 기능, 디자인이 추가되면서 복잡한 컴포넌트가 되어 갑니다.

이전 회사에서는 Input 컴포넌트에만 약 20개 정도의 Props를 사용할 만큼 기능, 디자인 추가에 따라 공통 컴포넌트도 점점 거대해져 갔는데요.

초기 컴포넌트 모습

interface Props {
  value: string;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = ({ value, onChange }: Props) => {
  return <input value={value} onChange={onChange} />;
};

시간이 흐른 뒤 다시 본 컴포넌트 모습

export interface Props {
  size?: number | string;
  radius?: number | string;
  icon?: React.ReactNode;
  className?: string;
  required?: boolean;
  disabled?: boolean;
  label?: string;
  placeholder?: stirng;
  onChange: React.ChangeEventHandler<HTMLInputElement>;
}

const Input = ({
  ...
}: Props) => {
  // ...
}

실제론 위 사항보다 더 컴포넌트가 커지게 됩니다. 이런 경우는 Input 뿐만 아니라 Atomic한 컴포넌트에서 충분히 일어날 수 있는 상황입니다. 이런 사항을 방지하기 위해 Headless Component 패턴으로 컴포넌트를 구성할 수 있는데요. Headless Component 패턴으로 Accordion 컴포넌트를 만드는 방법을 알아보겠습니다.


Headless Component ?

Headless Component는 로직만 담당하고 스타일은 없는 컴포넌트를 말합니다. 스타일 없이 기능만 담당하기에 Accordion 컴포넌트를 만든다면 Header에 해당하는 영역을 클릭하면 숨겨진 Content가 나오는 기능만 담당하고, Tab 컴포넌트라면 Title에 해당하는 영역을 클릭하면 해당 영역으로 보여주고 싶은 Content를 보여주는 기능만 동작하고, 나머지 Style은 사용하는 곳에서 적용하여 사용하면 될 것입니다.

이런 컴포넌트 방식은 왜 필요할까요?

잡다에서 사용하는 아코디언 컴포넌트를 살펴보겠습니다.

3가지 아코디언 형태의 UI가 보이는데요. 3가지 모두 디자인이 다릅니다. 이럴 때 기능 역할을 하는 Accordion 컴포넌트를 만들어서 Header 영역을 눌렀을 때 Content가 나오는 기능을 담당하고, 이를 사용하는 곳에서 스타일만 지정하면 될 것입니다.

Headless Component를 구현하는 방법은 여러 가지가 있는데요. 그중에서 저는 Compound Pattern을 활용하여 Accordion 컴포넌트를 만드는 방법을 알아보겠습니다.

그 외에 더 다양한 Component Pattern이 궁금하신 분들은 아래 글을 참고해 주시면 감사하겠습니다.

https://javascript.plainenglish.io/5-advanced-react-patterns-a6b7624267a6


Accordion 컴포넌트 만들기

첫 번째로 Accordion 컴포넌트를 사용할 때 어떤 방식으로 사용할지 구상해 놓으면 컴포넌트를 짜기에 비교적 수월해지는 것 같습니다.

제가 생각한 최종 모습은 아래와 같습니다.

<Accordion>
  <Accordion.Header>헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Content>콘텐츠, Header 영역을 눌러야 영역이 보임</Accordion.Content>
</Accordion>

onClick, css 스타일링 등 없이 마크업 구조를 위와 같이 가져가면 동작했으면 좋겠습니다. Accordion, Header, Content 컴포넌트를 하나씩 살펴보겠습니다.

※ 시맨틱 마크업, 변수명, 함수명 등은 참고만 부탁드립니다.🙏


Accordion.tsx

import React, { createContext, useState } from 'react';

interface AccordionContextProps {
  visible: boolean;
  toggle: () => void;
}

const AccordionContext = createContext<AccordionContextProps>({
  visible: false,
  toggle: () => {},
});

export const useAccordion = () => {
  const context = React.useContext(AccordionContext);
  if (!context) {
    throw new Error('Header, Content 컴포넌트는 Accordion 컴포넌트 내부에서 사용되어야 합니다.');
  }
  return context;
};

interface Props {
  children: React.ReactNode;
}

const Accordion = ({ children }: Props) => {
  const [visible, setVisible] = useState(false);

  const toggle = () => {
    setVisible((prev) => !prev);
  };

  return <AccordionContext.Provider value={{ visible, toggle }}>{children}</AccordionContext.Provider>;
};

export default Accordion;
  • Accordion 컴포넌트는 visible, toggle 값을 Context로 갖는 컴포넌트.
  • Accordion 컴포넌트 하위 요소에서 useContext를 통해 visible, toggle 값을 공유할 수 있음.

Header.tsx

import { useAccordion } from './Accordion';

interface Props {
  children: React.ReactNode;
  className?: string;
}

const Header = ({ children, className }: Props) => {
  const { toggle } = useAccordion();

  return (
    <div onClick={toggle} className={className}>
      {children}
    </div>
  );
};

export default Header;
  • Accordion 컴포넌트에서 생성한 Context로 toggle 함수를 가져와 onClick props로 전달

Content.tsx

import { useAccordion } from './Accordion';

interface Props {
  children: React.ReactNode;
  className?: string;
}

const Content = ({ children, className }: Props) => {
  const { visible } = useAccordion();

  return visible && <div className={className}>{children}</div>;
};

export default Content;
  • 여기선 visible 변수를 가져와 visible 값이 true인 경우에만 content를 보이도록 적용

여기까지만 작성하면 기본적인 동작은 완성입니다.

import React, { createContext, useState } from 'react';
import Header from './Header';
import Content from './Content';

// ...

const Accordion = ({ children }: Props) => {
  // ...

  return <AccordionContext.Provider value={{ visible, toggle }}>{children}</AccordionContext.Provider>;
};

export default Accordion;

Accordion.Header = Header; // Header, Content 컴포넌트를 여기서 추가해줍니다.
Accordion.Content = Content;
<Accordion>
  <Accordion.Header>헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Content>콘텐츠, Header 영역을 눌러야 영역이 보임</Accordion.Content>
</Accordion>


Animation 추가하기

지금까지는 Header 영역을 클릭하면 그대로 Content 영역이 조건부 렌더링 되면서 즉각적으로 보입니다. 여기서 애니메이션을 추가하여 스르륵 열리도록 추가해 보겠습니다.

※ 일부 애니메이션이 동작하기 위해 CSS는 필요합니다.

최종 모습은 아래와 같습니다.

<Accordion>
  <Accordion.Header>헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Animation>
    <Accordion.Content>콘텐츠, Header 영역을 눌러야 영역이 보임</Accordion.Content>
  </Accordion.Animation>
</Accordion>

AccordionAnimation.tsx

import React, { useEffect } from 'react';
import { useAccordion } from './Accordion';

interface Props {
  children: React.ReactNode;
  duration?: number;
  className?: string;
}

const AnimationAccordion = ({ children, duration = 300, className }: Props) => {
  const { visible } = useAccordion();
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [maxHeight, setMaxHeight] = React.useState(0);

  const childrenElement = React.cloneElement(children as React.ReactElement, { contentRef, duration });

  const handleHeight = () => {
    if (!contentRef.current) {
      setMaxHeight(0);
      return;
    }
    setMaxHeight(visible ? contentRef.current.clientHeight : 0);
  };

  useEffect(() => {
    requestAnimationFrame(handleHeight);
  }, [visible]);

  return (
    <div className={cx('animate', className)} style={{ maxHeight }}>
      {childrenElement}
    </div>
  );
};

export default AnimationAccordion;
/* CSS */
.animate {
  max-height: 0;
  transition: max-height 0.3s ease-out;
  overflow: hidden;
}
  • 자식 요소에 위치한 Content 영역의 크기를 활용하기 위해 useRef hook을 사용.
  • React.cloneElement 메서드를 이용하여 ref, duration 값을 props로 넘겨줍니다.
  • requestAnimationFrame 메서드를 호출하여 브라우저에게 애니메이션을 알리고 다음 리페인트 전에 업데이트하도록 합니다.

Content.tsx

import { useAccordion } from '@components/_atoms/Accordion';
import useTransitionStatus from '@utils/hooks/useTransitionStatus';

interface Props {
  children: React.ReactNode;
  className?: string;
  contentRef?: React.MutableRefObject<HTMLDivElement>;
  duration?: number;
}

const Content = ({ children, contentRef, duration, className }: Props) => {
  const { visible } = useAccordion();

  return (
    visible && (
      <div ref={contentRef} className={className}>
        {children}
      </div>
    )
  );
};

export default Content;
  • 여기서 ref를 받아서 연결해 줍니다.

여기까지 완성한 후 결과를 확인해 보면 아래와 같습니다.

<Accordion>
  <Accordion.Header>헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Animation>
    <Accordion.Content>콘텐츠, Header 영역을 눌러야 영역이 보임</Accordion.Content>
  </Accordion.Animation>
</Accordion>

  • 펼쳐질 땐 Animation이 정상 동작함.
  • 접힐 땐 바로 사라짐.

펼쳐질 때는 애니메이션이 동작하지만 접힐 땐 애니메이션 없이 접혀집니다. 그 이유는 visible && <div /> 형태로 컴포넌트를 작성해서 visible이 false가 될 때 바로 렌더링 시키지 않기 때문인데요. 저는 이를 해결하기 위해 아래 hook을 만들었습니다.


useTransitionStatus.ts

import { useEffect, useRef, useState } from 'react';

export type TransitionStatus = 'entered' | 'exited' | 'entering' | 'exiting' | 'pre-exiting' | 'pre-entering';

const useTransitionStatus = (visible: boolean, duration = 0) => {
  const [status, setStatus] = useState<TransitionStatus>(visible ? 'entered' : 'exited');
  const timeoutRef = useRef<number>(-1);

  useEffect(() => {
    setStatus(visible ? 'pre-entering' : 'pre-exiting');

    const preTimeout = window.setTimeout(() => {
      setStatus(visible ? 'entering' : 'exiting');
    }, 10);

    timeoutRef.current = window.setTimeout(() => {
      window.clearTimeout(preTimeout);
      setStatus(visible ? 'entered' : 'exited');
    }, duration);

    return () => clearTimeout(timeoutRef.current);
  }, [visible, duration]);

  return status;
};

export default useTransitionStatus;
  • status: pre-entering / entering / entered 형태의 3단계로 나눠서 들어갈 때, 나갈 때를 나눕니다.
  • status가 exited 일 때는 완전히 끝난 것이고, 그 외의 status는 나가거나 들어가는 중인 상태를 나타냅니다.

이 Hook은 visible 값이 true, false로 바뀜에 따라 entered, exited 상태를 갖는 문자열을 반환하는 역할을 수행합니다. 이렇게 되면 visible 값이 false로 변경되었더라도 status는 전달 인자로 넘겨준 {duration}ms 만큼 후에 exited로 변경됩니다. 그러면 애니메이션이 완전히 끝난 후 동작을 컨트롤할 수 있겠죠.

해당 코드를 적용시켜 보겠습니다.


Content.tsx

import { useAccordion } from '@components/_atoms/Accordion';
import useTransitionStatus from '@utils/hooks/useTransitionStatus';

interface Props {
  children: React.ReactNode;
  contentRef?: React.MutableRefObject<HTMLDivElement>;
  duration?: number;
  className?: string;
}

const Content = ({ children, contentRef, duration, className }: Props) => {
  const { visible } = useAccordion();
  const status = useTransitionStatus(visible, duration);

  if (duration && duration > 0) {
    const animateStyle: React.CSSProperties = {
      visibility: status === 'exited' ? 'hidden' : 'visible',
      pointerEvents: status === 'exited' ? 'none' : 'auto',
    };
    return (
      <div ref={contentRef} style={animateStyle} className={className}>
        {children}
      </div>
    );
  }

  return (
    visible && (
      <div ref={contentRef} className={className}>
        {children}
      </div>
    )
  );
};

export default Content;
  • 부모 요소가 Animation 이라면 ref, duration을 부모로부터 받아올 것이므로 그에 대한 처리를 해줍니다.
  • ref가 연결된 다음 그 clientHeight 값을 측정하려면 ref 요소가 렌더링 되어있는 상태여야 합니다. 이에 따라 visibility, pointer-events 속성을 부여하여 보이지 않도록 처리해 줍니다.

결과

이제 들어갈 때도 애니메이션이 적용되었습니다. 여기까지 작성된 코드로 대부분 Accordion이 필요한 곳에 활용할 수 있을 것입니다.


Render Props를 활용하기

한 가지 아쉬운 점은 Accordion 자체적으로 visible state 값을 갖기 때문에 이를 외부에서 컨트롤할 수 없게 되고, Accordion.Header에 visible 값에 따라 달라지는 화살표 아이콘을 추가해야 한다면 지금까지의 컴포넌트로는 불가능합니다.

여기에 Render Props를 활용하면 위 같은 현상을 해결할 수 있습니다.

※ Render Props란?
컴포넌트의 prop으로 함수를 전달받고, 이 함수의 return 값을 사용하여 렌더링 하는 기술입니다.
자세한 내용은 아래 URL 첨부로 대체합니다.

https://react-etc.vlpt.us/04.render-prop.html


Accordion.tsx

interface Props {
  children: React.ReactNode | (({ visible }: { visible: boolean }) => React.ReactNode);
}

const Accordion = ({ children }: Props) => {
  // ...

  return (
    <AccordionContext.Provider value={{ visible, toggle }}>
      {typeof children === 'function' ? children({ visible }) : children}
    </AccordionContext.Provider>
  );
};

export default Accordion;
  • children Props를 함수 형태로도 받아올 수 있도록 타입을 수정합니다.
  • 만약 children이 함수 타입인 경우 visible 값을 전달 인자로 넣어줍니다.

사용할 때

<Accordion>
  {({ visible }) => (
    <>
      <Accordion.Header>
        헤더, 누르면 아래 Content 영역이 나타남
        {visible ? <Icon name="arrowBottomLight" /> : <Icon name="arrowTopLight" />}
      </Accordion.Header>
      <Accordion.Animation>
        <Accordion.Content>
          <ul>
            <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
            <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
            <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
            <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
          </ul>
        </Accordion.Content>
      </Accordion.Animation>
    </>
  )}
</Accordion>

이제 visible 값을 render props 형태로 넘겨서 외부에서도 컨트롤할 수 있게 되었습니다.


중첩된 Accordion 구현하기

여기까지 했으면 중첩되지 않는 Accordion은 의도한 대로 사용하면 됩니다. 다만, 아코디언이 중첩된 상태에 있다면 어떻게 될까요?

<Accordion>
  <Accordion.Header>헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Animation>
    <Accordion.Content>
      콘텐츠, Header 영역을 눌러야 영역이 보임
      <Accordion>
        <Accordion.Header>두 번째 영역</Accordion.Header>
        <Accordion.Animation>
          <Accordion.Content>두 번째 콘텐츠, Header 영역을 눌러야 영역이 보임</Accordion.Content>
        </Accordion.Animation>
      </Accordion>
    </Accordion.Content>
  </Accordion.Animation>
</Accordion>

각각의 Accordion은 하나의 Context를 공유하고, 중첩되어 있는 Accordion이 있다면 둘의 Context는 공유되지 않습니다. 하나의 Accordion만 컨트롤이 가능한 것인데, 이에 따라 중첩된 Accordion이 펼쳐져서 크기를 갖더라도 최상단 Accordion에서는 그것을 파악하기가 어려워집니다.

그럼 중첩된 Accordion을 사용하기 위한 구조를 잡아보겠습니다.


Accordion.tsx

type AccordionType = Record<string, { height?: number; visible: boolean }>;

interface AccordionContextProps {
  toggle: (id: string) => void;
  accordions: AccordionType; // 추가
  registerAccordion: (key: string, height?: number) => void; // 추가
}

const AccordionContext = createContext<AccordionContextProps>({
  toggle: () => {},
  accordions: {},
  registerAccordion: () => {},
});

const Accordion = ({ children }: Props) => {
  // ...

  const [accordions, setAccordions] = useState<Record<string, { height?: number; visible: boolean }>>({});

  const registerAccordion = useCallback((key: string, height?: number) => {
    setAccordions((prev) => ({ ...prev, [key]: { height, visible: prev[key]?.visible } }));
  }, []);

  return (
    <AccordionContext.Provider value={{ toggle, accordions, registerAccordion }}>
      {typeof children === 'function' ? children({ accordions }) : children}
    </AccordionContext.Provider>
  );
};

export default Accordion;
  • visible 값을 없애고 accordions 이름의 객체를 추가합니다.
  • 중첩된 모든 Accordion에 id 값을 부여하여 accordions 객체로 관리합니다.

Header.tsx

import { useAccordion } from '@components/_atoms/Accordion';
import useOnMount from '@utils/hooks/useOnMount';

interface Props {
  children: React.ReactNode;
  className?: string;
  id: string;
}

const Header = ({ children, className, id }: Props) => {
  const { toggle, registerAccordion } = useAccordion();

  useOnMount(() => {
    registerAccordion(id || '');
  });

  return (
    <div onClick={() => toggle(id)} className={className}>
      {children}
    </div>
  );
};

export default Header;

AccordionAnimation.tsx

interface Props {
  children: React.ReactNode;
  duration?: number;
  className?: string;
  id: string;
}

const AnimationAccordion = ({ children, duration = 300, className, id }: Props) => {
  const { registerAccordion, accordions } = useAccordion();
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [maxHeight, setMaxHeight] = React.useState(0);
  const visible = accordions[id]?.visible;

  const childrenElement = React.cloneElement(children as React.ReactElement, { contentRef, duration, id });

  const handleHeight = useCallback(() => {
    if (!contentRef.current) {
      setMaxHeight(0);
      return;
    }

    const totalHeight = Object.values(accordions).reduce((acc, cur) => {
      if (!cur.visible) return acc;
      return acc + (cur?.height || 0);
    }, 0);
    setMaxHeight(visible ? totalHeight : 0);
  }, [accordions, visible]);

  useEffect(() => {
    requestAnimationFrame(handleHeight);
  }, [visible, handleHeight]);

  useEffect(() => {
    if (!contentRef.current) {
      return;
    }

    registerAccordion(id, contentRef.current.clientHeight);
  }, [id, registerAccordion]);

  // ...
*};*
  • 이제 toggle 시에 accordions 객체에 추가된 id 값을 key로 갖는 요소에 visible을 토글링합니다.
  • 객체가 변경되었을 때 setMaxHeight 값을 업데이트해줍니다.

결과

<Accordion>
  <Accordion.Header id="depth1">헤더, 누르면 아래 Content 영역이 나타남</Accordion.Header>
  <Accordion.Animation id="depth1">
    <Accordion.Content>
      <ul>
        <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
        <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>

        <Accordion.Header id="depth2">두 번째 헤더</Accordion.Header>
        <Accordion.Animation id="depth2">
          <Accordion.Content>
            <ul>
              <li>2번 콘텐츠</li>
              <li>2번 콘텐츠</li>
              <li>2번 콘텐츠</li>
              <li>2번 콘텐츠</li>
            </ul>
          </Accordion.Content>
        </Accordion.Animation>
        <li>콘텐츠, Header 영역을 눌러야 영역이 보임</li>
      </ul>
    </Accordion.Content>
  </Accordion.Animation>
</Accordion>
<hr />
아코디언 아래 콘텐츠

이제 중첩된 Accordion도 간단하게 구현할 수 있게 되었습니다.


결론

지금까지 Compound Pattern을 활용한 Headless Accordion Component를 만들어 보았습니다. 어떻게 만드는지는 알겠는데, 과연 유용할까요?

만들어 놓을 때 코드량이 많아지기도 하고, 이해하지 못했다면 코드를 작성하거나 사용하는 것에 불필요한 시간을 낭비하게 될 수도 있습니다.

예를 들어, 아래처럼 Accordion 컴포넌트를 사용할 수도 있을 것 같습니다.

const rows = [
  {
    header: 'header1',
    content: 'content1',
  },
  {
    header: 'header2',
    content: 'content2',
  }
]

// ...

<Accordion rows={rows} />

이렇게 만들면 rows를 배열로 받아 순회하며 렌더링 하면 될 것입니다. 각각의 상태를 외부에서 주입해 줄 수도 있겠죠.

이 경우가 좀 더 직관적이고 컴포넌트를 작성하기 편하지만, 기능이 조금씩 추가된다거나 디자인이 변경될 필요가 있는 경우엔 Compound Pattern이 더 적은 시간으로 구현이 가능할 것이라고 생각합니다. 다만, 복잡한 구현이 필요 없는 경우라면 Compound Pattern 대신 단순히 State만을 활용하거나 Custom Hook을 이용해서 컴포넌트를 작성해도 충분할 것 같습니다.

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글