업무

전역 상태관리 툴 없이 Toast만들기(custom hook으로)

모_아이 2024. 4. 17. 14:24

Toast 만들기

업무중 atomic component로 사용될 기본 컴포넌트 들을 만들다 Toast의 차례가 왔다
기본적으로 Toast는

 

이런식으로 어떠한 동작을 했거나 실패 등 여러 상황에서 alert창이 아닌 쉽게 말해 서비스 디자인 등과 맞춰 제작된 자체적인 알림창이라 봐도 무방하다.

 

React에서 이를 제작할 경우 일단 모든 컴포넌트에서 이 Toast 알림창이 화면에 띄워질 수 있도록 해당 Toast 컴포넌트에 관여 할 수 있어야 한다.

 

게시판 글이나 뭐 회원가입 버튼이나 각종 여러 군데에서 이 Toast 알림을 띄울 수 있기 때문에 첫번째로 고안한 방법은 

Context API와 같은 전역 상태를 활용해서 가장 최상위 루트에서 감싸주는 것이다.

 

그럴경우 최상위에서 감싸진 ToastContext에서 useToast라는 함수를 내보내게 되고 이는 Toast가 필요한 컴포넌트에서 이 Toast 알림창을 띄울 수 있는 로직을 사용할 수 있게 된다.

 

하지만 문제점이 하나 생기는데..

 

다른 라이브러리들은 이 현상을 해결했다고 하지만 Compound component 디자인 패턴등을 사용하다 보니 ContextAPI를 많이 사용했고 이 Toast 또한 ContextAPI로 전역 상태를 감쌌더니 Toast가 뜰때마다 모든 컴포넌트들이 리렌더링 되는 일이 발생했다.

 

이 때문에 Recoil이나 redux, zustand와 같은 전역 상태관리 라이브러리들이 생겨났다고도 읽었는데 확실히 사용자가 버튼을 연속해서 누를 경우도 있고 그 때는 엄청난 리렌더링이 일어나 성능적으로 매우 부적절하다 생각했다.

이를 고치기 위해선 모든 컴포넌트에 memo를 씌운다면 가능하겠지만... 합리적이지 못하다


어떻게 해결했나?

우선 Toast들을 사용하고 있는 UI 라이브러리들을 뒤졌다.

import해서 사용하고 있는 라이브러리들은 내부적으로 보기가 까다로웠지만 그 중 shadcn 이라는 라이브러리를 찾았는데 manual 부분과 직점 install하면 사용되는 코드를 볼 수가 있어 직접 코드를 분석해봤다.

 

https://ui.shadcn.com/

 

shadcn/ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.

ui.shadcn.com

 

shadcn 페이지에 들어가서 docs에 Toast를 살펴보면 알겠지만 shadcn의 Toast를 설치하면 약 3개의 파일을 다운받게 되는데

 

 

이렇게 3개의 파일이 만들어지게 된다.

각각 간단히 살펴보면

- Toast는 띄워지는 알림창의 component이고 

-  Toaster는 알림창이 띄워지기 위한 레이아웃이다

- use-toast는 toast를 각 컴포넌트에서 사용하기 위한 toast 라는 함수와 Toaster에서 관리하기 위한 useToast를 관리하고 있다.

 

 

이 shadcn에서 사용되는 방법을 간단히 소개하면

1. <Toaster/>라는 toast 레이아웃을 최상위 루트에서 잡아놓는다. 이는 Toast 알림창이 하나만 뜨게 할것인지 여러개 뜨게 할 수 있을것인지는 직접 CSS와 Toast 상태값을 조절하면 된다.(일단 Shadcn은 Toast를 하나만 뜨도록 만들어놨다)

 

2. <Toaster /> 내부에서 useToast를 사용하 toasts 즉, 뜨게할 알림창들을 배열로 가져온 다음 map을 돌려 <Toast/>로 화면에 표시한다.

 

3. use-toast에는 export를 통해 Toaster에 보낼 toasts와 각 컴포넌트에서 사용된 toast함수를 내보내는데 이 toast 함수를 통해 각 컴포넌트에서 

toast({
	title:'test',
    description:'test toast'
    })

이러한 함수식을 버튼의 onClieck 과 같은 곳에 실행되도록 하여 use-toast에서의 toast함수를 실행되도록 한다

 

 

처음 이걸 봤을 때 이게 그래서 어떻게 전역적으로 상태가 관리된다는 것인가? 궁금할 수 있다.

 

보통 우리가 자주 보는 custom hook은 각 컴포넌트에서 useCustomHook과 같이 사용할 때 각 컴포넌트 별로 상태들이 만들어지고 각각은 독립적인 상태가 된다 는게 보통 많이 접했을 것이다.

 

