리액트 토스트 커스텀 훅 만들기

토스트를 리액트에서 커스텀 훅으로 쉽게 사용할 수 있게 만들어 봅니다.
2024.01.21

사용자의 인터렉션에 대한 반응을 주기 위해 하단에 작은 알림을 띄어주게 되는데 이러한 요소를 토스트라고 한다.

이러한 토스트를 쉽게 사용할 수 있도록 커스텀 훅으로 빼면 토스트 컴포넌트를 직접 jsx에 추가하지 않아도 되고, 다른 커스텀 훅에서도 쉽게 사용할 수 있다.

처음으로 만들었던 방법은 매번 추가로 토스트 컴포넌트를 직접 불러와야 해서 프로젝트의 팀원에게 불편하다는 의견을 들었고 다른 커스텀 훅 안에서 사용하기에도 불편했다.

그래서 좀 더 나은 토스트 컴포넌트 구성을 찾는 중 shadcn/ui의 토스트 컴포넌트가 참고가 될만해서 참고해 보았다.

shadcn/ui의 코드는 https://ui.shadcn.com/docs/components/toast 을 참고하면 된다.

본 글은 위의 코드를 심플하게 만드는 내용이라 보면 된다.

구성

이번 토스트는 화면에 하나의 토스트만 보이며, 일정 시간 후에 자동으로 사라지는 형태이다.

추가로 여러 토스트를 표시할 수 있게 확장할 수 있는 상태로 두었기에 약간의 변경을 통해 여러 개의 토스트를 표시, 혹은 입력을 받은 후에 사라지는 등의 형태를 만들 수 있을 것이다.

토스트 컴포넌트의 스타일링보다는 토스트 컴포넌트를 어떻게 쉽게 사용할지에 대해 주로 다룬다.

Toaster

토스트를 매번 사용할 때마다 토스트 컴포넌트를 불러와 페이지 등에 추가하기보다 상위 컴포넌트에서 한 번만 추가하여 커스텀 훅으로 매우 편리하다.

// layout.tsx

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <head>
      </head>
      <body>
        <div>{children}</div>
        <Toaster />
      </body>
    </html>
  )
}

위 코드처럼 Toaster라는 컴포넌트가 한 번만 추가하면 되는데 그러면 Toaster의 코드를 보자.

// Toaster.tsx

'use client';

import { useToast } from './useToast';
import { Toast } from './Toast';

const Toaster = () => {
  const { toasts } = useToast();

  return (
    <>
      {toasts.map((toast) => {
        return (
          <Toast key={toast.id} show={toast.show}>
            {toast.message}
          </Toast>
        );
      })}
    </>
  );
};

export default Toaster;

Toaster 컴포넌트 또한 useToast 커스텀 훅을 부르고 있다. 해당 훅에서 토스트들을 받아와 그려주는 역할만 하게 되는 간단한 컴포넌트이다.

Toast 컴포넌트의 스타일 같은 경우 position: fixed, left: 50%, transform: translateX(-50%)가 주요하게 하단에 고정하여 중앙으로 정렬하는 방법이고 이외의 여러개의 토스트를 보여주기, 애니메이션에 관한 스타일링은 이 블로그에서 다루지는 않는다.

다음으로는 실제로 토스트들을 관리하는 useToast 코드를 확인해 보자.

useToast

// useToast.tsx

import { useEffect, useState } from 'react';
import { Toast } from './Toast';

const TOAST_REMOVE_DELAY = 3000;

interface Toast {
  id: string;
  message: string;
  show: boolean;
}

let toasts: Toast[] = [];
let count = 0;

const genId = () => {
  count = (count + 1) % Number.MAX_SAFE_INTEGER;
  return count.toString();
};

const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
const listeners: Array<(toasts: Toast[]) => void> = [];

const toast = (message: string) => {
  const id = genId();

  listeners.forEach((listener) => {
    if (!toasts.find((toast) => toast.id === id)) {
      toasts = [...toasts, { id, message, show: true }];
    }
    listener(toasts);

    const timeout = setTimeout(() => {
      toasts = toasts.map((toast) => {
        return toast.id === id ? { ...toast, show: false } : toast;
      });
      listener(toasts);

      const maxTimeoutId =
        !!timeouts.size && [...timeouts.entries()]?.reduce((a, b) => (b[1] > a[1] ? b : a))[0];

      if (maxTimeoutId === id) {
        toasts = [];
        timeouts.clear();
      }
    }, TOAST_REMOVE_DELAY);

    timeouts.set(id, timeout);
  });
};

