2023년 01월 04일
6

컴포넌트 다루기

Frontend
KKingmo

Changmo Oh

@KKingmo

전체 글 보기

컴포넌트 Best Practices

가능한 가까운 곳에 배치하기

컴포넌트, 함수, 스타일, 상태 등을 사용하는 곳에 최대한 가깝게 배치한다. 이는 코드베이스를 더 읽기 쉽게 만들고 이해하기 쉽게 할 뿐만 아니라 상태 업데이트 시 중복 렌더링을 줄여준다.

중첩된 렌더링 함수가 있는 큰 컴포넌트 피하기

애플리케이션 내부에 여러 렌더링 함수를 추가하지 않는다. 이렇게 하면 금방 관리하기 어려워진다. 대신, 하나의 단위로 간주할 수 있는 UI 조각이 있다면 별도의 컴포넌트로 추출한다

// 컴포넌트가 커지기 시작하면 유지 보수가 매우 어렵다
function Component() {
  function renderItems() {
    return <ul>...</ul>;
  }
  return <div>{renderItems()}</div>;
}
 
// 별도의 컴포넌트로 추출
function Items() {
  return <ul>...</ul>;
}
 
function Component() {
  return (
    <div>
      <Items />
    </div>
  );
}

일관성 유지

코드 스타일을 일관되게 유지한다. 예를 들어, 컴포넌트를 파스칼 케이스로 이름 지었다면, 모든 곳에서 그렇게 한다. 대부분의 코드 일관성은 린터와 코드 포매터를 사용하여 달성되므로, 프로젝트에 이를 설정해둔다.

컴포넌트가 받는 props 수 제한

컴포넌트가 너무 많은 props를 받는다면, 여러 컴포넌트로 나누거나 children 또는 slots를 통한 composition 기법을 사용하는 것을 고려한다.

composition 기법 예시 코드

import { CircleAlert, Info } from 'lucide-react';
import * as React from 'react';
import { useEffect } from 'react';
 
import { Button } from '@/components/ui/button';
import { useDisclosure } from '@/hooks/use-disclosure';
 
import {
  Dialog,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '../dialog';
 
export type ConfirmationDialogProps = {
  triggerButton: React.ReactElement;
  confirmButton: React.ReactElement;
  title: string;
  body?: string;
  cancelButtonText?: string;
  icon?: 'danger' | 'info';
  isDone?: boolean;
};
 
export const ConfirmationDialog = ({
  triggerButton,
  confirmButton,
  title,
  body = '',
  cancelButtonText = 'Cancel',
  icon = 'danger',
  isDone = false,
}: ConfirmationDialogProps) => {
  const { close, open, isOpen } = useDisclosure();
  const cancelButtonRef = React.useRef(null);
 
  useEffect(() => {
    if (isDone) {
      close();
    }
  }, [isDone, close]);
 
  return (
    <Dialog
      open={isOpen}
      onOpenChange={(isOpen) => {
        if (!isOpen) {
          close();
        } else {
          open();
        }
      }}
    >
      <DialogTrigger asChild>{triggerButton}</DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader className="flex">
          <DialogTitle className="flex items-center gap-2">
            {icon === 'danger' && (
              <CircleAlert className="size-6 text-red-600" aria-hidden="true" />
            )}
            {icon === 'info' && (
              <Info className="size-6 text-blue-600" aria-hidden="true" />
            )}
            {title}
          </DialogTitle>
        </DialogHeader>
 
        <div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
          {body && (
            <div className="mt-2">
              <p>{body}</p>
            </div>
          )}
        </div>
 
        <DialogFooter>
          {confirmButton}
          <Button ref={cancelButtonRef} variant="outline" onClick={close}>
            {cancelButtonText}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

공유 컴포넌트를 컴포넌트 라이브러리로 추상화

더 큰 프로젝트의 경우 모든 공유 컴포넌트 주위에 추상화를 구축하는 것이 좋다. 이렇게 하면 애플리케이션이 더 일관되고 유지 관리가 쉬워진다. 잘못된 추상을 피하기 위해 컴포넌트를 생성하기 전에 반복되는 부분을 식별한다.

컴포넌트 라이브러리 예시 코드

import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
 
import { cn } from '@/utils/cn';
 
import { Spinner } from '../spinner';
 
const buttonVariants = cva(
  'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground shadow hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
        outline:
          'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
        secondary:
          'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2',
        sm: 'h-8 rounded-md px-3 text-xs',
        lg: 'h-10 rounded-md px-8',
        icon: 'size-9',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  },
);
 
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean;
    isLoading?: boolean;
    icon?: React.ReactNode;
  };
 
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant,
      size,
      asChild = false,
      children,
      isLoading,
      icon,
      ...props
    },
    ref,
  ) => {
    const Comp = asChild ? Slot : 'button';
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      >
        {isLoading && <Spinner size="sm" className="text-current" />}
        {!isLoading && icon && <span className="mr-2">{icon}</span>}
        <span className="mx-2">{children}</span>
      </Comp>
    );
  },
);
Button.displayName = 'Button';
 
export { Button, buttonVariants };

또한, 서드파티 컴포넌트를 애플리케이션의 요구에 맞게 조정하기 위해 래핑하는 것도 좋은 생각이다. 이렇게 하면 애플리케이션의 기능에 영향을 주지 않고 기본 변경을 더 쉽게 할 수 있다.

서드파티 컴포넌트 예시 코드

import { Link as RouterLink, LinkProps } from 'react-router-dom';
 
import { cn } from '@/utils/cn';
 
export const Link = ({ className, children, ...props }: LinkProps) => {
  return (
    <RouterLink
      className={cn('text-slate-600 hover:text-slate-900', className)}
      {...props}
    >
      {children}
    </RouterLink>
  );
};