본문 바로가기

FrontEnd

리액트 디자인 패턴(React design pattern) : Compound Component Pattern(컴파운드 컴포넌트패턴)과 Uncontrolled Component Pattern(유상태 컴포넌트 패턴)

반응형

radix-ui와 같은 라이브러리는 컴파운드 컴포넌트 패턴uncontrolled component 패턴과 조합한 구현을 채택하고 있습니다.

위 다이얼로그 컴포넌트의 재미있는 점은, children으로 전달받은 컴포넌트에 dialog를 열기 위한 핸들러를 전달할 필요가 없다는 점입니다.

<Dialog.Trigger asChild>
  <button className="Button violet" size="large">
    Edit profile
  </button>
</Dialog.Trigger>

컴파운드 컴포넌트는 내부적으로 context를 활용하니,

Dialog 컨텍스트를 활용해 해당 버튼이 클릭되는 것을 어떻게든 감지한다는 것을 알 수 있습니다.

 

그런데 일반 <div/>와 버블링을 활용하면 span을 div로 감싸다보니 css(스타일) 관점에서 원하지 않는 동작이 발생할 수 있습니다.

따라서 가장 보수적인 방법은 button으로 open, close 이벤트 핸들러를 "몰래" 전달해 주는 건데요,

이 "몰래 전달하기"를 어떻게 구현할 수 있을까요?


1. Dialog 소스 보기

https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/Dialog.tsx#L88

 

GitHub - radix-ui/primitives: Radix Primitives is an open-source UI component library for building high-quality, accessible desi

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos. - GitHub - radix-ui/primitives: Radix Primitives is...

github.com

위 소스 코드의 88번째 라인을 보면 아래와 같은 코드가 있습니다.

일단 우리가 알 수 있는 것은 DiaolgTrigger의 prop으로 전달받은 정보와 context값을 바꾸는 context.onOpenToggle 핸들러를

Primitive.button으로 전달함을 알 수 있습니다.

const TRIGGER_NAME = 'DialogTrigger';

type DialogTriggerElement = React.ElementRef<typeof Primitive.button>;
type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<typeof Primitive.button>;
interface DialogTriggerProps extends PrimitiveButtonProps {}

const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
  (props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
    const { __scopeDialog, ...triggerProps } = props;
    const context = useDialogContext(TRIGGER_NAME, __scopeDialog);
    const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
    return (
      <Primitive.button
        type="button"
        aria-haspopup="dialog"
        aria-expanded={context.open}
        aria-controls={context.contentId}
        data-state={getState(context.open)}
        {...triggerProps}
        ref={composedTriggerRef}
        onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
      />
    );
  }
);

DialogTrigger.displayName = TRIGGER_NAME;

2. Primitive 소스 보기

https://github.com/radix-ui/primitives/blob/main/packages/react/primitive/src/Primitive.tsx#L42

 

GitHub - radix-ui/primitives: Radix Primitives is an open-source UI component library for building high-quality, accessible desi

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos. - GitHub - radix-ui/primitives: Radix Primitives is...

github.com

언급하지 않은 부분이 있는데요,

DialogTrigger는 다음과 같은 기능을 갖고 있습니다.

  • asChild prop을 사용하면, 자식 컴포넌트를 버튼으로 동작하게 할 수 있습니다.
  • asChild prop이 없으면 DialogTrigger 자체를 버튼으로 렌더링하도록 할 수 있습니다.

42L을 봅시다.

우리의 관심사는 asChild가 전달되었을 때입니다.

리턴 부분에서는 DialogTrigger를 통해 전달한 prop들이 primitiveProps로 전달되고 있음을 알 수 있습니다.

최종적으로 Slot을 봐야겠네요

const Primitive = NODES.reduce((primitive, node) => {
  const Node = React.forwardRef((props: PrimitivePropsWithRef<typeof node>, forwardedRef: any) => {
    const { asChild, ...primitiveProps } = props;
    const Comp: any = asChild ? Slot : node;

    React.useEffect(() => {
      (window as any)[Symbol.for('radix-ui')] = true;
    }, []);

    return <Comp {...primitiveProps} ref={forwardedRef} />;
  });

  Node.displayName = `Primitive.${node}`;

  return { ...primitive, [node]: Node };
}, {} as Primitives);

3. Slot 소스 보기

