본문 바로가기

FrontEnd

컴파운드 컴포넌트 잘만들기 3탄 : compound component + uncontrolled component + co-location 삼신기 사용하기

반응형

컴파운드 컴포넌트 + uncontrolled component + co-location 삼신기로,

API 컨슈머의 상태 관리 부담을 덜어줄 수 있습니다.

또한 전역 상태도 피할 수 있습니다.

원문 번역입니다 : https://jjenzz.com/avoid-global-state-colocate

 

Avoid Global State — Co-locate with Uncontrolled Compound Components

Over the years, I've thought a lot about colocation and how to effectively apply this principle to the components I build. I began to notice a pattern emerge in my work and I'd like to share it.

jjenzz.com

아무리 최선을 다해도,
결국 코드베이스는 컴포넌트, 추상화 및 전역 상태(타사 라이브러리에 의해 관리됨)의 얽힌 실타래가 됩니다.
작은 변경마저도 살아있는 악몽으로 만들고 유지 보수는 이 실타래를 푸는 복잡한 일입니다.
 
이를 해결하기 위해 추상화를 피해야 한다는 이야기를 자주 듣습니다. 그런데 어떻게요?
때때로 우리는 공유를 위해 상태를 들어올려야 하거나,
여러 페이지에서 사용할 수 있게 컴포넌트를 들어올려야 합니다.
 
나는 코로케이션에 대해 알게 되었고 내가 구축한 컴포넌트에 이 원칙을 효과적으로 적용하는 방법에 대해 많이 생각했습니다.
저는 제 개발 업무에서 패턴을 발견하였고, 이를 여러분과 공유하고 싶습니다.

코로케이션이 뭔가요?(What is colocation anyway?)

Kent C. Dodds는 comment code를 예로 사용하는 해당 주제에 대한 훌륭한 아티클(great article)을 작성하였습니다.
그는 코로케이션을 가능한 한 관련성이 높은 곳에 코드를 배치하는 것이라고 설명합니다.
함께 변하는 것들은 합리적으로 가까운 곳에 위치해야 합니다. 다음 문단을 읽기 전에 그의 게시물을 읽는 것이 좋습니다... 기다릴게요 🙂

 

리액트와 코로케이션(Colocation in React)

동료들과 제가 동의한 것 중 하나는 DRY에 대한 지나친 욕망이었습니다.
앱의 다양한 부분에서 열어야 하는 콘텐츠가 있는 <Dialog>가 있었고,
우리는 이렇게 생각했습니다.
"같은 컴포넌트를 여러 곳에 배치할 필요가 있을까요? 이건 DRY가 아닌것 같아요."
전역 상태를 사용해 앱의 여러 부분에서 해당 상태를 변경할 수 있도록 합니다.
추상화가 시작됩니다.
 
명심하세요! 컴포넌트은 재사용을 위한 것입니다.
콘텐츠를 사용하는 ​​곳에서 멀리 옮기고 재사용하기 위해 많은 전역 상태를 도입할 필요가 있을까요?
 
여러 위치에서 컴포넌트의 단일 인스턴스를 제어하는 ​​대신
재사용할 UI 조각을 앱의 여러 위치에서 사용할 수 있는 컴포넌트로 번들링하여 DRY를 적용하면 어떨까요?
 

첫 번째 시도입니다.

 
모든 Dialog의 모양과 동작을 캡슐화하는 재사용 가능한 컴포넌트를 만들었습니다.
그런 다음 여러 위치에서 열어야 하는 모든 피드백 콘텐츠, 스타일 및 로직을 다루는 별도의 Feedback 컴포넌트를 만들었습니다.
이 컴포넌트 내부적으로 Dialog를 렌더링합니다.
이 예제의 첫 번째 문제는 사용자가 Dialog의 여러 인스턴스를 동시에 열 수 있다는 것입니다.
우리는 이것을 원하지 않으며 동시에 colocation 원칙을 고수할 것이기 때문에,
한 번에 하나의 인스턴스를 보장하기 위해 전역 상태를 사용할 수 없습니다.
전역 상태 없이 이를 달성하거나,
여러 렌더링을 하나의 인스턴스로 결합하는 방법을 생각해야 합니다.
이 경우 해결 방법은 누군가 Dialog 외부에서 무언가를 클릭하면 대화 상자를 닫는 것입니다.
이는 다른 Dialog를 여는 버튼이 될 수도 있겠죠
export const Dialog = ({ onClose, ...props }) => {
  const ref = React.useRef();

  React.useEffect(() => {
    const handleClick = event => {
      const dialog = ref.current;
      if (!dialog?.contains(event.target)) {
        onClose();
      }
    };
    document.addEventListener('click', handleClick, { capture: true });
    return () => {
      document.removeEventListener('click', handleClick, { capture: true });
    };
  });

  return ReactDOM.createPortal(<div {...props} ref={ref} />, document.body);
};

