Tech/ReactJS

컴파운드 컴포넌트로 재사용성과 가독성 높이기

닝닝깅 2024. 5. 9. 10:29

🚨 고민 발생

해당 컴포넌트는 공통 컴포넌트로 만들어져있다.

여러 곳에서 사용되기 때문에 공통 컴포넌트로 분리했던 건데,, 문제가 생겼다.

사용처마다 스타일이나 요소 배치 등의 컴포넌트 요구사항이 조금씩 다르는 것이다.

 

결국 여러 조건을 걸 수밖에 없었고 아래와 같이 꽤 복잡한 공통 컴포넌트가 만들어졌다.

const ProductBoardItem = forwardRef<HTMLDivElement, IProductBoardItem>(
  (
    { item, children, checkHandler, checkedItems, selectedCnt, isCart },
    ref
  ) => {
    return (
      <div className={`flex py-2 ${isCart ? "gap-2" : "gap-5"}`} ref={ref}>
        <Checkbox
          onCheckedChange={(checked) => {
            checkHandler(
              checked as boolean,
              selectedCnt
                ? { itemId: item.id, itemCount: selectedCnt }
                : { itemId: item.id }
            );
          }}
          checked={checkedItems.map((el) => el.itemId).includes(item.id)}
        />
        <div className={`${isCart ? "w-32" : "w-28"} aspect-square`}>
          <img
            src={item.productThumbnail}
            className="object-cover w-full h-full rounded-sm"
          />
        </div>
        <div className="flex flex-col gap-1 justify-between w-full py-1">
          <span className={`${isCart ? "text-sm" : "text-md"}`}>
            {item.productName}
          </span>
          {isCart ? (
            <>
              <span className={`${isCart ? "text-[11px]" : "text-sm"}`}>
                {convertPriceUnit(item.productPrice)}원 · 남은수량:{" "}
                {item.productQuantity}개
              </span>
              {children}
            </>
          ) : (
            <>
              <span className={`${isCart ? "text-xs" : "text-sm"}`}>
                {convertPriceUnit(item.productPrice)}원
              </span>
              <div className="flex items-end justify-between">
                <p className="text-xs">남은수량: {item.productQuantity}개</p>
                {children}
              </div>
            </>
          )}
        </div>
      </div>
    );
  }
);

export default ProductBoardItem;

 

공통 컴포넌트 내부 변경되는 부분을 children으로 받아 렌더링시키니 코드 이해도도 많이 떨어진다고 느꼈다.

<ProductListItem
  item={item}
  ref={lastItemRef}
  checkHandler={handleSingleCheck}
  checkedItems={checkedItems}
>
  <div className="flex gap-1">
    <AdminBoardEditBtn productId={item.id} />
    <AdminBoardDeleteBtn deleteHandler={() => onClickItemDelete(item.id)} />
  </div>
</ProductListItem>;

 

 

📍 고민해결 방법

두가지 방법으로 해결할 수 있을 것 같았다.

 

1) HOC 사용

- 컴포넌트를 함수로 받아 새로운 컴포넌트 반환

- 컴포넌트 기능 확장에 유리

 

2) 컴파운드 컴포넌트 패턴 사용

- props로 내려오는 데이터 없이 내부 context를 사용하여 내부에서 데이터 처리

- context를 공유하는 작은 단위의 컴포넌트로 이루어져있어 자유롭게 사용 / 여러 하위 컴포넌트와 상태 공유에 유리

 

HOC는 기존 기능에 확장하는 경우 사용하는 게 좋을 것 같았고, 디자인적 자유도도 없어보였다.

좀 더 유연한 변경이 가능하면서도 컴포넌트의 기능적 가독성이 향상된 컴파운드 컴포넌트가 더 적합하다고 생각했다!

컴파운드 컴포넌트 패턴을 사용하자!!!

 

 

📍 컴파운드 컴포넌트 도입 과정

1. 내부 context 생성

데이터 공유를 위한 내부 context를 만든다.

const ProductListItemContext = createContext<IProductListItem>({
  item: {
    productThumbnail: "",
    productImage: [],
    productName: "",
    productCategory: "",
    productPrice: 0,
    productQuantity: 0,
    productDescription: "",
    sellerId: "",
    createdAt: "",
    updatedAt: "",
    id: "",
  },
  checkHandler: () => {},
  checkedItems: [],
});

const useProductListItem = () => useContext(ProductListItemContext);

 

2. 최상위 컴포넌트 Root 생성

하위 컴포넌트들이 context에 접근할 수 있도록 생성한다.

const Root = ({
  item,
  children,
  checkHandler,
  checkedItems,
  selectedCnt,
  isCart,
  lastItemRef,
  gapSize,
}: IProductListItemWrapper) => {
  const value = { item, checkHandler, checkedItems, selectedCnt, isCart };
  return (
    <ProductListItemContext.Provider value={value}>
      <div className={`flex py-2 gap-${gapSize}`} ref={lastItemRef}>
        {children}
      </div>
    </ProductListItemContext.Provider>
  );
};

 

