이번에 회사에서 디자인 시스템까지는 아니지만 다양한 공통 컴포넌트를 설계하였고, 해당 업무를 진행하면서 느낀점을 작성해보려합니다.
해당 컴포넌트 어떤 레벨인가?
먼저 공통 컴포넌트로 구현할 컴포넌트가 어느정도 레벨의 컴포넌트인지 파악해야합니다.
여기서 말하는 레벨이란 간단한게 3단계로 나눌 수 있을거같습니다
- 기본 레벨
- 버튼, 입력 필드, 토글 등 일반적인 UI 요소
- 중간 레벨
- 모달, 카드, 네비게이션 바 등 여러 개의 기본 블록이 결합된 UI 컴포넌트
- 애플리케이션 레벨
- 사용자 프로필 카드, 쇼핑 카트, 데이터 테이블 등 특정 도메인과 밀접하게 연결된 컴포넌트
간단한 예시를 들어본다면 `<Button>`은 일반적으로 가장 저수준의 컴포넌트입니다.
이 컴포넌트 다른 컴포넌트에서 일종의 레고처럼 하나의 블럭으로 사용될 수 있고, 단독으로 사용될 수도 있습니다.
이러한 레벨의 컴포넌트들의 특징은 애플레케이션(또는 도메인)과 크게 연관성이 없어야합니다.
다른말로 해당 컴포넌트를 그대로 뜯어서 다른 프로젝트에서 사용 가능할 수 있어야합니다.
다음 레벨의 예시로는 `<IconButton>`이 될수있습니다.
해당 컴포넌트는 아이콘이 포함된 버튼이라는 특정 요구사항에 맞춰진 컴포넌트이지만, 특정 애플리케이션에 크게 종속적이지는 않습니다.
마지막 레벨의 예시로는 `<SaveProfileButton>`이 될수있습니다.
해당 컴포넌트는 Profile 페이지에서 프로필을 저장하는 버튼으로 요구사항에 맞춰 저장 중에 버튼 내의 특정 애니메이션 등을 보여주는 등의 역할을 할수있고, 이는 애플리케이션에 또는 특정 요구사항에 크게 종속적입니다.
이렇게 3단계로 나눠서 컴포넌트를 개발하면 아래와 같은 이점을 얻을 수 있습니다.
`<IconButton>`, `<SaveProfileButton>` 모두 `<Button>` 컴포넌트 기반으로 변형/확장을 통해 만들어질것입니다.
그러면 새로운 요구사항이 생길 때마다 기본 컴포넌트를 재사용하고 확장하여 손쉽게 새로운 컴포넌트를 개발할 수 있습니다. 이는 코드의 중복을 줄이고 개발 시간을 단축시킵니다.
또한 기존 컴포넌트에 대한 변경 요구사항이 있을 경우, 기본 <Button> 컴포넌트만 수정하면 이를 기반으로 한 모든 컴포넌트에 변경 사항이 자동으로 반영됩니다. 이렇게 하면 애플리케이션 전반에 걸쳐 일관된 UI와 기능을 유지할 수 있으며, 수정 사항을 개별적으로 적용해야 하는 번거로움을 줄일 수 있습니다.
Props 활용하기
공통 컴포넌트에서 `props`는 핵심입니다.
공통 컴포넌트의 최종 목적은 미리 구축해둔 컴포넌트를 여러 곳에서 사용할 수 있어야함 입니다.
근데 공통 컴포넌트 내부 로직을 아무리 잘 구축해도 모든 상황을 대처하는 것에는 한계가 존재합니다.
예를들어 보겠습니다
const Button = () => {
return (
<button className="rounded-lg px-4 py-2 text-sm font-semibold">
버튼
</button>
)
}
피그마를 둘러보니 이 스타일을 가지고 있는 버튼이 많아서 이것을 공통 컴포넌트로 분리하였습니다,,
이렇게 끝내면 이건 공통 컴포넌트라고 할 수 없을거같습니다
당장 피그마에서 padding을 24px을 준 버튼이 나오기만 해도 이 컴포넌트 사용 불가능해집니다.
즉 유연성과 재사용성 측면에서 매우 떨어집니다.
이를 개선하기 위해서 `props`를 적극 활용해볼 수 있습니다
import { ComponentProps } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
type ButtonProps = ComponentProps<'button'>;
const Button = ({ className, children, ...props }: ButtonProps) => {
return (
<button
{...props}
className={twMerge(clsx('rounded-lg px-4 py-2 text-sm font-semibold', className))}
>
{children}
</button>
);
};
이렇게 `button` 태그의 기본 props만을 받아도 유연성이 크게 개선됩니다
해당 컴포넌트를 사용하는 컴포넌트에서 추가적인 스타일링도 가능하고, 버튼안에 렌더링할 엘리먼트도 정의가 가능해집니다.
조금 더 개선해보겠습니다
예를들어 피그마에서 "A", "B", "C", "D" 이런 4개의 타입의 버튼이 존재한다고 가정하면 이러한 타입에 대한 스타일을 내부에서 정의해놓고, 외부에서 타입을 선택하게만 하면 되게하면 사용하는 입장에서는 매우 편할것입니다.
import { ComponentProps } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, VariantProps } from 'class-variance-authority';
const buttonVariants = cva('rounded-lg px-4 py-2 text-sm font-semibold', {
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
typeA: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
typeB: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
typeC: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
typeD: 'text-primary underline-offset-4 hover:underline',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
});
type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants>;
const Button = ({ className, children, variant, ...props }: ButtonProps) => {
return (
<button {...props} className={twMerge(clsx(variant, className))}>
{children}
</button>
);
};
forwardRef 활용
마지막으로 개선해보겠습니다.
리액트에는 `ref`라는 특수한 속성이 존재합니다.
이 속성은 다른 속성들과 다르게 일반적인 props 전달 방법으로는 전달이 되지 않습니다
하지만 공통 컴포넌트는 열려있어야합니다. 즉 어떤 요구사항에 의해 `ref`를 사용해야하지만 공통 컴포넌트에서 `ref`를 받는 처리를 해놓지 않으면 이러한 요구사항 반영이 불가능합니다
따라서 `forwardRef`를 사용해서 ref를 받아야합니다
import { ComponentProps, forwardRef } from 'react';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cva, VariantProps } from 'class-variance-authority';
const buttonVariants = cva('rounded-lg px-4 py-2 text-sm font-semibold', {
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
typeA: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
typeB: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
typeC: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
typeD: 'text-primary underline-offset-4 hover:underline',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
});
type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants>;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, variant, ...props }, ref) => {
return (
<button {...props} ref={ref} className={twMerge(clsx(variant, className))}>
{children}
</button>
);
}
);
Button.displayName = 'Button';
논의사항
사실 위의 공통 컴포넌트를 정의할 때, 저는 개인적인 선호로 props로 `className`을 열어주었습니다.
하지만 이것을 열지말지는 꽤 논쟁거리입니다.
props로 `className`을 열어주는 방식말고 특정 스타일에 대한 custom props를 열어주는 방법도 존재합니다
예를들어 아래와 같습니다
type ButtonProps = {
flex: "col" | "row"
m: number
p: number
..
}
이 두 방식을 비교해보겠습니다
1. 구체적인 custom props를 사용
공통 컴포넌트를 만들 때, 보통 이를 디자인 시스템에 따라 구현하게 됩니다.
디자인 시스템은 각 컴포넌트를 어떻게 사용할지에 대한 가이드라인과 규칙을 제공하는 문서입니다. 이 시스템은 어떤 변경이 허용되고, 어떤 변경이 허용되지 않는지를 명시합니다. 우리가 구현하는 컴포넌트는 디자인 시스템에서 허용된 커스터마이징만 허용해야 합니다.
즉, 외부에서 특정 스타일링을 추가/변경/삭제하는 것은 이러한 디자인 시스템이 반대되는 행동입니다.
이러한 행동은 일관성을 깰수도있습니다.
또한, 컴포넌트는 마크업, 로직, 스타일을 캡슐화하는 것이 목적입니다. 개발자가 원하는 스타일을 적용할 수 있다면, 우리는 진정한 캡슐화를 하지 못한 것입니다. 디자인 시스템이 있다면, 개발자가 마음대로 스타일을 바꿀 수 있는 것을 막아야 합니다.
2. className props를 사용
실제로 프로젝트를 구현하다 보면 구체적인 custom props 방식을 모든곳에 적용하는게 굉장히 까다로울 수 있습니다
왜냐하면 css 굉장히 방대합니다. 즉 정말 유연한 공통 컴포넌트를 만들려면 custom props를 수십개를 뚫어줘야합니다
또한, 피그마 또는 다지이너가 예외의 상황에 대한 디자인을 제시하게되면 단순히 custom props를 뚫는것으로는 해결이 불가능할수 있습니다.
그러면 결국 DOM 접근등의 우회적인 방법을 통해서 해결해야합니다... 이 말은 다르게 말하면 아무리 props로 뚫지 않아도 마음만 먹으면 외부에서 스타일링 변경이 가능하다는 말도 됩니다
저는 이러한 이유를 2번의 방식을 선호합니다.
하지만 팀의 상황 프로젝트의 규모등에 따라 달라질것입니다.
'React' 카테고리의 다른 글
[React] useEffect vs useLayoutEffect (0) | 2024.09.30 |
---|---|
[React] Suspense 이해하고 활용하기 02 - Promise를 던지는 방법, use 훅 (2) | 2024.09.26 |
[React] React Query 알아보기 01 - 왜 React Query를 사용해야하는지 (0) | 2024.09.18 |
[React] Suspense 이해하고 활용하기 01 - Suspense를 활용하면 스피너 지옥에서 탈출할 수 있습니다 (0) | 2024.09.18 |
[React] 사이드 이펙트는 렌더링에 영향을 주지 말아야합니다 (0) | 2024.09.16 |