shadcn은 독특하게 hook이 불려질 때 해당 hook파일 자체적으로 변수를 가지고 있도록 파일 즉 해당 모듈에서의 전역변수를 만든다.

그리고 그 변수에 hook이 불릴때마다 만들어지는 state들을 관리할 수 있는 setState들을 배열로 집어넣고 toast라는 함수가 발동할 때마다 이 setState들을 순환하여 모든 state들을 변경할 수 있게 만든다.

이는 해당 hook이 사용된 컴포넌트들만 리렌더링 됨으로 전역적인 리렌더링을 방지할 수 있다.

 


의문점

그대로 사용하기엔 의문점이 있다.

toast알림창들의 layout으로 잡고 있는 Toaster에서만 리렌더링 되면 안되나? 왜 굳이 배열에 넣고 반복문을 돌리지? 라고 생각이 들 수 있다.

 

다시 생각해보면 shadcn은 공통적으로 사용할 수 있게 라이브러리로 나온 상황이라 사용자가 <Toaster/>를 루트 한군데에서만 사용한다는 보장이 없다.

그렇기에 <Toaster/>를 컴포넌트별로 분할시켜 사용한다면 각각의 상태를 다 업데이트 해야 하기 때문에 배열로 만들었다 생각하고 내가 만들 Toaster는 루트에서 한번만 관리할 것이기 때문에 쓸데없는 부분은 삭제시키고 새로 만들었다.

ToastLayout.tsx

import { memo } from 'react';
import { useToast } from '../../hooks/useToast';
import { cn } from '../../lib/utils';

const ToastLayout = () => {
  const [toastArr, closeToast] = useToast();

  return (
    <div className='fixed bottom-2 right-2 z-100 flex flex-col'>
      {toastArr.length !== 0 &&
        toastArr.map((toast) => (
          <Toast key={toast.id} closeToast={closeToast} {...toast} />
        ))}
    </div>
  );
};

 

useToaste.tsx

import { Dispatch, SetStateAction, useEffect, useState } from 'react';

type ToastType = {
  id: number;
  content: string;
  variant?: ColorVariants;
  soft?: boolean;
  className?: string;
};
const TOASTCLOSEDELAY = 3000;
let toastListener: Dispatch<SetStateAction<ToastType[]>>;
let toastId = 0;

type ToastFunctionType = Omit<ToastType, 'id'>;

const openToast = ({
  content,
  variant,
  soft,
  className,
}: ToastFunctionType) => {
  toastId = (toastId + 1) % Number.MAX_SAFE_INTEGER;
  toastListener((prev) => [
    ...prev,
    { id: toastId, content, variant, soft, className },
  ]);
  setTimeout(() => {
    toastListener((prev) => prev.slice(1));
  }, TOASTCLOSEDELAY);
};

const useToast = () => {
  const [toastArr, setToastArr] = useState<ToastType[]>([]);

  const closeToast = (id: number) =>
    setToastArr((prev) => prev.filter((toast) => toast.id !== id));

  useEffect(() => {
    toastListener = setToastArr;
  }, []);

  return [toastArr, closeToast] as const;
};

export { useToast, openToast };

 

일단 테스트 용으로 만든 Toast이며 <ToasteLayout/>이라는 Toast들이 뜰 공간을 잡아놓고 useToast를 통해 상태를 하나 잡아놓는다

그리고 openToast라는 함수를 통해 각 컴포넌트에서 사용하고 openToast가 실행될 경우 useToast란 파일에서 잡아놓은 toastLitener변수가 Toaster가 가지고 있는 toastArr이란 상태를 업데이트 할 수 있기 때문에 Toaster가 리렌더링 되며 toast가 화면에 나타나게 된다.

 

이후 적절히 사용되는 Toast에도 memo를 걸어줄 경우 Toast알림창이 뜰경우 리렌더링이 굉장히 최소화 되는 부분을 확인해 볼 수 있다

실제로 toast알림창이 뜰때 화면 우측 하단의 ToastLayout과 새롭게 만들어지고 사라지는 toast들만 리렌더링 되는지 체크해보자

 

 

코드에 정답이 없다지만 이렇게 useToast안에 모듈 전역변수를 두는 게 좋은 방식인가? 는 의문이 들기도 하여 추후에 더 좋은 방법이 있다면 실행해 볼만 하다.

아니면 아싸리 전역 상태 관리 라이브러리에서 Toast를 관리하는 것도 훨씬 좋은 방안이라 생각한다.

 

반응형