Dialog 컴포넌트에 몇 개의 이벤트 리스너를 추가했습니다.
전역 상태를 방지하는 방법을 생각하여 한 번에 하나만 열 수 있도록 허용하는 컴포넌트 요구 사항을 함께 배치할 수 있었습니다.


좀 더 복잡한 패턴(More complex patterns)

이 접근 방식을 적용하면 좋을더 복잡한 문제가 있는지 궁금하기 시작했고
일반적으로 글로벌 상태가 필요한 무언가를 떠올렸습니다.
사이드바이 속성 패널입니다.
Sketch와 같은 제품에서 볼 수 있는 UI 타입입니다.

Image of&nbsp;Modulz, an interface design tool.

제품 : Modulz

측면 패널은 많은 앱에서 내 인생의 골칫거리였습니다.
메인 영역에서 사이드바의 속성을 사용하여 무언가를 렌더링합니다.
이를 위해
  • 전역 상태가 화면에서 선택했다고 알려주는 내용에 따라 표시할 속성을 결정하는 많은 스위치 절을 도입합니다.
  • 화면에서 사물의 모양을 변경하기 위해 전역 상태 또는 props 드릴을 통해 선택된 속성을 전달합니다.

코드 측면에서도 트리 셰이킹하기 어려울 것입니다.
누군가 Rectangle(이미지 메인 화면 내부)을 렌더링하지 않는다 해도,
그 로직은 관계없이 직사각형을 처리하기 위해 앱 전체에 퍼질 것입니다.


해결책

이렇게 복잡한 것에 코로케이션을 어떻게 적용할 수 있을까요?
Dialog와 같은 아이디어를 적용해 보겠습니다.

왜 단일 PropertiesPanel 인스턴스를 재사용하려고 할까요?

  1. Rectangle과 관련된 내용만 재사용하고 싶습니다
  2. 실제로 재사용 하고 싶은것은 위치입니다.

첫 번째 요구 사항은 사각형이 재사용할 수 있는 각 속성 패널 부분에 대한 컴포넌트를 생성하여 해결되지만,
문제는 해당 코드를 Rectangle 컴포넌트와 함께 배치하면 사이드바에 표시되지 않는다는 것입니다.

잠깐만, Dialog는 컴포넌트 렌더링 위치와 상관없이 화면 중간에 나타났었죠?

컴포넌트 렌더링 위치와 돔이 삽입되는 위치와의 분리 > Portal입니다!

프로퍼티 패널이 포털로 들어갈 수 있는 사이드바용 빈 공간을 만들면,

코로케이션의 힘을 사용할 수 있습니다.

 
 
Rectangle을 컴파운드 컴포넌트(Compound Component)로 빌드해 보겠습니다.
각 앱이 프로퍼티 패널을 필요로 하는 모든 위치에서 프로퍼티 패널을 포털할 수 있습니다.
import {
  Styler,
  StylerSize,
  StylerBackgroundColor,
  StylerBorder,
  // StylerTextShadow - not necessary in Rectangle so will be tree-shaken
} from './Styler';

const RectangleContext = React.createContext({});

