본문 바로가기
Next.js & React

[Next.js] 헤드리스 컴포넌트로 드롭다운 만들기!

by Meanings_ 2024. 2. 14.

 

보통 드롭다운을 만든다고 하면 어떻게 만들까요?

{dropdownClick === scheduleId && isDropdownOpen && (
                <Dropdown className={dropdownContainer}>
                  <Dropdown.Item
                    text="수정하기"
                    onClick={handleEditClick}
                    rightAddon={<Icon name="pencil" size={16} />}
                  ></Dropdown.Item>
                  <div className={dropdownDivider}></div>
                  <Dropdown.Item
                    text="삭제하기"
                    onClick={handleDeleteClick}
                    rightAddon={<Icon name="delete" size={16} />}
                  ></Dropdown.Item>
                </Dropdown>
)}

아마 이런식으로 dropdownClick됐을때만 드롭다운이 열리도록 구현을 했을겁니다.

하지만 이런 코드는 중복되는 코드를 늘릴뿐더러 가독성 측면에서도 좋지 못합니다.

드롭다운을 만들때 마다 매번 클릭했는지 상태를 만들어줘야 하다보니 가뜩이나 상태관리할게 많은 UI단이 더 복잡해지겠죠

드롭다운 같은 컴포넌트의 경우 디자인 시스템으로 따로 만드는데 차라리 디자인 시스템한테 상태관리를 할수 있게하면 어떨까?! 라는 생각이 들겁니다.

 

그래서 나타난 헤드리스 컴포넌트 패턴!

(번역) 헤드리스 컴포넌트: 리액트 UI를 합성하기 위한 패턴

정의를 살펴보면 “헤드리스 컴포넌트 패턴은 계산과 UI 표현을 분리하여, 개발자가 다재다능하고 유지 관리가 가능하며 재사용 가능한 컴포넌트를 구축할 수 있도록 지원합니다.” 라고 하네요.

결론부터 말하자면 사용처에서 드롭다운을 쓸 때 따로 useState로 드롭다운을 열었는지 상태관리를 해주지 않고

<Dropdown>
            <Dropdown.Trigger>
              <Icon name="more" size={32} />
            </Dropdown.Trigger>
            <Dropdown.Content className={deleteDropdownContainer}>
              <Dropdown.Item
                text="삭제하기"
                onClick={handleDeleteClick}
                rightAddon={<Icon name="delete" size={16} />}
              ></Dropdown.Item>
            </Dropdown.Content>
</Dropdown>

요렇게 드롭다운만 가져다 쓰면되게 만들어볼겁니다! 이렇게 구현하면 개발자는 비즈니스로직과 UI에 좀더 집중할 수 있게 됩니다.

 

 

직접구현해보면서 헤드리스 컴포넌트에 꼭 필요한 세가지를 꼽을 수 있었는데 Context,Trigger,Provider 입니다.

드롭다운을 열었는지,닫았는지에 그리고 그 state를 변경시키는 함수를 ContextAPI를 사용해서 전역적으로 관리하고, 실제로 드롭다운을 실행시키는 함수를 Trigger라고 합니다.

  • 드롭다운 실행전

  • 드롭다운 실행후

<Dropdown>
				
        <Dropdown.Trigger> // 드롭다운을 실행시키는 버튼 (특정버튼을 눌렀을때 드롭다운이 열린다면 그 버튼을 Trigger라한다)
              <Icon name="more" size={32} />
         </Dropdown.Trigger>
				
         <Dropdown.Content className={deleteDropdownContainer}> // 드롭다운에 들어가는 내용
              <Dropdown.Item
                text="삭제하기"
                onClick={handleDeleteClick}
                rightAddon={<Icon name="delete" size={16} />}
              ></Dropdown.Item>
         </Dropdown.Content>
</Dropdown>

직장 옆에 있는 arrow icon이 여기서 Trigger에 해당됩니다.

import { createContext } from '@linker/react';

interface DropdownContext {
  isOpen: boolean;
  onOpenChange?: () => void;
}

const context = {
  isOpen: false,
  onOpenChange: undefined,
};

export const [DropdownProvider, useDropdownContext] = createContext<DropdownContext>(
  'Dropdown',
  context,
);

우선 상태부터 살펴볼까요?

열었는지 상태를 저장하는 isOpen과 이 변수를 변경시키는 onOpenChange()를 함께 저장합니다.

Dropdown.tsx

'use client';

import { HTMLAttributes } from 'react';
import { ReactNode } from 'react';
import { useState } from 'react';

import { dropdownTrigger } from './Dropdown.css';
import DropdownItem from './DropdownItem';
import { DropdownProvider, useDropdownContext } from './context';

interface Props extends HTMLAttributes<HTMLButtonElement> {
  children?: ReactNode;
  className?: string;
}

const Dropdown = ({ children, className }: Props) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <DropdownProvider isOpen={isOpen} onOpenChange={() => setIsOpen((prev) => !prev)}>
      <button className={className}>{children}</button>
    </DropdownProvider>
  );
};

export default Object.assign(Dropdown, {
  Item: DropdownItem,
  Trigger: DropdownTrigger,
  Content: DropdownContent,
});

function DropdownTrigger({ children }: Props) {
  const { onOpenChange } = useDropdownContext('Dropdown-Trigger');

  return (
    <button type="button" onClick={onOpenChange} className={dropdownTrigger}>
      {children}
    </button>
  );
}

function DropdownContent({ children, className }: Props) {
  const { isOpen } = useDropdownContext('Dropdow-Trigger');

  return <>{isOpen && <button className={className}> {children}</button>}</>;
}

드롭다운 컴포넌트를 보면 상태를 전달해주는 Provider가 있는 Dropdown, Dropdown을 여는 역할을 하는 DropdownTrigger, Dropdown내부에 콘텐트를 저장해주는 DropdownContent가 존재합니다.

여기서 핵심은 Provider에서 open된 상태와 onOpenChange함수의 상태를 가지는데 onOpenChange의 경우 ()⇒setIsOpen((prev)⇒!open)을 통해 Trigger에서 버튼을 누르게 되면 onOpenChange함수가 호출돼서 isOpen값이 바뀐다는 겁니다!

사용처에서는 아래와 같이 쓸 수 있습니다.

<Dropdown>
            <Dropdown.Trigger>
              <Icon name="more" size={32} />
            </Dropdown.Trigger>
            <Dropdown.Content className={deleteDropdownContainer}>
              <Dropdown.Item
                text="삭제하기"
                onClick={handleDeleteClick}
                rightAddon={<Icon name="delete" size={16} />}
              ></Dropdown.Item>
            </Dropdown.Content>
</Dropdown>