Drawer 컴포넌트 제어/비제어 방식 지원하기

오정진 Jeongjin Oh·2023년 5월 18일
3
post-thumbnail

Drawer 컴포넌트

Drawer 컴포넌트 - https://ant.design/components/drawer

TL;DR

  • 이 글은 제어/비제어 방식을 모두 지원하는 컴포넌트를 설계하기 위한 저의 고민이 담겨 있습니다.
  • radix-ui 라이브러리 소스코드에서 어떻게 제어/비제어 방식을 모두 지원하는 컴포넌트를 작성했는지 살펴봅니다.
  • radix-ui에서 본 소스코드를 응용해 우리 프로젝트 코드에 적용하였습니다.

문제 상황

프로젝트를 하며 Drawer 컴포넌트에 제어와 비제어 컴포넌트 방식을 모두 지원해야 하는 경우가 생겼다. 처음에는 해당 컴포넌트의 상태를 외부에서 필요로 하지 않아 비제어 컴포넌트로만 구현했다. 하지만 요구사항을 구현하며 Drawer 컴포넌트의 상태를 외부에서 제어해줘야 하는 상황이 발생했다.

기획안을 보면,

태그 카테고리(파란색 Drawer)를 열었을 때 메뉴(주황색 Drawer)를 누르면 태그 카테고리(파란색 Drawer)가 닫힌 후 메뉴가 열립니다.

쉽게 말하면, 주황색 Drawer가 열리면 파란색 Drawer가 닫혀야 한다는 말이다. 즉, Drawer의 상태를 외부에서 제어해줘야 가능한 기능이라고 판단했다.

하지만 여기서 다음과 같은 고민을 하게 되었다.

기존에 이미 비제어 방식으로 Drawer 컴포넌트를 사용하고 있는데 Drawer 컴포넌트를 변경하더라도 기존에 사용하고 있던 곳에서 추가적인 변경을 막을 수는 없을까?

사실 비제어 컴포넌트를 제어 컴포넌트로 바꾸는 작업은 어렵지 않다. 아래와 같이 그냥 Drawer 를 사용하는 쪽에서 Drawer의 open 상태를 정의해주고 그 상태를 props 로 넘겨주면 된다.

const [open, setOpen] = useState(false);

// 부모에서 Drawer로 open 상태를 props로 넘겨줌
<Drawer open={open} setOpen={setOpen}>
	...
</Drawer>

하지만 이렇게 비제어에서 제어로 바꾸게 되면 컴포넌트의 인터페이스(props 추가)가 변경되고 그러면 기존에 사용하던 곳에서도 컴포넌트의 상태를 props로 넘겨주는 등의 추가적인 변경이 필요하다.

그러면 1️⃣Drawer 컴포넌트의 인터페이스를 바꾸지 않으면서 2️⃣제어 컴포넌트 방식도 지원하려면 어떻게 해야할까?

솔루션 찾기 - Radix-ui 살펴보기

솔루션을 찾기 위해 고도화된 UI 컴포넌트 라이브러리를 찾아보았다. 그 중 내가 가장 관심있어 하는 라이브러리인 Radix-ui를 자세히 뜯어보았다. 이 라이브러리는 제어와 비제어 방식을 모두 지원한다.

https://www.radix-ui.com/docs/primitives/overview/introduction#uncontrolled

radix-ui overview

그럼 어떻게 제어와 비제어 방식을 모두 지원할 수 있는걸까?

일단 컴포넌트의 인터페이스을 보기 위해 Radix-UI의 Dialog 컴포넌트를 살펴보았다.

Dialog 컴포넌트는 제어와 비제어 모두 지원한다
https://www.radix-ui.com/docs/primitives/components/dialog

radix-ui dialog component feature

https://www.radix-ui.com/docs/primitives/components/dialog#root
radix-ui dialog root component

openonOpenChange props 설명

