halang-log
💻 Frontend

[Next13] toast 만들기

date
Jul 29, 2023
slug
making-toast
author
status
Public
tags
Next.js
TailwindCSS
toast
summary
mz2mo 프로젝트에서 사용할 toast를 직접 만들어보자
type
Post
thumbnail
category
💻 Frontend
updatedAt
Aug 3, 2023 09:38 AM
언어

개요

 
현재 진행중인 mz2mo프로젝트에서는 아래와 같은 토스트가 필요하다.
디자인 해준 주니 최고
디자인 해준 주니 최고
notion image
react-hot-toast와 같은 라이브러리를 사용할까 했지만! 이번에는 직접 구현해보고 싶어 한번 도전해봤다. 우선 사용한 기술스택은 아래와 같다.
 
🛠️
Next13 Typescript TailwindCSS Jotai
 

구현

먼저 리액트 포탈을 이용해 body 태그 하단에 toast portal을 두었다.
notion image
 
전체적인 틀은 아래 그림과 같다.
notion image
간단히 말하면, Toast Provider을 포탈에 두고 내부에 toast들을 보여주는 로직이다.
 
Toast Provider을 포탈에 위치시키는건 아래와 같이 /app/tempate.tsx에 작성해두었다.
'use client'; import AppPortal from '@/components/app-portal'; import ToastProvider from '@/components/toast/ToastProvider'; export default function Template({ children }: { children: React.ReactNode }) { return ( <> ... <AppPortal.Provider portalName="toast-portal"> <ToastProvider /> </AppPortal.Provider> </> ); }
 
다음은 Toast Provider 코드이다.

/components/toast/ToastProvider.tsx

import React from 'react'; import { useAtomValue } from 'jotai'; import AppPortal from '@/components/app-portal/AppPortal'; import Toast from '@/components/toast/Toast'; import { useToastAtom } from '@/stores/toast'; const Toaster = () => { const toasts = useAtomValue(useToastAtom); return ( <AppPortal.Wrapper portalName="toast-portal"> <div className="fixed space-y-2 -translate-x-1/2 left-1/2 top-32"> {toasts.map((toast) => ( <Toast key={toast.id} {...toast} /> ))} </div> </AppPortal.Wrapper> ); }; export default Toaster;
 
사실 Toast가 보여지는 위치도 커스텀할 수 있게 해주려고 했지만 디자인 시안에서는 항상 상단에만 보여줘서 굳이 해주진 않았다. (그래서 코드에서 보이는것처럼 top-32로 박아버림)
 

/stores/toast/store.ts

... export const toasterAtom = atom<ToastProviderType>({ toasts: [], sequence: 0, });
 
toasterAtom에는 토스트를 담을 toasts 배열과 각 토스트별로 고유 아이디를 부여해주기 위한 sequence가 있다. 그 이유는 지정해둔 ms가 지나면 해당 토스트가 사라져야하는데 그 때 toasts 배열에서 없애줘야 하는 것을 고유 아이디를 통해 삭제해주기 때문이다.
 
이제 toasts 배열에 담아질 Toast 컴포넌트에 대해 살펴보자. 아래는 해당 코드이다.
 

/components/toast/Toast.tsx

import React, { useEffect, useState } from 'react'; import { useSetAtom } from 'jotai'; import AlertError from '@/assets/icons/alertError.svg'; import AlertInfo from '@/assets/icons/alertInfo.svg'; import AlertSuccess from '@/assets/icons/alertSuccess.svg'; import { removeToastAtom } from '@/stores/toast'; import { ToastType } from '@/types/atom/toast'; const TOAST_DURATION = 3000; const ANIMATION_DURATION = 350; interface ToastProps { title: string; message: string; id: string; type: ToastType; } const ToastIcon = ({ type }: { type: ToastType }) => { switch (type) { case 'success': return <AlertSuccess className="w-6 h-6 text-[#4F6EFF]" />; case 'error': return <AlertError className="w-6 h-6 text-[#F35B3C]" />; case 'info': return <AlertInfo className="w-6 h-6 text-[#00BA77]" />; default: return null; } }; const Toast = ({ title, id, message, type }: ToastProps) => { const removeToastItem = useSetAtom(removeToastAtom); const [opacity, setOpacity] = useState(0.2); useEffect(() => { setOpacity(1); const timeoutForRemove = setTimeout(() => { removeToastItem(id); }, TOAST_DURATION); const timeoutForVisible = setTimeout(() => { setOpacity(0); }, TOAST_DURATION - ANIMATION_DURATION); return () => { clearTimeout(timeoutForRemove); clearTimeout(timeoutForVisible); }; }, [id, removeToastItem]); return ( <div style={{ opacity: opacity, transition: 'all 0.35s ease-in-out', transform: `translateY(${opacity === 0 ? '-10px' : '0'})`, }} className="bg-gray-900 border min-w-[300px] max-w-[360px] p-4 flex items-center rounded-lg border-gray-800" > <div className="flex text-white"> <span className="mr-2.5"> <ToastIcon type={type} /> </span> <div className="flex flex-col gap-1"> <span className="text-subtitle1">{title}</span> <span className="text-caption">{message}</span> </div> </div> </div> ); }; export default Toast;
 