export const useToast = () => {
  const [state, setState] = useState(toasts);

  useEffect(() => {
    listeners.push(setState);
    return () => {
      const index = listeners.indexOf(setState);
      if (index > -1) {
        listeners.splice(index, 1);
      }
    };
  }, []);

  return {
    toast,
    toasts: state,
  };
}

코드를 보면 알다시피 토스트들을 리액트의 상태로 관리하고 있지 않다. 꼭 상태로 관리해야 하는 값이 아니므로 리액트의 상태로 관리하지 않아도 된다.

다른 함수들에 앞서 useToast부터 더 살펴보자.

export const useToast = () => {
  const [state, setState] = useState(toasts);

  useEffect(() => {
    listeners.push(setState);
    return () => {
      const index = listeners.indexOf(setState);
      if (index > -1) {
        listeners.splice(index, 1);
      }
    };
  }, []);

  return {
    toast,
    toasts: state,
  };
}

토스트들의 값을 초깃값으로 사용하는 상태를 가지고 있지만, 훅에서 직접 상태를 변경시키지 않는다. 나중에 상태가 변경되어야 하면 setState를 통해 리렌더링이 일어날 수 있도록 listeners라는 배열에 넣어주었다.

이후 useEffect의 클린업에서는 listeners가 커지는 것을 방지하기 위해 개수를 초기화시키고 있다.

useToast가 리턴하는 값은 toasttoasts 인데 toast는 토스트를 보이게하는 함수고, toastsuseToaststate를 이름을 변경해서 내려준 것으로, 앞에서 본 Toaster가 표시하는 토스트들을 가지는 값이다. listeners에 저장한 setState들이 useToast의 상태를 변경하고 변경되면 Toaster에서 새로운 토스트들이 표시되는 흐름이다.

다음으로는 toast 함수를 봐보자.

const toast = (message: string) => {
  const id = genId();

  listeners.forEach((listener) => {
    if (!toasts.find((toast) => toast.id === id)) {
      toasts = [...toasts, { id, message, show: true }];
    }
    listener(toasts);

    const timeout = setTimeout(() => {
      toasts = toasts.map((toast) => {
        return toast.id === id ? { ...toast, show: false } : toast;
      });
      listener(toasts);

      const maxTimeoutId =
        !!timeouts.size && [...timeouts.entries()]?.reduce((a, b) => (b[1] > a[1] ? b : a))[0];

      if (maxTimeoutId === id) {
        toasts = [];
        timeouts.clear();
      }
    }, TOAST_REMOVE_DELAY);

    timeouts.set(id, timeout);
  });
};

toast는 호출될 때 메시지를 받는다. 이후 유니크한 아이디를 생성하고 toasts 변수에 저장하고 listeners 안에 있던 listener (listeners안의 setState)를 통해 리렌더링을 일으켜 토스트를 표시한다.

토스트는 일정 시간 후에 사라져야 하므로 setTimeout을 통해 시간이 지나면 해당 토스트의 show 프로퍼티를 false로 만들어 보이지 않게 한다. 이후에도 listener를 토스트를 보이지 않게 한다.

이후에는 토스트를 가지고 있는 배열과, setTimeout의 아이디들을 초기화해주는데 제일 마지막에 발생한 setTimeout에서 초기화시켜주면 된다.

실제 사용

사용하는 경우는 아래처럼 사용하면 된다.

const { toast } = useToast();

const handleSave = () => {
  toast('저장되었습니다');
}

const handleConfirm = () => {
  toast('확인되었습니다');
}

return (
  <div>
    <button onClick={handleSave}>저장</button>
    <button onClick={handleConfirm}>확인</button>
  </div>
)

토스트를 사용하는 쪽에서는 커스텀 훅을 통해 toast 함수를 불러와 사용하기만 되고 Toast를 직접 보여주는 컴포넌트에 대해서는 직접 관리하지 않기 때문에 유용하다.

마무리

변경 후 토스트를 사용하는 프로젝트의 다른 팀원들은 커스텀 훅을 부르고 결과 값 중에 토스트를 표시하는 함수를 통해 토스트만 표시하게 되어 매우 편리해졌다는 의견을 들었다!

이번 구현에서 배운 점 하나는 모든 것을 리액트의 상태로 관리할 필요가 없다는 점이다. 전역으로 관리돼야 하는 상태는 꼭 리액트의 상태로 관리되지 않아도 된다는 것을 다시 한번 알게 된 것 같다. 상태가 바뀌는 경우 자동으로 리렌더링이 일어나지만 상태가 아닌 경우 의도적으로 setState를 불러 렌더시키는 방식이 특이했던 것 같다.

여러 개의 토스트를 동시에 보여주거나 애니메이션등 아직 개선해야할 점이 많지만, 간단한 토스트라면 위와 같은 방법도 좋다고 생각한다.