끄적이는 개발노트
Next.js 15 - Toast Message (with. Portal & Context API) 본문
웹 혹은 앱을 개발하다보면 사용자의 행동에 대한 반응이 필요한 경우가 있다. 다들 한번쯤은 저장 버튼을 클릭했을 때 "저장 완료" 라던지 혹은 어떠한 이유로 버튼 기능이 실패했을 경우, "잠시 후에 다시 시도해 주세요." 같은 메세지를 본 적이 있을 것이다. 특히 앱에서 많이 이용되는데 요새는 웹에서도 모바일 뷰를 대다수 의식하고 개발하는 만큼 alert 대신에 toast 혹은 dialog를 많이 활용한다.
Toast Message 역시 마찬가지로 react-toastify, react-hot-toast와 같이 굉장히 좋은 라이브러리가 있지만, 저번 Calendar와 마찬가지로 내가 구현하고자 하는 Toast Message는 팝업과 자동 삭제 기능만 있으면 되고 추후에 기능이 추가되도 문제가 없게끔 커스텀으로 제작해보기로 했다.
활용한 기능은 createPortal, Context API, TainwindCSS 이다. Context API는 Recoil, Redux, Zustand 등 다양한 상태 관리로 대체해도 문제없다.
1. Portal 생성
Toast Message를 만들기 전에 먼저 portal를 만들어줘야 한다.
Portal?
DOM 계층 구조 바깥에 있는 DOM 노드.
부모 컴포넌트 안에 위치한 자식 컴포넌트를 바깥에 표현하게 해준다.
사용하는 이유는 Toast Message와 같은 경우는 layout.tsx에 글로벌하게 위치를 하던, 아니면 필요한 부분에서만 선언해서 쓰든 본인의 tree 구조에 따라서 부모 컴포넌트 안에 자식 컴포넌트로 위치할 수 밖에 없다. 그렇게 되면 DOM 구조적으로도 깔끔하지 않을 뿐만 아니라 부모 컴포넌트의 상태 변경과 같은 리렌더링에 따라 불필요한 렌더링이 발생할 수가 있다. 또한, 부모 컴포넌트의 style에 영향을 받을 수 밖에 없기 때문에 z-index 같은 처리가 불가피하게 요구된다. 따라서, Portal을 활용해 코드적으로는 root 혹은 어떠한 부모 컴포넌트 안에 존재하지만, 렌더링은 root 바깥에서 이루어지게끔 하는 것이다.
// components/portal.tsx
import { ReactNode } from "react";
import ReactDOM from "react-dom";
interface IPortalProps {
children: ReactNode;
}
function Portal({ children }: IPortalProps) {
const element =
typeof window !== "undefined" &&
(document.getElementById("portal") as HTMLElement);
if (!element) return null;
return ReactDOM.createPortal(children, element);
}
export default Portal;
위와 같이 Portal 컴포넌트를 생성하여 재사용이 가능하게끔 구성했다.
ReactDOM.createPortal를 활용하여 만드는데
첫번째 인자에는 children을 전달하여 portal 안에 보여줄 컴포넌트를 의미하고, 두번째 인자인 element는 id="portal"에 해당하는 div로 portal에 연결시켜주는 역할을 한다.
그러고나서 layout.tsx에 id가 portal인 div를 생성해주면 된다.
// app/layout.tsx
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { AsPathProvider } from "@/context/asPathContext";
const gmarket = localFont({
src: [
{
path: "../../public/assets/fonts/GmarketSansLight.otf",
weight: "300",
style: "light",
},
{
path: "../../public/assets/fonts/GmarketSansMedium.otf",
weight: "400",
style: "normal",
},
{
path: "../../public/assets/fonts/GmarketSansBold.otf",
weight: "700",
style: "bold",
},
],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={gmarket.className}>
<AsPathProvider>
{children}
{modal}
<div id="portal" /> <----- 추가
</AsPathProvider>
</body>
</html>
);
}
마지막으로 portal을 원하는 컴포넌트를 <Portal>태그로 감싸주면 된다. 이번 경우에는 Toast 컴포넌트를 감싸주면 된다.
2. Context 생성
이제 toast 메세지의 기능을 하는 context를 생성해야 한다.
먼저 구현하고자 하는 기능을 정리해보면 다음과 같다.
- Toast Message는 여러 개가 동시에 보여질 수 있도록 처리
- 각 Toast는 3000ms 이후 자동 사라짐
- 애니메이션 fade in / out 효과 적용 (500ms)
- 닫기 버튼을 통해 바로 사라짐
이를 구현한 코드는 아래와 같다.
// context/toastContext.tsx
"use client";
import type { ToastItem, ToastType } from "@/types/toastType";
import { createContext, ReactNode, useContext, useState } from "react";
interface ToastContext {
toastList: ToastItem[]; // 여러 toast가 가능하기 위해 배열로 생성
showToast: (type: ToastType, message: string) => void;
hideToast: (id: string) => void;
}
interface ToastProvider {
children: ReactNode;
}
const ToastContext = createContext<ToastContext>({
toastList: [],
showToast: () => {},
hideToast: () => {},
});
const duration = 3000; // toast가 보여지는 시간
const animation = 500; // fade-in/out 애니메이션 효과 시간
export const ToastProvider = ({ children }: ToastProvider) => {
const [toastList, setToastList] = useState<ToastItem[]>([]);
const showToast = (type: ToastType, message: string) => {
const id = Date.now().toString(); // 각각 toast는 id 값 필요(생성 시간)
const newToast = { id, type, message, shown: true }; // shown 값을 통해 보여짐 관리
// toast list 상태에 새로운 toast 추가
setToastList((prevList) => [...prevList, newToast]);
// duration - animation(실제 삭제 이전에 사라지는 효과를 부여하기 위한 시간 계산)
// id에 해당하는 toast의 shown 값을 false로 변경 setTimeout 처리
// 이후, 이 변수를 통해서 animation 효과를 부여
setTimeout(() => {
setToastList((prevList) =>
prevList.map((toast) =>
toast.id === id ? { ...toast, shown: false } : toast
)
);
}, duration - animation);
// duration 만큼 보여진 후 실제 삭제 setTimeout 처리
setTimeout(() => {
setToastList((prevList) => prevList.filter((toast) => toast.id !== id));
}, duration);
};
// 닫기 버튼을 눌렀을 경우 이벤트 처리
const hideToast = (id: string) => {
setToastList((prevList) => prevList.filter((toast) => toast.id !== id));
};
return (
<ToastContext.Provider value={{ toastList, showToast, hideToast }}>
{children}
</ToastContext.Provider>
);
};
export const useToast = () => useContext(ToastContext);
layout.tsx 기존 코드에 ToastProvider를 추가해준다.
// app/layout.tsx
...
export default function RootLayout({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={gmarket.className}>
<AsPathProvider>
<ToastProvider> <----- 추가
{children}
{modal}
<div id="portal" />
</ToastProvider>
</AsPathProvider>
</body>
</html>
);
}
// types/toastType.tsx
// 필요한 toastType 임의 지정
export type ToastType = "success" | "info" | "warning" | "error" | "default";
export interface ToastItem {
id: string;
type: ToastType;
message: string;
shown: boolean;
}
3. TailwindCSS에 fade-in / out 애니메이션 효과 추가
v4.1
버전이 바뀌면서 그동안 theme를 변경하는 방식이 조금 바뀌었다. (tailwind.config.ts => globals.css)
/* globals.css */
@import "tailwindcss";
/* @tailwind base;
@tailwind components;
@tailwind utilities;
*/
@theme {
...
--animate-fadeIn: fadeIn 0.5s ease-in-out;
--animate-fadeOut: fadeOut 0.5s ease-in-out;
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
...
v3.4.17
// tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
theme: {
extend: {
...,
keyframes: {
fadeIn: {
"0%": {
opacity: "0",
},
"100%": {
opacity: "1",
},
},
fadeOut: {
"0%": {
opacity: "1",
},
"100%": {
opacity: "0",
},
},
},
animation: {
fadeIn: "fadeIn 0.5s ease-in-out",
fadeOut: "fadeOut 0.5s ease-in-out",
},
...
},
},
plugins: [],
} satisfies Config;
보이는 것처럼 정말 간단하게 keyframes에 fadeIn, fadeOut 을 선언하고 0% - 100% (from - to 도 가능) 에 따른 opacity 처리를 해주면 된다. 이 후, animation 에 선언한 fadeIn, fadeOut 값을 시간과 효과를 같이 지정해주면 된다.
- ease : 느림 - 빠름 - 느림
- ease-in : 보통 - 빠르게
- ease-out : 보통 - 느리게
- ease-in-out : 느림 - 보통 - 느림
4. Toast Message 생성
// components/toast.tsx
"use client";
import React from "react";
import Portal from "./portal";
import CheckCircleIcon from "../../public/assets/fonts/svg/check-circle";
import InfoCircleIcon from "../../public/assets/fonts/svg/info-circle";
import WarningCircleIcon from "../../public/assets/fonts/svg/warning-circle";
import ErrorCircleIcon from "../../public/assets/fonts/svg/error-circle";
import CloseIcon from "../../public/assets/fonts/svg/close";
import { useToast } from "@/context/toastContext";
import type { ToastItem } from "@/types/toastType";
const ToastItem = ({ id, type, message, shown }: ToastItem) => {
const { hideToast } = useToast();
const toastColor = {
success: "bg-green-200 text-green-800",
info: "bg-blue-200 text-blue-800",
warning: "bg-orange-200 text-yellow-800",
error: "bg-red-200 text-red-800",
default: "bg-textMain",
};
const ToastIcon = () => {
switch (type) {
case "success":
return (
<CheckCircleIcon className="text-green-600 w-5 h-5 mr-3 mb-[0.5px]" />
);
case "info":
return (
<InfoCircleIcon className="text-blue-600 w-5 h-5 mr-3 mb-[0.5px]" />
);
case "warning":
return (
<WarningCircleIcon className="text-yellow-600 w-5 h-5 mr-3 mb-[0.5px]" />
);
case "error":
return (
<ErrorCircleIcon className="text-red-600 w-5 h-5 mr-3 mb-[0.5px]" />
);
default:
return null;
}
};
return (
<div
className={`flex items-center justify-between w-full min-w-64 max-w-[360px] px-4 py-4 rounded-md shadow-lg ${
toastColor[type]
} ${shown ? "animate-fadeIn" : "animate-fadeOut"}`}
>
<div className="flex items-center">
<ToastIcon />
<p className="text-sm font-normal">{message}</p>
</div>
<button
type="button"
className="ml-4 text-textNormal rounded-lg inline-flex items-center justify-center"
onClick={() => hideToast(id)}
>
<CloseIcon className="w-4 h-4 mb-1" />
</button>
</div>
);
};
const ToastList = () => {
const { toastList } = useToast();
if (toastList.length === 0) return null;
return (
<Portal>
<div className="fixed top-4 right-4 z-[99] flex flex-col gap-1 overflow-hidden md:top-8 md:right-8">
{toastList.map((toast) => (
<ToastItem
key={toast.id}
id={toast.id}
type={toast.type}
message={toast.message}
shown={toast.shown}
/>
))}
</div>
</Portal>
);
};
export default ToastList;
- 임의로 toast type에 따라 색깔 및 아이콘 지정
- context에서 변경한 shown 값에 따라 fade-in / out 효과 부여
- toast list 값이 있으면 Portal 안에 toast 생성
// app/toast/page.tsx
"use client";
import ToastList from "@/components/toast";
import { useToast } from "@/context/toastContext";
const TestPage = () => {
const { showToast } = useToast();
return (
<div className="w-full h-full flex flex-col justify-center items-center gap-4">
<p>Toast Message 테스트 페이지</p>
<button
type="button"
className="bg-black py-3 px-4 text-white rounded-lg"
onClick={() => showToast("success", "성공입니다!")}
>
Toast
</button>
<ToastList />
</div>
);
};
export default TestPage;
이제 원하는 위치에서 <ToastList /> 를 선언하고 사용하면 된다.
5. 실행
실행해보면 애니메이션 효과가 적용된 Toast Message가 클릭에 따라 생기고 사라지는 것을 확인할 수 있다.
'JavaScript > Next.js' 카테고리의 다른 글
Next.js 15 - SearchableDropdown (1) | 2025.05.28 |
---|---|
Next.js 15 - Zustand (0) | 2025.05.21 |
Next.js 15 - Calendar 만들기 (with. dayjs) (0) | 2025.05.08 |
Next.js 15 - Modal 구현 (with. Parallel Routes & Intercepting Routes) (1) | 2025.04.26 |
Next.js 15 - 전역 상태 Context API (0) | 2025.04.24 |