Typescript & Tailwind CSS 사용해 만든 재사용 가능한 Button Components 구현
→ Button component는 자주 사용되는 UI 요소 중 하나로, 재사용 가능하고 유지보수성 높은 코드를 작성해 개발 생산성을 높이도록 해보자!
import React, { ReactNode } from "react";
import { Loader2 } from 'lucide-react';
import Typography from './typography';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?:
| 'primary'
| 'secondary'
| 'stroke'
| 'ghost'
...;
size?: 'default' | 'xs' | 'sm' | 'lg';
icon?: {
position?: 'left' | 'right';
children: ReactNode;
size?: 'iconXS' | 'iconS' | 'iconM' | 'iconL';
};
iconOnly?: boolean;
className?: string;
loading?: boolean;
asChild?: boolean;
}
const variantStyle = {
primary:
'bg-primary hover: active: disabled: ...',
secondary:
'bg-primary-10% hover: active: disabled: ...',
stroke:
'bg-white hover: active: disabled: ...',
ghost:
'text-primary hover: active: disabled: ...',
...
}
const sizeStyle = {
xs: 'h-6 px-3 py-1',
sm: '...',
default: '...',
lg: '...',
};
const iconSizeStyle = {
iconXS: 'h-6 p-2',
iconS: '...',
iconM: '...',
iconL: '...',
};
const typographyVariants = {
xs: 'buttonXS',
sm: 'buttonS',
default: 'buttonM',
lg: 'buttonL',
} as const;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'default',
icon,
className,
children,
loading = false,
asChild = false,
iconOnly = false,
...props
},
ref,
) => {
const calcPadding = () => {
if (iconOnly) return '';
if (icon && icon.position) {
if (icon.position === 'right') {
if (size === 'xs') return 'pr-2';
if (size === 'sm') return '...';
if (size === 'default') return '...';
if (size === 'lg') return '...';
}
if (icon.position === 'left') {
if (size === 'xs') return 'pl-2';
if (size === 'sm') return '...';
if (size === 'default') return '...';
if (size === 'lg') return '...';
}
}
return '';
};
const calcSize = iconOnly
? iconSizeStyle[icon?.size || 'iconM']
: sizeStyle[size];
return (
<button
ref={ref}
className={
baseStyle,
variantStyle[variant],
sizeStyle[size],
calcSize,
calcPadding(),
className,
loading && 'pointer-events-none',
}
disabled={loading}
{...props}
>
{loading && <Loader2 className="animate-spin" />}
<span
className={
'flex items-center',
children ? 'gap-2' : '',
icon?.position === 'right' ? 'flex-row-reverse' : 'flex-row',
}
>
{icon && <span>{icon.children}</span>}
<Typography variant={typographyVariants[size]}>{children}</Typography>
</span>
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
리팩토링 된 Button 컴포넌트
스타일 상수화
→ 스타일 관련 객체 이름을 BUTTON_VARIANTS, BUTTON_SIZES 등으로 변경해 명확한 의미 전달
as const 추가
→ 타입 안전성 강화, 객체 값들을 불변으로 처리
const BUTTON_VARIANTS = {
base: 'flex items-center justify-center whitespace-nowrap rounded-lg',
primary:
'bg-primary hover: active: disabled: ...',
...
} as const;
const BUTTON_SIZES = {
xs: 'h-6 px-3 py-1',
...
} as const;
const ICON_SIZES = {
iconXS: 'h-6 p-2',
...
} as const;
const TYPOGRAPHY_VARIANTS = {
xs: 'buttonXS',
...
} as const;
계산 로직 간소화
→ paddingMap 객체를 사용해 중복 코드 제거
const calcPadding = () => {
if (iconOnly) return '';
if (icon && icon.position) {
const paddingMap = { xs: '2', sm: '', default: '', lg: '' };
return icon.position === 'right'
? `pr-${paddingMap[size]}`
: `pl-${paddingMap[size]}`;
}
return '';
};
리팩토링 된 Button 컴포넌트
import React, { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import Typography from './typography';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?:
| 'primary'
| 'secondary'
| 'stroke'
| 'ghost'
;
size?: 'default' | 'xs' | 'sm' | 'lg';
icon?: {
position?: 'left' | 'right';
children: ReactNode;
size?: 'iconXS' | 'iconS' | 'iconM' | 'iconL';
};
iconOnly?: boolean;
className?: string;
loading?: boolean;
asChild?: boolean;
}
const BUTTON_VARIANTS = {
base: 'flex items-center justify-center whitespace-nowrap rounded-lg',
primary:
'bg-primary hover: active: disabled: ...',
secondary:
'bg-primary-10% hover: active: disabled: ...',
stroke:
'bg-white hover: active: disabled: ...',
ghost:
'text-primary hover: active: disabled: ...',
...
} as const;
const BUTTON_SIZES = {
xs: 'h-6 px-3 py-1',
sm: '...',
default: '...',
lg: '...',
} as const;
const ICON_SIZES = {
iconXS: 'h-6 p-2',
iconS: '...',
iconM: '...',
iconL: '...',
} as const;
const TYPOGRAPHY_VARIANTS = {
xs: 'buttonXS',
sm: 'buttonS',
default: 'buttonM',
lg: 'buttonL',
} as const;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'default',
icon,
className,
children,
loading = false,
asChild = false,
iconOnly = false,
...props
},
ref,
) => {
const calcPadding = () => {
if (iconOnly) return '';
if (icon && icon.position) {
const paddingMap = { xs: '2', sm: '', default: '', lg: '' };
return icon.position === 'right'
? `pr-${paddingMap[size]}`
: `pl-${paddingMap[size]}`;
}
return '';
};
const calcSize = iconOnly
? ICON_SIZES[icon?.size || 'iconM']
: BUTTON_SIZES[size];
return (
<button
ref={ref}
className={
BUTTON_VARIANTS.base,
BUTTON_VARIANTS[variant],
BUTTON_SIZES[size],
calcSize,
calcPadding(),
className,
loading && 'pointer-events-none',
}
disabled={loading}
{...props}
>
{loading && <Loader2 className="w-5 h-5 mr-2 animate-spin" />}
<span
className={
'flex items-center',
children ? 'gap-2' : '',
icon?.position === 'right' ? 'flex-row-reverse' : 'flex-row',
}
>
{icon && <span>{icon.children}</span>}
<Typography variant={TYPOGRAPHY_VARIANTS[size]}>
{children}
</Typography>
</span>
</button>
);
},
);
Button.displayName = 'Button';
export default Button;
Button 컴포넌트를 활용하여 다양한 텍스트 스타일을 쉽게 적용할 수 있음
import Typography from '@/components/Typography';
export default function Page() {
return (
<div>
<Button variant="primary" size="default">Primary Button</Button>
<Button variant="stroke" size="sm" icon={{ position: 'left', children: <IconName /> }}>
Button with Icon
</Button>
<Button iconOnly icon={{ size: 'iconM', children: <IconName /> }} variant="ghost" />
<Button variant="primary" size="lg" loading>Loading...</Button>
<Button
variant="secondary"
size="default"
className="bg-blue-500 hover:bg-blue-600 text-white"
>
Customized Button
</Button>
</div>
);
}
Button 컴포넌트를 구현하면서, 단순히 버튼을 클릭하는 기능만 고려하는 것이 아닌 다양한 상태와 아이콘, 사용자 정의 스타일을 지원하고 유지보수하기 쉽도록 구조화하는데 많은 고민을 하였다.
variant, size 등 다양한 속성을 선언적으로 관리함으로써 코드 중복을 최소화하고, 새로운 요구사항이 생겼을 때도 쉽게 확장할 수 있었다.variantStyle, sizeStyle, iconSizeStyle로 구분해, 코드가 명확해지고 다른 컴포넌트에서도 유사한 패턴으로 쉽게 재활용할 수 있었다BUTTON_VARIANTS, BUTTON_SIZES, ICON_SIZES 등)를 명확한 상수로 분리하면서, 전체적인 코드 구조가 명료해지고 이해하기 쉬워졌다.as const를 사용해 객체의 불변성을 보장함으로써 타입 관련 오류를 사전에 방지할 수 있었다.