본문 바로가기

프론트엔드/React

Toast 컴포넌트 구현하기

  • 프로젝트: loplat UI
  • 키워드: Toast, class, setTimeout
  • 상황
    1. 기존 loplat UI의 Toast 컴포넌트는 react-hot-toast 라는 라이브러리를 fork 한 후, 사내 디자인에 맞게 변형하여 사용하고 있었다.
    2. Toast 컴포넌트의 변경사항이나 버그가 있을 때 코드의 흐름을 이해하기 어려웠고, 필요한 기능에 비해 용량이 매우 컸다.(20Kb)
    3. 직접 Toast를 구현하는 것이 유지보수, 용량, 성능 모든 면에서 더 좋을 것이다.
  • 해결 방법
    1. 아래의 예시 코드와 같이 Toast 를 App에서 렌더링하고, toast 인스턴스를 통해 함수를 트리거하는 것으로 설계했다.
      'Toast' 컴포넌트와 'toast' 인스턴스를 연결하는 것이 구현의 핵심이다.
        <Toast />
      ...
      <Button onClick={() => toast.success('성공')}>
        Success
      </Button>​


    2. 최종 결과물은 총 5개의 파일이며, 각 파일은 다음과 같은 역할을 한다.
      - Toast.tsx: ToastBar를 담고 있는 Wrapper
      - ToastBar.tsx: 하나의 메시지를 담고 있는 Bar UI
      - toaster.ts: toast 함수(success, danger, warning, info)를 담고 있는 class
      - types: typescript 관련 타입 선언
      - utils: 기타 함수들(ex> 적절한 colorset을  함수)
      폴더 구조
    3. 먼저 toaster.ts 파일의 class를 작성한다.
        type SetToastItems = React.Dispatch<SetStateAction<ToastItem[]>>;
      
      class Toaster {
        setToastItems: SetToastItems = () => {
          return;
        };
      
        constructor(setState: SetToastItems | null) {
          if (setState) this.setToastItems = setState;
        }
      
        addToastItem({ type, message }: Omit<ToastItem, 'id'>): void {
          this.setToastItems((state: ToastItem[]) => [{ id: generateUniqueId(), type, message }, ...state]);
        }
      
        removeToastItem(toastId: ToastItem['id']): void {
          this.setToastItems((state: ToastItem[]) => {
            const indexToRemove = state.findIndex((toastItem) => toastItem.id === toastId);
            if (indexToRemove > -1) {
              return [...state.slice(0, indexToRemove), ...state.slice(indexToRemove + 1)];
            }
            return state;
          });
        }
      
        success(message: ToastItem['message']): void {
          this.addToastItem({ type: 'success', message });
        }
      
        info(message: ToastItem['message']): void {
          this.addToastItem({ type: 'info', message });
        }
      
        danger(message: ToastItem['message']): void {
          this.addToastItem({ type: 'danger', message });
        }
      
        warning(message: ToastItem['message']): void {
          this.addToastItem({ type: 'warning', message });
        }
      }
      리액트의 setState 함수를 멤버 변수로 저장한 후, addToastItem과 removeToastItem 메서드 내부에서 setState 함수를 이용하는 방식이다.

      Toast.tsx 파일에서 해당 class를 이용하여 toast instance를 만들고, export 한다.
      그럼 setToastItems를 통해 Toast 컴포넌트와 toast instance가 연결된다!
        export let toast: Toaster = new Toaster(null);
      
      export const Toast = ({ ... }: Props): React.ReactElement => {
        const [toastItems, setToastItems] = useState<ToastItem[]>([]);
      
        useEffect(() => {
          /** Initialize */
          toast = new Toaster(setToastItems);
        }, []);
        
        ...
      }​

      이제 toast.success() 와 같이 함수를 호출하면 addToastItem 호출 -> setToastItems 호출로 인해 toastItem이 추가된다.

    4. 추가된 ToastItems 배열을 렌더링한다.
        return (
        <ToastWrapper {...}>
          {toastItems.map((toastItem) => (
            <ToastBar
              toastItem={toastItem}
              ...
              key={toastItem.id}
            />
          ))}
        </ToastWrapper>
      );​


    5. ToastBar가 mount, unmount될 때 opacity transition로 애니메이션 효과를 준다.
      // ToastBar.tsx
      
      let toastDuration = 3000;
      const ANIMATION_DURATION = 350;
      ...
      
      // lifecycle(add/remove) 관련 로직
      useEffect(() => {
        setOpacity(1);
      
        const timeoutForRemove = setTimeout(() => {
          onRemoveToastItem(toastItem.id);
        }, toastDuration);
      
        const timeoutForOpacity = setTimeout(() => {
          setOpacity(0);
        }, toastDuration - ANIMATION_DURATION);
      
        return () => {
          clearTimeout(timeoutForRemove);
          clearTimeout(timeoutForOpacity);
        };
      }, [toastItem, onRemoveToastItem, toastDuration]);​
       
      setTimeout을 이용하여
      -  toastDuration이 끝나기 직전(i.e. toastDuration - ANIMATION_DURATION) opacity를 서서히 줄이는 애니메이션을 트리거하고,
      - toastDuration이 지났을 때 toastItem을 제거해야함을 부모에게 알릴 수 있다.(onRemoveToastItem)

    6. 버튼을 눌러 toast 함수를 실행하면, ToastBar가 렌더링 되는 것을 확인할 수 있다! 스토리북으로 확인하기
  • 의의
    1. 이해하기 어려운 레거시 코드와 로직을 5개의 파일로 압축하였고, class 방식을 도입해 핵심기능을 그대로 구현했다.
      레거시 코드를 충분히 이해하고 개발을 진행하니 0.5일 만에 컴포넌트를 만들어낼 수 있었다. 
    2. 번들링 용량을 20KB → 8KB로 60% 줄였다.
    3. ToastBar를 연달아 생성하거나 지워도 frame이 60fps로 유지된다.
  • known issues
    1. react portal을 이용하여 layer를 분리하면 좋을 것 같다.
    2. translate를 이용한 ToastBar의 위치 조정, 메시지 길이에 따른 ToasterBar height 계산 등 추가 코드가 있지만 핵심 로직이 아니므로 포스팅에선 생략했다.