카테고리 없음

[React] Toast 컴포넌트 구현하기

chaeon1 2025. 6. 9. 00:01

✨ 핵심 기능

  • 재사용 가능한 useToast()
  • Framer Motion 적용
  • React Portal을 사용해 DOM 트리 외부에 렌더링

1️⃣ Toast 컴포넌트

ReactDOM.createPortal을 이용해 Toast를 document.body에 직접 렌더링

이렇게 하면 부모 컴포넌트의 CSS나 위치에 영향을 받지 않고 자유롭게 위치 지정 가능

return ReactDOM.createPortal(
  <div className="...">
    <div className={`...`}>
      {message}
    </div>
  </div>,
  document.body,
);

2️⃣ useToast Hook

컴포넌트 어디서든 Toast 메시지를 쉽게 트리거할 수 있도록 커스텀 훅 작성

필요한 곳에서 showToast(message)만 호출하여 사용

import { useState, useCallback } from 'react';

export default function useToast() {
  const [message, setMessage] = useState('');

  const showToast = useCallback((msg: string) => {
    setMessage(msg);
    setTimeout(() => setMessage(''), 2000);
  }, []);

  return { message, showToast };
}
  • useToast() 훅은 상태(message) + 제어 함수 (showToast를 제공하는 컨트롤러 역할
  • Toast는 그 상태를 뷰로 표현하는 컴포넌트
  • 토스트를 띄우는 로직과 실제 UI를 분리함으로써 독립적으로 유지 → 재사용성, 유지보수성 증가

3️⃣ 실제 사용 예시

const { message, showToast } = useToast();

const handleCopy = async () => {
  await navigator.clipboard.writeText(`https://...`);
  showToast('클립보드에 복사되었습니다.');
};

...

{message && <Toast key={message} message={message} />}

동일한 메시지를 여러 번 연속으로 보여주기 위해 key={message} 사용

React에서는 동일한 key를 가진 컴포넌트를 재사용하려 하기 때문에 메시지가 바뀌지 않으면 Toast가 재렌더링되지 않음

이를 방지하고자 메시지 변경 시 강제 리마운트하도록 적용

4️⃣ 최종 코드

//Toast.tsx
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';

interface ToastProps {
  message: string;
  duration?: number;
}

export default function Toast({ message, duration = 2000 }: ToastProps) {
  const [show, setShow] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => setShow(false), duration);
    return () => clearTimeout(timer);
  }, [duration]);

  return ReactDOM.createPortal(
    <AnimatePresence>
      {show && (
        <motion.div
          className="fixed bottom-6 inset-x-0 mx-auto z-50 w-fit"
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 20 }}
          transition={{ duration: 0.5 }}
        >
          <div className="bg-black text-white text-sm px-4 py-2 rounded-lg shadow-lg text-center">
            {message}
          </div>
        </motion.div>
      )}
    </AnimatePresence>,

    document.body,
  );
}
// useToast.tsx
import { useState, useCallback } from 'react';

export default function useToast() {
  const [message, setMessage] = useState('');

  const showToast = useCallback((msg: string, duration: number = 2000) => {
    setMessage(msg);
    setTimeout(() => setMessage(''), duration);
  }, []);

  return { message, showToast };
}