export const Rectangle = ({ children }) => {
  const ref = React.useRef(null);
  const [style, setStyle] = React.useState({});
  const [selected, setSelected] = React.useState(false);

  React.useEffect(() => {
    // click outside logic to set `selected` to `false`
  });

  return (
    <RectangleContext.Provider
      value={React.useMemo(
        () => ({ selected, style, onStyleChange: setStyle }),
        [style, selected],
      )}
    >
      <div style={style} ref={ref} onClick={() => setSelected(true)}>
        {children}
      </div>
    </RectangleContext.Provider>
  );
};
// 해당 컴포넌트는 패널에 그려집니다!!!
export const RectangleStyler = () => {
  const context = React.useContext(RectangleContext);
  return context.selected ? (
    <Styler style={context.style} onStyleChange={context.onStyleChange}>
      <StylerSize />
      <StylerBackgroundColor />
      <StylerBorder />
    </Styler>
  ) : null;
};

Rectangle 내부의 Styler 컴포넌트는 내부에서 렌더링한 프로퍼티를 기반으로 스타일 객체를 반환합니다.
이러한 속성이 변경되면 Rectangle의 style 속성을 업데이트합니다.

주 : 내부 Styler의 데이터를 이용해 외부 Rectangle의 스타일 업데이트

우리의 Sketch 열화버전에서 사용하기 전에 RectangleStyler를 래핑하면서,
사이드바에서 렌더링할 SidebarPortal 컴포넌트가 필요합니다.

주 :  RectangleStyler는 SidebarPortal 내부에서 그려집니다.
const SidebarContext = React.createContext([]);

export const SidebarProvider = props => {
  const sidebarState = React.useState(null);
  return <SidebarContext.Provider value={sidebarState} {...props} />;
};

export const Sidebar = () => {
  const [, setSidebar] = React.useContext(SidebarContext);
  return <div ref={setSidebar} />;
};

export const SidebarPortal = ({ children }) => {
  const [sidebar] = React.useContext(SidebarContext);
  return sidebar ? ReactDOM.createPortal(children, sidebar) : null;
};
아래와 같이 사용합니다.
// Main.js
export const Main = () => (
  <main>
    <Rectangle>
      <SidebarPortal>
        <RectangleStyler />
      </SidebarPortal>
    </Rectangle>
  </main>
);

// App.js
export const App = () => (
  <div>
    <SidebarProvider>
      <Main />
      <Sidebar />
    </SidebarProvider>
  </div>
);

그리고 짜잔! 직사각형을 클릭하면 사이드바에서 해당 속성이 열립니다.
하지만 아직 문제가 있습니다. 사이드바에서 스타일러와 상호 작용하면 닫힙니다. 

아래에서 테스트해 보세요.

이는 외부 클릭 로직 때문입니다.
rectangle.contains를 이용해 내부를 선택하는지를 파악하고 있습니다.
스타일러는 사이드바에 있기 때문에 패널이 닫히게 됩니다.

다행스럽게도 React는 사람들이 잘 모르는(rarely praised) 정말 편리한 기능을 가지고 있지만 이와 같이 배치할 때 매우 유용합니다. React 이벤트는 DOM에서 렌더링되는 위치에 관계없이, React 트리를 통해 버블링됩니다.
주 : DOM 위치와 관계없이 React Tree 내 관계에 따라 이벤트 버블링
 
 
사이드바의 클릭으로 인해 스타일러가 닫히지 않도록 이 기능을 활용할 수 있습니다.
export const Rectangle = ({ children }) => {
  const ref = React.useRef();
  const [style, setStyle] = React.useState({});
  const [selected, setSelected] = React.useState(false);
  const isClickInsideRef = React.useRef(false);

  React.useEffect(() => {
    const handleClick = () => {
      if (!isClickInsideRef.current) setSelected(false);
      isClickInsideRef.current = false;
    };
    document.addEventListener('click', handleClick);
    return () => {
      document.removeEventListener('click', handleClick);
    };
  });

  return (
    <RectangleContext.Provider
      value={React.useMemo(
        () => ({ selected, style, onStyleChange: setStyle }),
        [style, selected],
      )}
    >
      <div
        style={style}
        ref={ref}
        onClick={() => setSelected(true)}
        onClickCapture={() => (isClickInsideRef.current = true)}
      >
        {children}
      </div>
    </RectangleContext.Provider>
  );
};​

