프로젝트를 진행하면서 북마크 컴포넌트를 구현하면서 고려했던 점은 아래와 같다.
1. UI와 로직의 분리
Headless 컴포넌트 방식으로 UI와 로직을 분리했다. 북마크의 on/off 상태를 관리하는 로직은 별도의 `useSwitch` 훅으로 처리하고 Bookmark 컴포넌트는 UI만 담당하도록 설계
2. 확장성을 고려해보자!
- cva를 사용하여 다양한 레이아웃과 상태에 따른 스타일 관리
- 북마크에 국한되지 않고 다양한 아이콘을 사용할 수 있는 Checkbox 형태로 구현
import { cx } from "class-variance-authority";
import { BookmarkFill, BookmarkOutline } from "../icons";
const getContainerClass = (isOn: boolean) =>
cx("flex gap-2 px-4 py-3 border rounded w-fit items-end justify-center", {
"bg-white text-body": !isOn,
"border-stroke-100": !isOn,
"bg-primary-active border-primary-active text-white": isOn,
});
const getTextClass = (isOn: boolean) =>
cx("font-bold leading-4", {
"text-body": !isOn,
"text-white": isOn,
});
interface BookmarkProps {
isOn: boolean;
onSwitch: () => void;
}
const Bookmark = ({ isOn, onSwitch }: BookmarkProps) => {
return (
<div onClick={onSwitch} className={getContainerClass(isOn)}>
{isOn ? (
<BookmarkFill className="text-white" />
) : (
<BookmarkOutline className="text-body" />
)}
<span className={getTextClass(isOn)}>북마크</span>
</div>
);
};
export default Bookmark;
처음에는 이런 식으로 구현했다!
그런데 내가 구현해야 하는 북마크는 레이아웃이 2가지였지만, 가로 레이아웃만 구현한 상태다.
레이아웃을 추가하면서 cx을 사용한 부분을 cva를 사용하도록 수정했다.
const containerVariants = cva(
[
"flex px-4 py-3 rounded w-fit justify-center transition-colors duration-300",
],
{
variants: {
layout: {
col: ["flex-col gap-2 items-center"],
row: ["flex-row gap-2 border items-end"],
},
isOn: {
true: [],
false: [],
},
},
compoundVariants: [
{
layout: "row",
isOn: true,
class: "bg-primary-active border-primary-active",
},
{
layout: "row",
isOn: false,
class: "bg-white border-stroke-100",
},
],
}
);
const labelVariants = cva(["transition-colors duration-300"], {
variants: {
layout: {
row: ["font-bold leading-[18px]"],
col: ["text-sm font-normal text-caption"],
},
isOn: {
true: [],
false: [],
},
},
compoundVariants: [
{
layout: "row",
isOn: true,
class: "text-white",
},
{
layout: "row",
isOn: false,
class: "text-body",
},
],
});
const iconVariants = cva(["transition-colors duration-300"], {
variants: {
layout: {
row: "w-5 h-5",
col: "w-8 h-8 fill-primary-active",
},
isOn: {
true: [],
false: [],
},
},
compoundVariants: [
{ layout: "row", isOn: true, class: "fill-white" },
{ layout: "row", isOn: false, class: "fill-body" },
],
});
layout 별로 on/off 됐을 때 디자인이 꽤 달랐어서 이를 모두 구현해보니 이렇게 길어졌다.
이번에 cva를 처음 사용해보는데, 이렇게 복잡한 스타일을 처리할 때 정말 유용한 것 같다!
아이콘 변경 가능한 체크박스
추가적으로, 가장 처음 Bookmark를 구현했을 때는 아이콘을 import 해서 사용했는데 생각해보니 북마크도 체크박스의 일종이고 다른 아이콘을 사용하는 체크박스도 추후에 추가될 수도 있겠다! 라는 생각이 들었다.
그래서 CheckboxBase 컴포넌트를 도입해, 아이콘을 함수로 렌더링할 수 있도록 변경했다.
const CheckboxBase = ({
isOn,
onSwitch,
layout = "row",
label,
renderIcon,
}: CheckboxProps) => {
return (
<button
type="button"
onClick={onSwitch}
className={containerVariants({ layout, isOn })}
>
{renderIcon(iconVariants({ layout, isOn }))}
{label && (
<span className={labelVariants({ layout, isOn })}>{label}</span>
)}
</button>
);
};
export default CheckboxBase;
on/off와 layout에 따라 icon의 디자인도 바뀌어야 했기 때문에, 이와 같이 아이콘을 렌더링하는 함수를 props로 입력받았다.
처음에는 `cloneElement`를 사용했지만 `cloneElement`는 React에서 추천하지 않는 방식이다!
그래서 대안으로 제시해준 방법으로 구현을 했다.
컴포넌트 사용 시에는 이렇게 함수를 전달한다.
import { BookmarkFill, BookmarkOutline } from "../../icons";
import CheckboxBase, { CheckboxLayout } from "./CheckboxBase";
interface BookmarkProps {
isOn: boolean;
onSwitch: () => void;
layout?: CheckboxLayout;
}
const Bookmark = ({ isOn, onSwitch, layout }: BookmarkProps) => {
return (
<CheckboxBase
isOn={isOn}
onSwitch={onSwitch}
renderIcon={(className: string) =>
isOn ? (
<BookmarkFill className={className} />
) : (
<BookmarkOutline className={className} />
)
}
label="북마크"
layout={layout}
/>
);
};
export default Bookmark;
회고
복잡한 스타일을 효율적으로 관리하기 위해 `cva`를 사용했고, 아이콘을 유연하게 처리하기 위해 `CheckboxBase` 컴포넌트를 통해 `Bookmark` 컴포넌트를 구현했다. 좋은 설계인지는 모르겠지만, 글 초반에 언급한 2가지 고려사항은 부합한 것 같다. (혹시, 이 글을 보신 분이 있다면 피드백 부탁드립니다.. 감사합니다.🥹)
체크박스 컴포넌트를 만들면서 svg를 컴포넌트처럼 사용할 수 있도록 svgr도 도입하고, storybook을 이용해 테스트도 해봤다. 둘 다 처음 사용해보는데, Storybook은 진입장벽이 존재했다. 버전이 올라가면서 사용 방법이 많이 바뀐 것 같아서 다른 블로그에서는 레거시 방식으로 설명하는 경우가 많아서 공식문서를 확인해야 했다. 할 땐 어려웠지만 확실히 테스트가 많이 편하다!! 매번 빈 화면에 컴포넌트 띄우고 테스트 했는데 한 곳에 모아서 공통 컴포넌트를 관리할 수 있다니!