openonOpenChange
The controlled open state of the dialog. Must be used in conjunction with onOpenChange. (dialog의 제어된 open 상태. onOpenChange와 함께 사용해야 한다.)Event handler called when the open state of the dialog changes. (dialog의 open 상태가 바뀔 때 호출되는 이벤트 핸들러)

위 설명에서 주목해야 하는 것은 “제어된 open 상태” 이다. radix-ui의 Dialog 컴포넌트는 openonOpenChange props를 통해 컴포넌트의 상태를 외부에서 제어해줄 수 있는 것이다.

그럼 어떻게 이 두 props으로 비제어 방식의 컴포넌트에 제어 방식도 지원해줄 수 있는 걸까?

아무래도 소스코드를 뜯어봐야알거같다.

Radix-ui 소스코드 뜯어보기

위에서 본 openonOpenChange props가 있는 Root 컴포넌트는 아래와 같다.
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L51-L84

const Dialog: React.FC<DialogProps> = (props: ScopedProps<DialogProps>) => {
  const {
    __scopeDialog,
    children,
    open: openProp,
    defaultOpen,
    onOpenChange,
    modal = true,
  } = props;
  ...
  const [open = false, setOpen] = useControllableState({
    prop: openProp,
    defaultProp: defaultOpen,
    onChange: onOpenChange,
  });

  return (
    <DialogProvider
			...
      open={open}
      onOpenChange={setOpen}
      ...
    >
      {children}
    </DialogProvider>
  );
};

open, onOpenChange props 가 useControllableState 라는 hook의 인자로 들어가고 있다.

useControllableState .. 뭔가 이름만 보면 제어 가능한 상태로 만들어주는 hook인 것으로 보인다.

useControllableState 의 구현체를 찾아가 보면 실제로 외부에서 주입해준 open 상태와 내부의 비제어 상태를 결합하는 hook 임을 알 수 있었다.
https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx#L12-L36

function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
}: UseControllableStateParams<T>) {
  const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
  const isControlled = prop !== undefined;
  const value = isControlled ? prop : uncontrolledProp;
  const handleChange = useCallbackRef(onChange);

  const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
    (nextValue) => {
      if (isControlled) {
        const setter = nextValue as SetStateFn<T>;
        const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
        if (value !== prop) handleChange(value as T);
      } else {
        setUncontrolledProp(nextValue);
      }
    },
    [isControlled, prop, setUncontrolledProp, handleChange]
  );

  return [value, setValue] as const;
}

다음 세 줄이 비제어 상태와 제어 상태를 결합하는 부분이다.
https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx#L17-L19

const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
const isControlled = prop !== undefined;
const value = isControlled ? prop : uncontrolledProp;

useUncontrolledState hook은 그냥 컴포넌트 내의 비제어 상태를 정의하는 hook이다.

prop은 외부에서 주입해준 open 상태이고 이것의 유무로 제어방식을 취할지 비제어방식을 취할지 결정하고 있다.(isControlled)

그래서 isControlled 을 이용해 제어 방식일 때는 외부에서 주입해준 상태를 사용하고 아닐 때는 비제어 상태를 사용할 수 있는 것이다.(const value = isControlled ? prop : uncontrolledProp)
https://github.com/radix-ui/primitives/blob/main/packages/react/use-controllable-state/src/useControllableState.tsx#L22-L33

const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
  (nextValue) => {
    if (isControlled) {
      const setter = nextValue as SetStateFn<T>;
      const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
      if (value !== prop) handleChange(value as T);
    } else {
      setUncontrolledProp(nextValue);
    }
  },
  [isControlled, prop, setUncontrolledProp, handleChange]
);

setValue 함수는 React.useState의 인자와 반환타입을 그대로 따르고 있다.(React.Dispatch<React.SetStateAction<T | undefined>>)

setValue 함수도 마찬가지로 isControlled 를 이용해 제어방식과 비제어 방식 로직을 따로 취하고 있다.

정리를 하자면,

radix-ui는 useControllableState 유틸 hook을 구현함으로써 외부에서 주입해주는 제어 상태와 컴포넌트 내부의 비제어 상태를 결합할 수 있는 것이다.

