본문 바로가기

개발 일기

Dropdown 컴포넌트 개선: useImperativeHandle 훅으로 외부에서 드롭다운 상태 제어하기

 

이번 글에서는 React로 제작한 Dropdown 컴포넌트를 개선하여 외부에서 selectedItems 상태를 제어할 수 있도록 한 과정에 대한 내용을 공유하려한다.

 

지난 Dropdown 컴포넌트 제작기에 관련된 포스팅

작지만 생각할게 참 많았던, Dropdown 컴포넌트 설계 과정 기록: https://kodywiththek.tistory.com/12

 

작지만 생각할게 참 많았던, Dropdown 컴포넌트 설계 과정 기록

1. Radix UI를 활용한 Dropdown 컴포넌트 조각 만들기처음에는 Radix UI의 DropdownMenu 프리미티브를 기반으로 드롭다운 컴포넌트의 기본 조각을 만들었다. Radix UI는 접근성과 유연성을 갖춘 컴포넌트를

kodywiththek.tistory.com

 


 

기존 Dropdown 컴포넌트의 한계

처음 작성한 Dropdown 컴포넌트는 사용자가 드롭다운 메뉴에서 아이템을 선택할 때만 selectedItems 상태가 변경되도록 설계되었다. 주요 코드 일부는 다음과 같다.