3. 기능별로 하위 컴포넌트 생성

const Image = ({ ...props }) => {
  const { item } = useProductListItem();
  return (
    <div {...props}>
      <img
        src={item.productThumbnail}
        className="object-cover w-full h-full rounded-sm"
      />
    </div>
  );
};
const Name = ({ ...props }) => {
  const { item } = useProductListItem();

  return <span {...props}>{item.productName}</span>;
};

 

더보기

전체코드

 

import { createContext, memo, useContext } from "react";
import { ICheckedItem } from "../../../../hooks/useCheckboxSelection";
import { IProductResData } from "../../../../types/product";
import { convertPriceUnit } from "../../../../utils/convertPriceUnit";
import { Checkbox } from "../../../ui/checkbox";

interface IProductListItem {
  item: IProductResData;
  checkHandler: (isCheck: boolean, currentItem: ICheckedItem) => void;
  checkedItems: ICheckedItem[];
  selectedCnt?: number;
  isCart?: boolean;
  lastItemRef?: (node?: Element | null | undefined) => void;
}

interface IProductListItemWrapper extends IProductListItem {
  children: React.ReactNode;
  gapSize: string;
}

const ProductListItemContext = createContext<IProductListItem>({
  item: {
    productThumbnail: "",
    productImage: [],
    productName: "",
    productCategory: "",
    productPrice: 0,
    productQuantity: 0,
    productDescription: "",
    sellerId: "",
    createdAt: "",
    updatedAt: "",
    id: "",
  },
  checkHandler: () => {},
  checkedItems: [],
});

const useProductListItem = () => useContext(ProductListItemContext);

const Root = ({
  item,
  children,
  checkHandler,
  checkedItems,
  selectedCnt,
  isCart,
  lastItemRef,
  gapSize,
}: IProductListItemWrapper) => {
  const value = { item, checkHandler, checkedItems, selectedCnt, isCart };
  return (
    <ProductListItemContext.Provider value={value}>
      <div className={`flex py-2 gap-${gapSize}`} ref={lastItemRef}>
        {children}
      </div>
    </ProductListItemContext.Provider>
  );
};

const CheckBox = () => {
  const { checkHandler, selectedCnt, item, checkedItems } =
    useProductListItem();

  return (
    <Checkbox
      data-testid="checkbox"
      onCheckedChange={(checked: any) => {
        checkHandler(
          checked as boolean,
          selectedCnt
            ? { itemId: item.id, itemCount: selectedCnt }
            : { itemId: item.id }
        );
      }}
      checked={checkedItems.map((el) => el.itemId).includes(item.id)}
    />
  );
};

const Image = ({ ...props }) => {
  const { item } = useProductListItem();
  return (
    <div {...props}>
      <img
        src={item.productThumbnail}
        className="object-cover w-full h-full rounded-sm"
      />
    </div>
  );
};

const Name = ({ ...props }) => {
  const { item } = useProductListItem();

  return <span {...props}>{item.productName}</span>;
};

const Price = ({ ...props }) => {
  const { item } = useProductListItem();

  return <span {...props}>{convertPriceUnit(item.productPrice)}</span>;
};

const LeftQuantity = ({ ...props }) => {
  const { item } = useProductListItem();
  return <p {...props}>남은수량: {item.productQuantity}</p>;
};

export default {
  Root: memo(Root),
  CheckBox: memo(CheckBox),
  Image: memo(Image),
  Name: memo(Name),
  Price: memo(Price),
  LeftQuantity: memo(LeftQuantity),
};

 

📍 컴파운드 컴포넌트 도입 결과

아래는 컴파운드 컴포넌트를 활용한 결과다. 깔끔하고 가독성이 좋게 변경된 것을 얼핏봐도 확인할 수 있다.

새로운 사용처에서 요구사항이 변경되면 항상 조건을 추가하기 바빴는데, 컴파운드컴포넌트를 통해 개발 효율도 올라간 것 같아 앞으로 많이 사용하지 않을까 싶다!

 

  return (
    <ListItem.Root
      item={item}
      checkHandler={handleSingleCheck}
      checkedItems={checkedItems}
      lastItemRef={lastItemRef}
    >
      <ListItem.CheckBox />
      <ListItem.Image />
      <div className="flex flex-col gap-1 justify-between w-full py-1">
        <ListItem.Name />
        <ListItem.Price />
        <div className="flex items-end justify-between">
          <ListItem.LeftQuantity />
          <div className="flex gap-1">
            <AdminBoardBtn name="미리보기" onClickHandler={onClickPreview} />
            <AdminBoardBtn name="수정" onClickHandler={onClickEdit} />
            <AdminBoardDeleteBtn
              deleteHandler={() => onClickItemDelete(item.id)}
            />
          </div>
        </div>
      </div>
    </ListItem.Root>
  );