시작하며...
중급 프로젝트에서 Toast 를 react-toastify 라이브러리와 Styled Components 를 사용해서 커스텀하여 사용하였다.
그러나 간혈적으로 토스트가 생기지 않고 갑자기 다른 곳에서 생기는 등(?) 이상한 현상이 발생하였다.
주강사님께 여쭤보니깐 아마 Next.js Hydration 에러인 것 같다고 해서 Context 를 사용해서 구현해 보라고 피드백을 주셨다.
당시에 토스트를 다시 구현할 시간이 없어서 나중에 기회가 되면 직접 Toast 컴포넌트를 구현하고 싶었는데,
마침 이번 프로젝트에서 Jotai 라는 상태 관리 라이브러리를 사용하여 직접 구현하게 되었다.
구현하기
참고로 나는 토스트가 스택처럼 여러 개 쌓이는 걸 별로 안 좋아한다..
그리고 다른 팀원분도 토스트가 쌓이지 않고 하나만 딱 보였으면 좋겠다고 해서 화면에 토스트 1개만 보이도록 구현하였다.
만약 여기서 토스트를 스택처럼 쌓고 싶다면 jotai 로 배열로 구현을 하고, 추가할 땐 기존 배열에 새로운 토스트를 추가하고, 삭제할 땐 filter 를 사용해서 해당 id값을 가진 것을 제거하면 된다.
1. Jotai로 토스트 상태 값 설정
1-1. 토스트 데이터 atom 정의
현재 화면에 표시될 토스트 데이터를 저장하는 ToastDataAtom 을 생성하고, 기본값을 null 로 설정한다.
export const ToastDataAtom = atom<ToastProps | null>(null);
+) 토스트 타입
참고로 토스트 타입은 다음과 같다.
export type ToastType = "success" | "error";
export interface ToastProps {
/** toast 타입입니다. success, error */
type: ToastType;
/** toast 메시지입니다. */
message: string;
/** toast 고유 id값입니다. 사용하실 때 고려하지 않아도 됩니다. */
id?: string;
}
1-2. 토스트 추가 atom 정의
Jotai 의 atom 함수를 사용해서, 토스트를 추가할 때 상태를 업데이트해 주는 ToastAtom 을 생성한다.
atom 의 첫 번째 인자에는 초기값이, 두 번째 인자에는 상태 변경 시 호출되는 함수이다.
초기값이 없기 때문에 첫 번째 인자에는 null 을, 두 번째 인자에는 상태를 업데이트하는 함수를 작성한다.
- get: 다른 atom 값을 가져올 때 사용하는 함수이지만, 여기서는 사용되지 않기 때문에 _ 로 설정한다.
- set: atom의 값을 업데이트할 때 사용하는 함수이다.
- update: 새로운 값을 나타내는 파라미터이다.
두 번째 인자의 update 로 받은 토스트의 타입과 메시지를 사용해서 새로운 토스트 객체인 newToast 를 만든다.
그리고 set 함수를 사용해, newToast 를 ToastDataAtom 에 설정하여 새로운 토스트를 상태에 반영한다.
export const ToastAtom = atom(null, (_, set, { type, message }: ToastProps) => {
// 새로운 토스트
const newToast = {
type,
message,
id: Date.now().toString(),
};
// ToastDataAtom 를 newToast 로 상태 업데이트
set(ToastDataAtom, newToast);
});
1-3. 토스트 제거 atom 구현
현재 토스트를 제거하기 위해 set 함수를 사용하여 ToastDataAtom 값을 null 로 설정한다.
export const RemoveToastAtom = atom(null, (_, set) => {
set(ToastDataAtom, null);
});
2. 토스트 컨테이너
실제로 토스트를 화면에 표시하는 컴포넌트를 만든다.
useAtomValue 를 사용하여 현재 토스트 데이터를 가져온다.
그리고 가져온 토스트 데이터를 화면에 렌더링 할 때, createPortal 을 사용하여 토스트를 document.body 에 렌더링 하여 다른 컴포넌트에 영향을 받지 않도록 한다.
참고로 클라이언트 사이드 렌더링을 보장하기 위해 isClient 가 false 일 때는 null 을 반환하여 서버 사이드에서는 렌더링 되지 않도록 하였다. (클라이언트 컴포넌트라 상위에 "use client" 지시문 선언함)
const ToastContainer = () => {
const [isClient, setIsClient] = useState(false);
const toast = useAtomValue(ToastDataAtom);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
return ReactDOM.createPortal(
<div className="fixed left-1/2 top-30 z-[60] flex -translate-x-1/2 flex-col items-center gap-12 sm:w-full sm:px-12 md:min-w-400">
{toast && <Toast {...toast} />}
</div>,
document.body,
);
};
3. 토스트 컴포넌트
실제 토스트를 표시할 컴포넌트를 만든다.
3-1. Toast 컴포넌트 정의
불필요한 렌더링을 방지하기 위해 memo 함수를 사용해 컴포넌트를 메모이제이션 하였다.
그리고 토스트를 제거하는 RemoveToastAtom과, 토스트의 표시 여부인 show 값을 가져온다.
const TOAST_DURATION = 2000;
const Toast = memo(({ type, message = "test", id }: ToastProps) => {
const removeToastItem = useSetAtom(RemoveToastAtom);
const [show, setShow] = useState(true);
3-2. 토스트 제거
토스트 컨테이너에서 토스트를 표시했다면, 토스트 컴포넌트에서는 시간이 지나는 등에 의해 토스트가 화면에 사라지는 것을 removeToastItem(RemoveToastAtom)를 사용해서 구현해야 한다.
토스트 지속 시간이 지나면 show 상태를 false 로 설정하고, show 상태가 false 로 변경된 후, 300ms 뒤에 removeToastItem(RemoveToastAtom) 을 호출하여 토스트를 제거한다.
(300ms 뒤에 제거하지 않고 바로 제거하면, 토스트가 점점 사라지는 애니메이션이 적용되기 전에 사라지기 때문에 300ms 설정함)
그리고 클린업 함수를 통해 타이머를 정리한다.
useEffect(() => {
const fadeOutTimeout = setTimeout(() => {
setShow(false);
const removeTimeout = setTimeout(() => {
removeToastItem();
}, 300);
return () => clearTimeout(removeTimeout);
}, TOAST_DURATION);
return () => clearTimeout(fadeOutTimeout);
}, [id, removeToastItem]);
3-3. 토스트 렌더링
Lottie 를 사용하여 움직이는 이미지를 적용하고 Tailwind CSS 로 토스트가 자연스럽게 사라지고 생기는 것을 구현하였다.
return (
<div
className={clsx(
"inline-flex cursor-pointer items-center rounded-lg border bg-background-tertiary px-24 py-12 text-center shadow-md transition-all duration-300 ease-in-out sm:w-full",
{
"text-point-green border-point-green": type === "success",
"text-point-rose border-point-rose": type === "error",
"animate-fadeIn": show,
"animate-fadeOut": !show,
}
)}
onClick={() => removeToastItem()}
>
{type === "success" ? (
<Lottie
animationData={SuccessAnimation}
style={{ width: "24px", height: "24px", marginRight: "6px" }}
/>
) : (
<Lottie
animationData={ErrorAnimation}
style={{
width: "18px",
height: "18px",
marginLeft: "3px",
marginRight: "10px",
}}
/>
)}
<span className="mr-6 text-16-500">{message}</span>
</div>
);
+) Tailwind CSS 애니메이션
참고로 Tailwind CSS 로 이렇게 애니메이션을 구현하였다.
const config: Config = {
theme: {
extend: {
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
fadeOut: {
"0%": { opacity: "1" },
"100%": { opacity: "0" },
},
},
animation: {
fadeIn: "fadeIn 0.3s ease-in-out",
fadeOut: "fadeOut 0.3s ease-in-out",
},
},
},
plugins: [],
};
export default config;
4. useToast 훅 생성
이제 직접 구현한 토스트를 쉽게 사용할 수 있도록 훅을 만든다.
ToastAtom 을 사용하여 토스트를 추가할 수 있게 하고, success 와 error 함수에 message 를 인자로 받아 해당 타입의 토스트를 추가하는 함수를 반환한다.
const useToast = () => {
const addToast = useSetAtom(ToastAtom);
return {
success: (message: string) => addToast({ type: "success", message }),
error: (message: string) => addToast({ type: "error", message }),
};
};
export default useToast;
5. Toast 사용하기
위에서 생성한 useToast 훅을 사용하여 success, error 함수를 가져와 메시지를 인자로 넣으면 해당 토스트가 화면에 표시된다.
import useToast from "@/hooks/useToast";
const testComponent = () => {
const { success, error } = useToast();
const handleSuccess = () => {
success("성공 토스트 입니다");
};
const handleError = () => {
error("실패 토스트 입니다");
};
return (
<div>
<button onClick={handleSuccess}>성공</button>
<button onClick={handleError}>실패</button>
</div>
);
};
6. 결과
마치며...
한 번 정도는 직접 토스트 컴포넌트를 구현하고 싶었는데 이렇게 직접 구현할 수 있어서 개발적으로 성장을 느낄 수 있었다.
특히 Jotai와 Tailwind를 사용하여 내가 원하는 방식대로 토스트를 구현할 수 있어 좋았다.
여기 게시물엔 내용이 없지만, 처음에는 Framer Motion을 사용해서 애니메이션을 적용했지만, 아마도 모바일 성능 문제 때문에 모바일에서 토스트가 깜박거리는 현상이 있었다.
그래서 이를 해결하기 위해 Tailwind CSS를 사용하여 애니메이션을 다시 구현하여 문제를 해결할 수 있었다.
이렇게 팀원의 요구사항에 맞춰 여러 개의 쌓이는 토스트가 아니라 하나의 토스트만 화면에 표시되도록, 또한 깜박거리는 현상을 Tailwind 로 변경하여 문제를 해결하는 등 상황에 맞게 유연하게 구현할 수 있었다는 점에서 문제를 개선해 나가는 경험이 보람이 있었다..
'💜 프로젝트 구현' 카테고리의 다른 글
[TanStack Query] 옵티미스틱 업데이트 사용하여 좋아요 구현 (1) | 2024.09.18 |
---|---|
[Next.js] Lighthouse 웹 사이트 성능 개선 (100으로 만들기) (0) | 2024.09.10 |
[Next.js / TanStack query] useInfiniteQuery 사용하여 무한 스크롤 구현 (feat. cursor 방식) (0) | 2024.09.07 |
[Next.js] 세션 스토리지, 이전 페이지 저장해서 뒤로 가기 구현 (feat. app router) (0) | 2024.09.02 |
[Next.js] 무한 캐러셀 컴포넌트 직접 구현하기 (feat. Tailwind CSS) (2) | 2024.09.02 |
FE 개발자가 되고 싶은 짱잼이
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!