React 클릭 이벤트를 통해 isClickInside의 불리언 값을 설정합니다.
클릭이 RectangleStyler에서 발생하면,

해당 컴포넌트는 리액트 트리의 자식이며 Rectangle로 전파되기 때문에 true가 됩니다.

 

이 접근 방식은 코드베이스의 복잡성을 줄입니다.
PropertiesPanel 인스턴스에 더 이상 switch 문이 없습니다.
스타일 선택을 Rectangle에 연결하는 전역 상태가 더 이상 없습니다.
Rectangle이 React.lazy를 사용하여 조건부로 렌더링되면 모든 것이 트리 셰이킹 됩니다.
또한 다른 컴포넌트에서 불필요한 다시 렌더링을 방지합니다.
사이드바 패널에서 RectangleStyler를 변경하는 경우 Rectangle 부분만 다시 렌더링됩니다.

더 나아가기

우리는 현재 이 직사각형을 항상 렌더링하고 있지만,
Sketch와 같은 앱에서는 일반적으로 "사각형 추가" 버튼을 통해 추가됩니다.
Rectangle이 렌더링되어야 하는지 여부를 결정하는 일부 상태를 사용하여 앱에 해당 논리를 추가할 수 있지만
이는 논리가 컴포넌트에서 분리되어야 하며, 다시 코로케이션을 잃을 수 있음을 의미합니다.
 
Trigger를 Rectangle 컴포넌트의 일부로 캡슐화하여 더 많은 작업을 수행할 수 있으며,
클릭 시 Main에서 Rectangle을 렌더링하기 위해 Portal 기술을 재사용할 수 있습니다.
 
이미 설명한 기술을 대부분 재사용하기 때문에, 이 예제의 세부 사항에 대해 자세히 설명하지 않겠습니다.
여기에 완성된 예제가 있습니다.
사각형을 선택하고 Backspace를 누르면 상자를 삭제합니다.
현재로서는 한 번에 하나의 Rectangle만 추가할 수 있습니다.
이것은 렌더 prop으로 해결할 수 있습니다. 이는 다른 게시물에서 설명하겠습니다.
 
이제 사각형 관련 속성이나 논리를 약간 변경해야 하는 경우
Rectangle 컴포넌트를 열기만 하면 됩니다.
모든 것이 거기에 있습니다. 다른 곳을 뒤질 필요가 없으며
변경 사항은 다른 기능에 영향을 미치지 않습니다.
나중에 Rectangle을 제거해야 하는 경우,
인스턴스 재사용 및 전역 상태 접근 방식을 사용했다면,
컴포넌트 트리 전체에서 모든 사각형 관련 로직을 제거해야 할 것입니다.
그러나 colocation을 사용하면
Rectangle.js와 함께 <Rectangle />에 대한 참조를 제거하면 완료됩니다.

Uncontrolled Compound Components

React에서 이 colocation 기술을 Uncontrolled Compound Component 패턴으로 언급하기 시작했습니다.
자신의 관심사와 관련된 모든 작업을 수행하는 컴포넌트 입니다.
 

다음은 Feedback 컴포넌트로 Dialog를 사용하여 코로케이션 원칙을 적용하는 방법을 보여줍니다.

import * as Dialog from '@radix-ui/react-dialog';

export const Feedback = ({ children, ...props }) => (
  <Dialog.Root>
    <Dialog.Trigger asChild>{children}</Dialog.Trigger>
    <Dialog.Content {...props}>
      <h2>Thoughts?</h2>
      <form>
        <textarea />
        <button>Submit feedback</button>
      </form>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Root>
);

// render (
// 	<Feedback>
//		<PrimaryButton>Feedback</PrimaryButton>
//	</Feedback>
//
//  ...
//
// 	<Feedback>
//		<SecondaryButton>Feedback</SecondaryButton>
//	</Feedback>
// )

결론

코로케이션은 복잡성을 크게 줄일 수 있습니다.

이것이 인스턴스 재사용보다 컴포넌트 재사용을 선호하는

애플리케이션에 원칙을 적용하는 데 영감을 주었으면 합니다.

 

 

반응형