생각보다 소스코드가 어렵지 않아 쉽게 파악할 수 있었다.

그러면 Drawer 컴포넌트도 위와 같은 로직으로 제어/비제어 방식을 모두 지원하는 컴포넌트로 재작성할 수 있지 않을까?

Drawer 컴포넌트 다시 작성하기

AS-IS

Drawer 컴포넌트가 현재는 비제어 방식으로만 동작하고 있다.

// Drawer 컴포넌트 -> 외부에서 주입해주는 상태 없음
// 비제어 방식만 지원하고 있다.
export const Drawer = ({ children }: PropsWithChildren) => {
  return (
    <DrawerContextProvider>
      <section>{children}</section>
    </DrawerContextProvider>
  );
};

interface DrawerTriggerProps {
  children: ({ isOpen }: { isOpen: boolean }) => ReactNode;
}
const DrawerTrigger = ({ children }: DrawerTriggerProps) => {
  const isOpen = useDrawerContext();
  const setIsOpen = useSetDrawerContext();

  return <button onClick={() => setIsOpen(!isOpen)}>{children({ isOpen })}</button>;
};

interface DrawerContentProps {
	...
  children: ReactNode;
	...
}
const DrawerContent = ({ children, className, direction }: DrawerContentProps) => {
  const isOpen = useDrawerContext();

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

Drawer.Trigger = DrawerTrigger;
Drawer.Content = DrawerContent;

TO-BE

여기서 제어 상태 로직을 추가하고 비제어 상태와 결합하는 코드를 작성해야 한다.

radix-ui의 소스코드에서 얻은 인사이트를 적용하면 되겠다.

// Drater.tsx
// isOpen과 onOpenChange props를 이용해 제어 상태를 주입해준다.
interface DrawerProps {
  isOpen?: boolean;
  onOpenChange?(open: boolean): void;
}
export const Drawer = ({ children, isOpen, onOpenChange }: PropsWithChildren<DrawerProps>) => {
  return (
    <DrawerContextProvider isOpen={isOpen} onOpenChange={onOpenChange}>
      <section>{children}</section>
    </DrawerContextProvider>
  );
};

// context.tsx
// 💡 Drawer의 비제어 상태와 제어 상태를 결합한다.
interface DrawerContextProviderProps {
  isOpen?: boolean;
  onOpenChange?(open: boolean): void;
}
export const DrawerContextProvider = ({
  children,
  isOpen: isOpenProp,
  onOpenChange,
}: PropsWithChildren<DrawerContextProviderProps>) => {
  const [isOpen = false, setIsOpen] = useState(isOpenProp);
  const isControlled = isOpenProp !== undefined;
	// 💡 외부에서 주입해준 상태가 있는지 여부(isControlled)에 따라 제어/비제어 상태값을 사용한다.
  const value = isControlled ? isOpenProp : isOpen;

	// 💡 외부에서 주입해준 상태가 있는지 여부(isControlled)에 따라 상태 변경 함수를 작성한다.
  const handleDrawer: React.Dispatch<React.SetStateAction<boolean | undefined>> = useCallback(
    (nextValue) => {
      const value = typeof nextValue === "function" ? nextValue(isOpenProp) : nextValue;
      if (isControlled) {
        if (value !== isOpenProp) onOpenChange?.(value as boolean);
      } else {
        setIsOpen(nextValue);
      }
    },
    [isControlled, isOpenProp, onOpenChange],
  );

  return (
    <DrawerContext.Provider value={value}>
      <DrawerSetContext.Provider value={handleDrawer}>{children}</DrawerSetContext.Provider>
    </DrawerContext.Provider>
  );

관련 PR https://github.com/thismeme-team/thismeme-web/pull/14

관련 Commit
https://github.com/thismeme-team/thismeme-web/commit/786cbf7422f679cb9c1372798680343ff79d6a90

profile
사람의 마음을 움직일 수 있는 글을 쓰고 싶어요

0개의 댓글