우선 위 코드에서 removeToastItem은 ToasterAtom에 있는 toasts배열에서 특정 toast를 삭제해주는 코드이다. 그래서 TOAST_DURATIONms가 지나면 removeToastItem(id) 가 실행된다.
 
자, 그러면 이제 남은건 토스트를 어떻게 부르느냐! 이다. react-hot-toast에서는 toast.success() 와 같은 형태로 토스트를 손쉽게 부를 수 있다. 나도 이와 비슷하게 사용할 수 있게 jotai에서 정의한 action을 custom hook으로 감싸주었다.
 

/stores/toast/action.ts

... export const useToastAtom = atom( (get) => get(toasterAtom).toasts, (get, set, type: ToastType) => ({ title, message }: { title: string; message: string }) => { const prevAtom = get(toasterAtom); set(toasterAtom, { toasts: [ { type, title, message, id: prevAtom.sequence.toString(), }, ...prevAtom.toasts, ], sequence: prevAtom.sequence + 1, }); }, );
 
여기서, 커링함수가 사용된다. ToastType(’success’, ‘error’, ‘info’)을 파라미터로 받으면 또 다른 함수를 리턴해준다. 여기서 리턴되는 함수는 title과 message를 입력받아 toasterAtom에 있는 toasts 배열에 새로 추가해주고 id역할을 하는 sequence를 업데이트 해준다. 굳이 커링함수로 만든 이유는 어떤 유형의 토스트인지와 어떤 내용의 토스트인지는 서로 다른 관심사기 때문이다. 그래서 이를 한번에 받기 보다 분리시키는게 나을 것 같았다.
 

/hooks/useToast.ts

import { useSetAtom } from 'jotai'; import { useToastAtom } from '@/stores/toast'; export const useToast = () => { const addToast = useSetAtom(useToastAtom); return { toast: { success: addToast('success'), info: addToast('info'), error: addToast('error'), }, }; };
 
그리고 커스텀 훅에서 이를 한번 감싸주었다. useToast훅 내부에 toast 객체는 총 3가지의 타입이 있으며 이들은 커링함수의 마지막 함수(title, message를 매개변수로 받는 함수)가 할당되게 된다.
 
그러면 아래와 같이 토스트를 손쉽게 부를 수 있다.
import { useToast } from '@/hooks/useToast'; export default function TestPage() { const { toast } = useToast(); const onClick = (type: 'success' | 'error' | 'info') => { switch (type) { case 'success': toast.success({ title: 'success', message: 'success message' }); break; case 'error': toast.error({ title: 'error', message: 'error message' }); break; case 'info': toast.info({ title: 'info', message: 'info message' }); break; default: break; } ... };
 

마무리

 
notion image
어떻게 구현해야 할지 고민을 많이 했었다. 다른 라이브러리들을 참고하면서 했었는데 역시 잘 만든 라이브러리들은 코드를 이해하기가 쉽지 않았다… 사실 이번에 만든 토스트는 자유도가 많이 떨어져서 살짝 아쉬운 부분도 있다. 나중에 좀 더 범용적으로 쓰이게 된다면 추가적으로 리팩토링을 해봐야겠다. 그리고 이번에 프레이머를 쓸 예정인데, 팝업 모션을 좀더 예쁘게(?) 넣어봐야겠다 😎