https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx#L12

 

GitHub - radix-ui/primitives: Radix Primitives is an open-source UI component library for building high-quality, accessible desi

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos. - GitHub - radix-ui/primitives: Radix Primitives is...

github.com

해당 소스를 분석하기 위해선 아래 글을 읽고 오시는 것이 좋습니다.

React Children API의 모든 것

 

React Children API의 모든 것

Children API를 사용하면 children prop으로 받은 JSX를 조작하고 변형할 수 있습니다. const mappedChildren = Children.map(children, child => {child} ); Children API의 명세 Children.count(children) Children.count(children)를 호출하

itchallenger.tistory.com

https://beta.reactjs.org/reference/react/cloneElement

 

cloneElement

A JavaScript library for building user interfaces

beta.reactjs.org

interface SlotProps extends React.HTMLAttributes<HTMLElement> {
  children?: React.ReactNode;
}

const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;
  // children 자료구조를 JS 배열로 만듭니다. children이 중첩 배열일 경우 평탄화 합니다. 단일 요소여도 배열화 합니다
  const childrenArray = React.Children.toArray(children);
  // 뭔가 찾음 ❌
  const slottable = childrenArray.find(isSlottable);
  // 뭔가 조건을 판단함 ❌
  if (slottable) {
    // the new element to render is the one passed as a child of `Slottable`
    const newElement = slottable.props.children as React.ReactNode;

    const newChildren = childrenArray.map((child) => {
      if (child === slottable) {
        // because the new element will be the one rendered, we are only interested
        // in grabbing its children (`newElement.props.children`)
        if (React.Children.count(newElement) > 1) return React.Children.only(null);
        return React.isValidElement(newElement)
          ? (newElement.props.children as React.ReactNode)
          : null;
      } else {
        return child;
      }
    });

    return (
      <SlotClone {...slotProps} ref={forwardedRef}>
        {React.isValidElement(newElement)
          ? React.cloneElement(newElement, undefined, newChildren)
          : null}
      </SlotClone>
    );
  }
  // 실제 로직
  return (
    <SlotClone {...slotProps} ref={forwardedRef}>
      {children}
    </SlotClone>
  );
});

Slot.displayName = 'Slot';

❌ 표시를 사용한 이유는, Radix-ui 내부와 공식문서에서 isSlottable과 Slotable을 활용하는 곳을 찾지 못했기 때문입니다.

따라서 우리는 SlotClone 코드만 확인하면 되겠습니다.

https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx#L56

interface SlotCloneProps {
  children: React.ReactNode;
}

const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
  const { children, ...slotProps } = props;

  if (React.isValidElement(children)) {
    return React.cloneElement(children, {
      ...mergeProps(slotProps, children.props),
      ref: composeRefs(forwardedRef, (children as any).ref),
    });
  }

  return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});

SlotClone.displayName = 'SlotClone';

이제 로직을 정리할 수 있겠네요.

  1. DialogTrigger의 children으로 버튼을 전달하고, primitive.button의 prop으로 다이얼로그을 열고 닫는 함수를 전달합니다
  2. primitive.button에서 Slot으로 다시 한번 prop들을 넘겨줍니다.
  3. cloneElement를 사용해 DialogTrigger로 전달된 버튼(children)을 복사한 뒤, children.props에서 children으로 전달된 prop들과, primitive.button으로 전달된 prop들을 결합하여 복사된 버튼에 prop으로 전달한 후, 자식을 렌더링 합니다.

사실 그냥 훅을 외부로 노출하면 되지 뭐 이리 복잡하게 하냐라고 물어볼 수도 있지만,

개발자가 신경써서 관리할 상태가 한두개만 줄어도 앱 개발이 상당히 편해집니다.

컴포넌트의 기능, 라이프사이클과 밀접한 있는 상태는, 컴포넌트 내에 캡슐화 하는게 좋은 것 같습니다.

 

cloneElement와 Children과 같은 레거시 API의 일반적인 사용은 권장하지 않지만,

Dialog와 같이 UI 기능과 밀접한 컴포넌트를 만들 때에는 여러모로 쓸모가 많은 것 같습니다.

 

참고 : https://beta.reactjs.org/reference/react/cloneElement

 

cloneElement

A JavaScript library for building user interfaces

beta.reactjs.org

 

반응형