export const Dropdown: React.FC<DropdownProps> = ({
  children,
  items,
  defaultValue = [],
  defaultOpen,
  onOpenChange,
  onChange,
  size,
  column = 1,
  multiple = false,
}) => {
  const [selectedItems, setSelectedItems] = React.useState<SelectedDropdownItem[]>(defaultValue);

	// selectedItems가 변경됨에따라 onChange로 외부에 알림
  React.useEffect(() => {
    if (onChange) {
      onChange(selectedItems);
    }
  }, [selectedItems, onChange]);

  // handleSelect 및 handleDeselect 함수 생략

  return (
    <DropdownMenu defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
      <DropdownMenuTrigger asChild style={{ width: size === "fit" ? "fit-content" : size || "auto" }}>
        {children}
      </DropdownMenuTrigger>
      <DropdownMenuContent style={{ width: size === "fit" ? "fit-content" : size || "auto" }}>
        {items.map((container, index) => (
          <DropdownContainerRenderer
            key={index}
            container={container}
            handleSelect={handleSelect}
            handleDeselect={handleDeselect}
            selectedItems={selectedItems}
            column={column}
          />
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
};

 

이 컴포넌트는 기본적으로 사용자 상호작용을 통해 selectedItems를 변경하도록 설계되었다. 그러나 특정 상황에서는 외부 요소의 변경에 따라 selectedItems를 예외적으로 변경해야 할 필요가 있었다. 예를 들어, 외부 상태나 API 호출의 결과에 따라 드롭다운의 선택 항목을 프로그래밍적으로 변경해야 하는 경우가 발생했다.

 

예시로, 아래의 컴포넌트는 과정의 키 선택그에 따른 회차 선택 드롭다운을 제공하는데, 첫 번째 드롭다운의 선택 항목이 변경되면 두 번째 드롭다운의 아이템이 재설정되어야 한다. 또한, 두 번째 드롭다운의 새로운 아이템 중 첫 번째 아이템이 자동으로 선택되어야 하는 요구사항이 있었다.

정리해보자면,

1. 첫 번째 드롭다운에서 선택한 키에 따라 두 번째 드롭다운의 아이템이 동적으로 재설정되어야 했다.
2. 두 번째 드롭다운의 아이템이 재설정될 때, 자동으로 첫 번째 아이템이 선택되어야 했다.
type Props = {
  reviews: ReviewCourseType;
  setIsClosed: React.Dispatch<React.SetStateAction<boolean>>;
  onChange: (selectedKeyItems: SelectedDropdownItem[]) => void;
};

export default function MultiCourseDropdown({ reviews, setIsClosed, onChange }: Props) {

  // 첫번째 드롭다운에 대한 아이템 상태
  const courseKeyItems = useMemo(() => createMultiCourseKeysDropdownItems(reviews, setIsClosed), [reviews, setIsClosed]);

  const initialSelectedItem = getInitialSelectedItem(CoursesKeyItems);
  const [selectedKey, setSelectedKey] = useState<SelectedDropdownItem[]>(initialSelectedItem);

  // 첫번째 드롭다운에서 선택된 아이템에 대한 후속 아이템들
  const selectedCourseItems = useMemo(
    () => createSelectedKeyDropdownItems(reviews, selectedKey[0].value, setIsClosed),
    [reviews, selectedKey, setIsClosed],
  );

  const initialSelectedKeyItem = getInitialSelectedItem(selectedCourseItems);
  const [selectedKeyItems, setSelectedKeyItems] = useState<SelectedDropdownItem[]>(initialSelectedKeyItem);

  useEffect(() => {
    onChange(selectedKeyItems);
  }, [selectedKeyItems, onChange]);

  return (
    <FlexBox.Row className="gap-2">
      {* 첫번째 드롭다운 *}  
      <Dropdown items={coursesKeyItems} size={240} defaultValue={selectedKey} onChange={setSelectedKey}>
        <Button
          icRight={<Icon.ArrowDown size="12" />}
          disabled={coursesKeyItems.length === 0}
        >
          {getDropdownButtonText(selectedKey, "프로그램 과정")}
        </Button>
      </Dropdown>
      
      {* 두번째 드롭다운 *} 
      <Dropdown items={selecteCourseItems} size={272} defaultValue={selectedKeyItems} onChange={setSelectedKeyItems}>
        <Button
          icRight={<Icon.ArrowDown size="12" />}
          disabled={selectedCourseItems.length === 0}
        >
          {getDropdownButtonText(selectedKeyItems, "회차를 선택해주세요.")}
        </Button>
      </Dropdown>
    </FlexBox.Row>
  );
}

 

 

이 컴포넌트의 구조에서 첫 번째 드롭다운에서 선택한 요소가 변경될 때마다 두 번째 드롭다운의 아이템이 재설정되어야 한다. 그러나 selectedCourseItems가 업데이트되면서 두 번째 드롭다운에 자동으로 첫 번째 아이템을 선택하는 기능을 구현하는 과정에서 어려움이 있었다. 상태가 연쇄적으로 변경되면서 드롭다운 상태를 유지하거나 동기화하는 데 문제가 발생했다.

 


useEffect로 defaultValue 재설정

첫 번째로 시도한 방법은 items가 변경될 때 defaultValue를 다시 설정하는 useEffect를 사용하는 것이었다. 이를 통해 외부에서 items가 변경될 때 selectedItems도 동기화하려 했다. items가 변경될 때마다 selectedItems를 defaultValue로 재설정함으로써 외부에서 상태를 변경하려 했다.

React.useEffect(() => {
  setSelectedItems(defaultValue);
}, [items]); // 이로 인해 무한 렌더링 발생

 

 

하지만 이 방법은 상태 업데이트와 리렌더링이 반복되면서 무한 렌더링을 초래했다. React의 useEffect는 setSelectedItems 호출로 인해 컴포넌트가 다시 렌더링되면 다시 실행된다. 이로 인해 items가 변경될 때마다 계속해서 상태가 업데이트되어 무한 루프가 발생했다

 

update 트리거를 이용한 defaultValue 재설정

두 번째 시도는 update라는 트리거 prop을 만들어, 외부에서 이 값을 변경하면 defaultValue를 다시 설정하도록 하는 방법이었다.

React.useEffect(() => {
  if (update) {
    setSelectedItems(defaultValue);
  }
}, [update]);

 

특정 조건(update prop의 변경)에 따라 selectedItems를 업데이트하려는 의도였다. 이를 통해 외부에서 update 값을 변경하여 상태를 제어할 수 있을 것으로 생각했다. 여기서 update가 바뀌면 useEffect가 그때마다 실행되고 동일한 update 값이 연속해서 설정되면 useEffect는 실행되지 않으므로 문제가 발생하지는 않는다. 하지만 확장성을 생각했을때, 만약 defaultValue가 아닌, 특정 데이터를 setSelectedItems 하고싶다면, 이 방법 역시 문제가 수 있다.

 


 

해결 방법: useImperativeHandle 훅과 forwardRef 사용

고민 끝에 useImperativeHandle 훅을 알게 되었고, 이를 통해 문제를 해결할 수 있었다.

useImperativeHandle은 React의 훅(Hook) 중 하나로, 부모 컴포넌트가 자식 컴포넌트의 특정 메서드나 속성에 접근할 수 있도록 ref를 사용자 정의할 수 있게 해준다. 이 훅은 일반적으로 forwardRef와 함께 사용되며, 컴포넌트가 제공하는 인터페이스를 명시적으로 제어하고 싶을 때 유용하다.

useImperativeHandle(ref, () => ({
  // 외부에서 접근 가능한 메서드나 속성 정의
}), [dependencies]);
  • ref: 부모 컴포넌트로부터 전달받은 ref.
  • 두 번째 인자: 외부에 노출할 메서드나 속성을 정의하는 함수.
  • 세 번째 인자: 의존성 배열로, 이를 통해 메서드나 속성이 변경될 때만 업데이트되도록 한다.

사용 목적

  • 컴포넌트가 내부 DOM 노드를 노출할 필요가 있을 때나, DOM 관련 메서드를 부모가 호출할 수 있게 해야 할 때 사용된다.
  • 주로 스크롤, 포커스 같은 DOM 요소의 명령형 행동을 제공하기 위해 사용된다.

 


해결 과정

1. DropdownRef 인터페이스 정의

외부에서 접근할 수 있는 메서드 인터페이스를 정의한다. 여기서는 setSelectedItems 메서드를 노출하기로 했다.

export interface DropdownRef {
  setSelectedItems: React.Dispatch<React.SetStateAction<SelectedDropdownItem[]>>;
}

 

2. forwardRef로 컴포넌트 수정

React.forwardRef를 사용하여 ref를 전달받도록 컴포넌트를 수정한다. useImperativeHandle을 사용하여 외부에서 setSelectedItems를 호출할 수 있도록 한다.

export const Dropdown = React.forwardRef<DropdownRef, DropdownProps>(
  (
    { children, items, defaultValue = [], defaultOpen, onOpenChange, onChange, size, column = 1, multiple = false },
    ref,
  ) => {
    const [selectedItems, setSelectedItems] = React.useState<SelectedDropdownItem[]>(defaultValue);

    // 외부에서 setSelectedItems를 호출할 수 있도록 메서드 노출
    React.useImperativeHandle(
      ref,
      () => ({
        setSelectedItems,
      }),
      [], // 의존성 배열 비우기
    );

    // 선택된 항목 변경 시 상태 업데이트
    React.useEffect(() => {
      if (onChange) {
        onChange(selectedItems);
      }
    }, [selectedItems, onChange]);

    // handleSelect 및 handleDeselect 함수 생략

    return (
      <DropdownMenu defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
        <DropdownMenuTrigger>
          {children}
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          {items.map((container, index) => (
            <DropdownContainerRenderer
              key={index}
              container={container}
              handleSelect={handleSelect}
              handleDeselect={handleDeselect}
              selectedItems={selectedItems}
              column={column}
            />
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    );
  },
);

Dropdown.displayName = "Dropdown";

 

3. 부모 컴포넌트에서 ref 사용하기

부모 컴포넌트에서 ref를 통해 setSelectedItems 메서드를 호출하여 selectedItems 상태를 제어할 수 있다.

import React, { useRef } from "react";
import { Dropdown, DropdownRef } from "./Dropdown";
import { Button } from "../button";

const ParentComponent = () => {
  const dropdownRef = useRef<DropdownRef>(null);

  const updateSelectedItems = () => {
    if (dropdownRef.current) {
      dropdownRef.current.setSelectedItems([
        { value: 'newItem1', containerType: 'checkbox', containerKey: 'key1' }
      ]);
    }
  };

  const resetSelection = () => {
    if (dropdownRef.current) {
      dropdownRef.current.setSelectedItems([]);
    }
  };

  return (
    <>
      <Dropdown ref={dropdownRef} items={items} defaultValue={defaultSelected}>
        <Button>Open Dropdown</Button>
      </Dropdown>
      <Button onClick={updateSelectedItems}>Update Selected Items</Button>
      <Button onClick={resetSelection}>Reset Selection</Button>
    </>
  );
};

export default ParentComponent;

 

 


 

useImparativeHandle 훅을 적용한 dropdown을 적용한 전체 코드

export default function MultiCourseDropdown({ reviews, setIsClosed, onChange }: Props) {
  // Dropdown 컴포넌트에 대한 참조 생성
  const dropdownRef = useRef<DropdownRef>(null);

  // 첫번째 드롭다운 아이템 상태
  const coursesKeyItems = useMemo(() => createMultiCourseKeysDropdownItems(reviews, setIsClosed), [reviews, setIsClosed]);

  const initialSelectedItem = getInitialSelectedItem(CoursesKeyItems);
  const [selectedKey, setSelectedKey] = useState<SelectedDropdownItem[]>(initialSelectedItem);

  // 선택된 키에 따른 두번째 드롭다운 아이템 생성
  const selectedCourseItems = useMemo(
    () => createSelectedKeyDropdownItems(reviews, selectedKey[0].value, setIsClosed),
    [reviews, selectedKey, setIsClosed],
  );

  const initialSelectedKeyItem = getInitialSelectedItem(selectedCourseItems);
  const [selectedKeyItems, setSelectedKeyItems] = useState<SelectedDropdownItem[]>(initialSelectedKeyItem);

  **/**
  * selectedCourseItems가 변경될 때마다 selectedKeyItems를 업데이트
  * 드롭다운의 선택 상태도 동기화
  */**
  useEffect(() => {
    const newSelectedKeyItems = getInitialSelectedItem(selectedCourseItems);
    if (dropdownRef.current) {
      dropdownRef.current.setSelectedItems(newSelectedKeyItems);
    }
  }, [selectedCourseItems]);

  useEffect(() => {
    onChange(selectedKeyItems);
  }, [selectedKeyItems, onChange]);

  return (
    <FlexBox.Row className="gap-2">
      <Dropdown items={coursesKeyItems} size={240} defaultValue={selectedKey} onChange={setSelectedKey}>
        <Button disabled={CoursesKeyItems.length === 0}>
          {getDropdownButtonText(selectedKey, "프로그램 과정")}
        </Button>
      </Dropdown>
      <Dropdown **ref={dropdownRef}** items={selectedCourseItems} size={272} defaultValue={selectedKeyItems} onChange={setSelectedKeyItems}>
        <Button disabled={selectedCourseItems.length === 0}>
          {getDropdownButtonText(selectedKeyItems, "회차를 선택해주세요.")}
        </Button>
      </Dropdown>
    </FlexBox.Row>
  );
}

 

 외부 제어 가능: 부모 컴포넌트에서 selectedItems 상태를 직접 변경할 수 있어 유연성이 향상되었다.

 무한 렌더링 방지: useImperativeHandle과 forwardRef를 사용함으로써 상태 업데이트와 리렌더링의 반복을 효과적으로 방지할 수 있었다.

 컴포넌트 재사용성 향상: 다양한 상황에서 컴포넌트를 재사용할 수 있게 되어, 더 나은 확장성과 재사용성을 가지게되었다.

 

 

참조

https://react.dev/reference/react/useImperativeHandle

 

useImperativeHandle – React

The library for web and native user interfaces

react.dev