이번 글에서는 제가 Toast 컴포넌트를 만들기도, 사용하기 편하게 로직을 만들기도, 문서화를 하기도, react-query에서 성공 시 띄우기도 한 과정들을 글로 남겨 보려고 합니다.
아래는 최종적으로 만든 Toast 컴포넌트에요!
좋은 레퍼런스를 찾았어서 처음부터 대단한 고민을 한 것은 아니지만, 그 과정 속에서 제 입맛대로(?) 바꾸고 발견한 것들이 있었어요! 그것들을 함께 이야기 해보려고 합니다.
일단 선 감사합니다 부터...
→ 참고한 레퍼런스
제가 지금 사용하고 있는 기술 스택은 다음과 같습니다.
v14
)v5
)보통 toast는 여러 개가 나오면서 시간차로 슥슥슥슥 하나씩 없어지는 거 많이 보셨을 거에요.
즉, toast를 배열을 통해 여러 개를 관리한다는 말을 의미하는데요! 그래서 toast들을 담을 수 있는 배열을 전역 상태로 만들어 주었어요.
// Toast.atom.ts
export const toastsAtom = atom<ToastsProps>([]);
jotai는 기존에 선언되어 있는 atom을 가지고 write하거나 read할 수 있는 atom을 만들 수 있어요. 저는 이를 이용해서 toastsAtom
에 편하게 toast를 추가할 수 있는 write용 atom을 만들었어요.
// Toast.atom.ts
export const toastAtom = atom(null, (get, set, type: ToastProps['type']) => (title: string) => () => {
const prev = get(toastsAtom);
const newToast = { type, title, id: Date.now().toString() };
set(toastsAtom, [...prev, newToast]);
});
🚨 주의1.
저희 팀에서 toast를 사용할 때 필요한 정보는 2개 뿐이였어요.
success / warning
)위 코드 중에서 이 부분을 주목해주세요!
(get, set, type: ToastProps['type']) => (title: string) => () => { ... }
즉, 해당 atom을 write할 때 type
을 넘겨주면 (title) => () => {}
를 반환하도록 되어 있습니다. 즉 위 코드를 사용할 때는 아래와 같이 사용한다는 것을 의미합니다.
const addToast = useSetAtom(toastAtom)
...
addToast(type)(title)()
커링형태로 사용 시 깔끔하게 호출할 수 있도록 구성했습니다.
🤔 엥 뭐가 깔끔하다는 거냐....
할 수 있지만 다 계획 된.. 아무튼 글을 계속 읽어봐주세요 😉
여기까지 하면 이제 어디서든 toast를 추가
할 수 있습니다.
🚨 주의2.
const newToast = { type, title, id: Date.now().toString() };
toast가 필요한 정보 중에 id가 필요한 이유는 고유한 값으로 그걸 가지고 해당 toast를 지우기 위해 필요해요.
처음에는 사실 id: prev.length
로 했었는데, 렌더링되고 시간차로 없어지면서 length가 줄어들었다 늘었다 하는 과정 속에서 map 돌릴 때 key값이 중복되어 warning이 뜨더라구요. 그래서 중복될 일이 없는 Date.now()
를 이용하여 toast에게 id를 부여해 주었습니다.
그 다음으로 Toast 컴포넌트를 만들었어요. 그런데, 위 gif를 보면 알 수 있듯이 각 toast가 독립적으로 일정 시간이 지나면 슥하고 사라지고 있어요.
이 말은 전역에서 관리하고 있는 toasts 배열에서 지워졌음을 의미합니다.
아까 추가를 위한 atom과 비슷한 맥락으로 삭제하는 atom을 만들었어요.
// Toast.atom.ts
...
export const removeToastAtom = atom(null, (get, set, id: string) => {
const prev = get(toastsAtom);
set(
toastsAtom,
prev.filter((toast) => toast.id !== id),
);
});
삭제하기를 원하는 id를 받아서 이 id를 가지고 toasts 배열에서 없애줍니다.
Toast 컴포넌트는 각자 시간만큼 보여주기를 완수하면 사라집니다. 즉, 이 timer는 Toast 컴포넌트 내에서 관리한다는 말을 의미합니다.
그래서 이 일정 시간이라는 주기를 useEffect
를 통해 작성하면 아래와 같습니다.
// Toast.tsx
const TOAST_DURATION = 3000;
const ANIMATION_DURATION = 350;
export const Toast = ({ id, title, type = 'success' }: ToastProps) => {
const [opacity, setOpacity] = useState('opacity-[0.2]');
const removeToastItem = useSetAtom(removeToastAtom);
useEffect(() => {
setOpacity('opacity-[0.8]');
const timeoutForRemove = setTimeout(() => {
removeToastItem(id);
}, TOAST_DURATION);
const timeoutForVisible = setTimeout(() => {
setOpacity('opacity-0');
}, TOAST_DURATION - ANIMATION_DURATION);
return () => {
clearTimeout(timeoutForRemove);
clearTimeout(timeoutForVisible);
};
}, [id, removeToastItem]);
return (
<div
className={`w-fit flex gap-5xs justify-center items-center px-3xs py-5xs bg-gray-60 rounded-[12px] mb-5xs transition-all duration-350 ease-in-out ${opacity}`}
>
...
</div>
);
};
위와 같이 opacity와 remove를 해주게 되면 스타일에 적용되어 있는 transition
덕분에 스르륵
보였다가 스르륵
없어지는 것처럼 보이게 됩니다. 스르륵 이거 무슨 느낌인지 다들 아시죠?
여기까지 우리가 한 작업들은 다음과 같아요.
만들 건 다 만들었는데 렌더링하는 코드만 없죠! map
을 사용해서 toast를 렌더링할 Provider를 만들어봅시다.
🤔 왜 Provider 일까?
Toast는 레이아웃과 상관없이 어디서든 뜰 수 있게 하고 싶었었죠? Toast 메시지를 띄우려는 컴포넌트들이 직접 메시지를 관리하지 않고, ToastProvider를 통해 메시지를 렌더링할 수 있도록 합니다.
// ToastProvider.tsx
import { Portal } from '@radix-ui/react-portal';
import { useAtomValue } from 'jotai';
import { Toast } from './Toast';
import { toastsAtom } from './Toast.atom';
export const ToastProvider = () => {
const toasts = useAtomValue(toastsAtom);
return (
<Portal>
<div className={`fixed bottom-[82px] left-1/2 transform translate-x-[-50%]`}>
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} />
))}
</div>
</Portal>
);
};
toastProvider
에서는 전역 상태로 관리 중인 toast 배열을 가져와서 map 돌려주는 역할을 하고 있어요. 이때 오버레이 형태로 렌더링되어야 하기 때문에 현재 팀에서 headless UI
가 필요할 때 사용하기로 했던 radix-ui
의 Portal
컴포넌트를 사용했어요.
덕분에 z-index
를 신경쓰지 않을 수 있었습니다.
아까 toast 배열에 toast를 추가할 수 있는 atom을 만들었던 거 기억하시나요?
addToast(type)(title)()
이런 코드와 함께 소개했던 것요..!
이제 이걸 좀 더 이쁘게(?) 사용할 수 있도록 하는 커스텀 훅을 만들어 보려고 합니다.
// useToast.ts
import { useSetAtom } from 'jotai';
import { toastAtom } from '@/components/atoms/toast/Toast.atom';
export const useToast = (option?: ToastOptionProps) => {
const addToast = useSetAtom(toastAtom);
return {
success: addToast('success'),
warning: addToast('warning'),
};
};
위 코드를 살펴보면 훅 함수로 2가지의 메서드를 리턴
하고 있어요. 한 가지를 가지고 이야기해보면, addToast('success')
라고 하면 success라는 프로퍼티에는 (title: string) => () => {}
라는 메서드를 가지게 되는 것과 같아요. 다시 말해서, title을 매개변수를 받아서 VoidFunction
을 리턴하는 함수를 갖게 되는 것이죠!
현재는 toast의 color나 position이 고정되어 있어요. 별도의 옵션 없이 이들의 값을 하드하게 넣어줘도 되지만, 저희 팀에서는 option을 받을 수 있는 전역 상태를 통해 좀 더 나중에 유연할 수 있도록 해주었어요.
우선 UI에서는 스타일 면이나 위치가 항상 동일해서 전달 받은 점은 없었지만, 우선 위치에 대한 옵션을 만들어 두었어요.
// Toast.atom.ts
export const toastOptionAtom = atom<ToastOptionProps>({
position: 'bottom-[84px]',
});
default로 위치를 명시해주었어요.
// Toast.atom.ts
...
export const toastOptionChangeAtom = atom(null, (get, set, changeOption: ToastOptionProps) => {
const prev = get(toastOptionAtom);
const updatedOption = { ...prev, ...changeOption };
set(toastOptionAtom, updatedOption);
});
이 write용 atom을 통해서는 default 옵션은 default대로 적용을 하고, 더 필요한 옵션을 넘겨주면 default + 필요한 옵션
이 set될 수 있도록 만들어주었어요.
이를 적용해보면 아래와 같아요.
// ToastProvider.tsx
export const ToastProvider = () => {
...
const { position } = useAtomValue(toastOptionAtom);
return (
<Portal>
<div className={`fixed ${position} ...`}>
...
</div>
</Portal>
);
};
필요한 부분에 맞게 불러와서 사용하면 됩니다.
옵션을 넘겨주고 싶을 때는 커스텀훅을 통해 넘길 수 있도록 useToast
도 수정해주었습니다.
// useToast.ts
export const useToast = (option?: ToastOptionProps) => {
...
const setOption = useSetAtom(toastOptionChangeAtom);
useEffect(() => {
if (option) setOption(option);
}, [option, setOption]);
...
};
만든 과정을 통해 우리가 사용할 때 건드릴 건 다음과 같아요.
ToastProvider
useToast
atom들도 빠지고 Toast 컴포넌트가 빠져있어요! useToast
를 통해 추가를 하면 ToastProvider
에서 toast의 렌더링을 제어하게 되는 거죠
ToastProvider만 한번 렌더링해주면 이 친구가 toast들의 렌더링을 제어해줄 거에요!
저희 팀 같은 경우에는 provider들을 모아두는 파일이 존재해서 여기에 포함했어요. 일반적인 경우라면 layout.tsx
에 해주시면 돼요!
// providers.tsx
'use client';
const Providers = ({ children }: PropsWithChildren) => {
return (
...
<ToastProvider />
{children}
...
);
};
export default Providers;
const toast = useToast()
...
return (
<button onClick={toast.success('토스트가 열렸어요.')}>이거 누르면 토스트</button>
)
지금 toast.success('토스트가 열렸어요.')
는 VoidFunction
타입이에요. 다시 돌아볼게요.
(type: TypeProps) => (title: string) => () => {}
우리는 위와 같은 함수를 통해 여기까지 온 것인데요! 만약
(type: TypeProps) => (title: string) => {}
이렇게 해도 전혀 문제가 없어요! 다만, 위와 같이 될 경우 사용할 때 코드는 아래와 같이 되게 되어요.
<button onClick={() => toast.success('토스트가 열렸어요.')}>이거 누르면 토스트</button>
onClick
은 항상 VoidFunction
을 받기 때문에 우리는 항상 저런 식으로 () => ...
이라는 코드를 통해 VoidFunction
을 만들어서 넘겨주었던 것인데요.
저는 이러한 특별한 의미없는 코드를 줄이고자, 설계 시 최종적으로 VoidFunction
을 리턴할 수 있도록 커링 형태를 가져간 것이였어요.
🚨 굳이라는 생각이 드신다면 안 해도 전혀 상관 없습니다! 🚨
저희 팀은 react-query
를 사용 중이에요. react-query
의 장점 중 하나는 비동기 처리에 대한 성공과 실패에 대한 처리를 명시적으로 처리할 수 있다는 점이라고 생각해요!
이를 이용해서 useMutation
의 onSuccess를 통해 "성공하면 toast 띄워!"
를 작성해주었어요.
export const useDeleteTask = () => {
...
const toast = useToast();
return useMutation({
...
onSuccess: toast.success('세부 목표를 삭제했어요.'),
...
});
};
그러면 요청이 성공했을 경우 toast가 띄워지게 됩니다. 아래와 같이요!
여기까지 하면 끝입니다!
추가로.. 🤔 이 toast를 띄우는 과정에서 제가 한 바보 같은 짓이 있었어요.
export const useDeleteTask = () => {
...
const toast = useToast();
return useMutation({
...
onSuccess: () => {
toast.success('세부 목표를 삭제했어요.')
},
...
});
};
위와 같이 작성하면 토스트가 죽어도(?) 안 뜨더라구요. 처음에 이렇게 해놓고 왜 안 될까 계속 고민하다가 제가 작성한 로직들을 돌아보면서 깨닫게 되었어요.
지금 toast.success('세부 목표를 삭제했어요.')
는 다음과 같은 값이에요.
"🫤 이게 왜"
하실 수 있지만 잘 보시면 함수를 호출하고 있지 않아요. 그냥 함수 자체를 불러온 거죠 저는.....
그래서 만약에 onSuccess: () => { ... }
형태를 유지하면서 toast를 띄우고 싶다면 아래와 같이 해야 하는 것입니다.
export const useDeleteTask = () => {
...
const toast = useToast();
return useMutation({
...
onSuccess: () => {
toast.success('세부 목표를 삭제했어요.')()
},
...
});
};
이건 제가 설계했던 커링에 의해 발목을 잡힌 것 같았어요. (type) => (title) => {}
로 했더라면 위와 같은 일이 없었을 겁니다.
이로인해 커링으로 인한 장단점이 생겨서, 더 보편적인 상황을 생각해보았어요. 보통 toast만 띄우는 상황이 많다면 최종적으로 VoidFunction
을 리턴하는 커링을 유지했을 거에요.
고민 끝에 toast만 띄우는 상황 보다는 어떤 상태 처리나 부가적인 액션을 처리를 한 후에 toast를 띄워줄 일이 많다고 판단하여 최종적으로 이전에 작성했던 커링을 조금 수정했어요.
export const toastAtom = atom(
(get) => get(toastsAtom),
(get, set, type: ToastProps['type']) => (title: string) => {
const prev = get(toastsAtom);
const newToast = { type, title, id: Date.now().toString() };
set(toastsAtom, [...prev, newToast]);
},
);
이렇게 해주면 사용할 때는 아래와 같을 거에요.
const toast = useToast()
const handleToastButton = () => {
toast.success('세부 목표를 수정했어요.')
}
return (
<button onClick={handleToastButton}>누르면 토스트가 열려요</button>
)
그러면 useMutation에서도 다음과 같은 형태가 될 겁니다.
export const useDeleteTask = () => {
...
const toast = useToast();
return useMutation({
...
onSuccess: () => {
toast.success('세부 목표를 삭제했어요.')
},
...
});
};
좀 더 일반적인 상황에서 자연스럽게 처리될 수 있었습니다. 다른 개발자가 사용하더라도 지금 작성한 코드의 상황이 더 자연스러울 거라고 생각했습니다.
이번 글에서는 제가 제 코드에 발목이 잡혀 반나절 정도를 고생한 과정들을 적어보았어요. 저는 자주 쓰일 컴포넌트를 만들 때 미래(=사용할 때
)를 굉장히 고려하는 습관이 있어요. 이번에는 그 미래를 너무 많이 고려하다보니 일반적인 경우에 대해 지나쳐 버린 것 같았습니다.
또, 코드를 거슬러 올라가면서 문제점을 찾고 이를 개선하는 과정의 의미를 배운 것 같아요. 이러한 과정들을 돌아보기 위함으로 글을 작성했음을 알려드리고 글을 마무리해보려고 합니다 :)
재밌게 읽었습니다!!