FE/react

[React] 토스트팝업/스낵바(toast popup/snack bar)직접 구현하기

s0ojin 2023. 5. 24. 16:15

 

좌: 에러메세지UI, 우: 토스트 팝업

 

 

 1차 배포 후 알파테스트를 진행하고 있는데, 제목과 내용이 빈 채로 글이 작성된다는 사실을 발견했습니다. 내용을 필수 입력하도록 설정을 해놓았는데 공백만 입력하는 경우를 고려하지 않았던 것이죠... 글 작성 시 공백으로만 글을 쓸 수 없게 만들고, 그 사실을 유저에게 알려야하는데 로그인 validation 에러메세지 방식으로 하기엔 UI가 좀 어색하다는 생각이 들어서 toast pop-up을 고려하게 되었습니다. (잠시... 기말고사 기간 동안 디자이너분께서 공부를 하러가셨으므로... 직접 어색하지않은 토스트 팝업을 뚝딱뚝딱 만들어 보았습니다. 용도에 따라 배경색을 다르게 쓰는게 좋은데 그건 디자이너 분과 상의해봐야하므로 우선 메인컬러를 사용했습니다.)

 

토스트 팝업(toast pop-up) / 스낵바(snack bar) 란?


유튜브 댓글 삭제 시 하단에 뜨는 toast pop-up

 

 토스트 팝업은 유저의 액션에 대한 피드백을 하기 위한 용도로 주로 사용합니다. 토스트처럼 튀어나오는 듯한 모션에서 따온 이름인 것으로 짐작됩니다. 토스트 팝업은 노출 후 몇 초 뒤에 저절로 사라지는 것이 특징입니다. 경우에 따라서는 내부에 팝업을 유저가 직접 닫을 수 있는 버튼을 포함하기도하는데, 이 경우에는 스낵바라고 부르기도 합니다. 하지만 좀 찾아보니 용어같은 경우 대개는 구분없이 사용하는 것 같습니다. 그냥 토스트 팝업, 스낵바라고 하면 하단이나 상단에 짧은 메세지와 함께 등장했다가 사라지는 UI 정도로 생각하시면 되겠습니다. 

 

구현 방법


 react에서 toast pop-up을 구현할 땐 react-tostify 라는 대표적인 라이브러리가 있습니다. 상황에 따라 직접 구현보다는 라이브러리를 사용하는 편이 효율적일 때가 많습니다. 그러나 구현하는게 복잡하지 않을 것 같으면 직접구현하는 편이 패키지가 적어 가볍고, 공부도 되고, 자유도도 높고, 피치못하게 라이브러리 사용이 불가능할 때 유연하게 대처할 수 있다는 생각에...이번에도 직접 구현을 택했습니다. 간단하고 재밌기도하니 제 블로그까지 들어오신 김에 직접 구현해보신 적이 없다면 직접 구현해보시는 걸 추천드립니다ㅎㅎ 

 

react에서 toast pop-up은 useStatesetTimeout 으로 쉽게 구현할 수 있습니다.

 

 

1. 팝업을 띄울 페이지 컴포넌트에서 팝업 노출 상태를 관리합니다.

function BoardCreate() {

  const [toast, setToast] = useState(false);

  return ();
}

export default BoardCreate;

 

2. 트리거되는 함수가 실행될 때 toast를 true로 바꿔줍니다. 저의 경우는 validation에서 공백만 제출될 경우를 트리거로 했습니다. 

function BoardCreate() {
  //useState로 팝업 노출 상태를 관리합니다.
  const [toast, setToast] = useState(false);

  const onSubmit: SubmitHandler = (data) => {
  	//form에서 넘어온 데이터가 공백으로만 이루어져 있다면,
    if (!data.title.trim() || !data.content.trim()) {
      setError('title', { type: 'trim' });
      // toast pop-up이 보이도록 true로 변경해줍니다.
      setToast(true);
      return;
    }
  };

  return ();
}

export default BoardCreate;

 

3. toast 팝업 컴포넌트를 제작해줍니다. 저는 toastPopup가 3개의 인수를 갖도록 디자인했습니다.

message : 컴포넌트의 재사용성을 높이기 위해서 메세지는 prop으로 전달받아 사용합니다.

setToast : 1번 단계 부모컴포넌트(팝업을 띄우 컴포넌트)에서 선언한 setState입니다. 몇 초 후 자동으로 팝업이 사라지도록 설정하기 위해 반드시 prop으로 받아와야합니다.

position : 팝업을 띄울 위치입니다. 저의 경우는 상단과 하단에 띄울 경우가 나눠질 것 같아서 추가한 속성으로, 프로덕트에 맞게 사용하시면 되겠습니다.

 

 여기서 추가로 type과 같은 속성을 추가할 수 있습니다. 경고, 실패, 성공 등 다양한 경우에 따라 토스트 팝업의 색상이나 디자인을 변경하려면 필요한 옵션입니다. 저는 우선 당장 필요하지 않아서 해당 속성을 추가하진 않았고, 만약 다양한 디자인의 팝업을 만드실 거라면 type과 같은 속성도 포함해주세요! 

 

** 팝업 애니메이션 또한 토스트팝업 컴포넌트에 작성해주시면 됩니다. 저는 여기에서 적당한 애니메이션을 가져와 수정해서 사용했습니다.

export default function ToastPopup({
  message,
  setToast,
  position,
}) {
  return (
    <div
      className={`fixed z-20 flex h-[4rem] w-[90%] max-w-[73rem] items-center justify-center rounded-[1rem] bg-green-50 opacity-[97%] shadow-[0px_2px_8px_rgba(0,0,0,0.25)] ${
        position === 'top' ? 'animate-toast-top' : 'animate-toast-bottom'
      }`}>
      <p className="text-Body text-white">{message}</p>
    </div>
  );
}

 

 

4. 부모컴포넌트에서 setToast와 기타 prop들을 toastPopup 컴포넌트로 전달합니다. 이때 toast가 true일 때만 팝업이 보이도록 코드를 작성합니다.

function BoardCreate() {
  //useState로 팝업 노출 상태를 관리합니다.
  const [toast, setToast] = useState(false);

  const onSubmit: SubmitHandler = (data) => {
  	//form에서 넘어온 데이터가 공백으로만 이루어져 있다면,
    if (!data.title.trim() || !data.content.trim()) {
      setError('title', { type: 'trim' });
      // toast pop-up이 보이도록 true로 변경해줍니다.
      setToast(true);
      return;
    }
  };

  return (
    <div className="container">
      <form onSubmit={handleSubmit(onSubmit)} className="w-full px-[2rem]">
		{/* 페이지 UI 코드 중략*/}
      </form>
      {/* toast가 true일 때만 팝업이 노출됩니다.*/}
      {toast && (
        <ToastPopup
          setToast={setToast}
          message={'⚠️ 공백으로만 입력할 수 없습니다.'}
          position="bottom"
        />
      )}
    </div>
  );
}

export default BoardCreate;

 

5. 팝업 컴포넌트 내부에서 setTimeout와 setToast를 이용해 3초 후 toast를 false로 변경해줍니다. 

export default function ToastPopup({
  message,
  setToast,
  position,
}) {

  // 3초 뒤 toast를 false로 변경합니다.
  useEffect(() => {
    const timer = setTimeout(() => {
      setToast(false);
    }, 3000);
    return () => {
      clearTimeout(timer);
    };
  }, [setToast]);

  return (
    <div
      className={`fixed z-20 flex h-[4rem] w-[90%] max-w-[73rem] items-center justify-center rounded-[1rem] bg-green-50 opacity-[97%] shadow-[0px_2px_8px_rgba(0,0,0,0.25)] ${
        position === 'top' ? 'animate-toast-top' : 'animate-toast-bottom'
      }`}>
      <p className="text-Body text-white">{message}</p>
    </div>
  );
}

 

전체 코드 (Typescript 포함)


export default function ToastPopup({
  message,
  setToast,
  position,
}: {
  message: string;
  setToast: React.Dispatch<React.SetStateAction<boolean>>;
  position: 'top' | 'bottom';
}) {
  useEffect(() => {
    const timer = setTimeout(() => {
      setToast(false);
    }, 3000);
    return () => {
      clearTimeout(timer);
    };
  }, [setToast]);

  return (
    <div
      className={`fixed z-20 flex h-[4rem] w-[90%] max-w-[73rem] items-center justify-center rounded-[1rem] bg-green-50 opacity-[97%] shadow-[0px_2px_8px_rgba(0,0,0,0.25)] ${
        position === 'top' ? 'animate-toast-top' : 'animate-toast-bottom'
      }`}>
      <p className="text-Body text-white">{message}</p>
    </div>
  );
}
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import ToastPopup from 'src/components/toastPopup/ToastPopup';

interface IFormInput {
  title: string;
  content: string;
}

function BoardCreate() {

  const { register, handleSubmit, setValue, setError } = useForm<IFormInput>({
    mode: 'all',
  });
  
  const [toast, setToast] = useState(false);

  const onSubmit: SubmitHandler<IFormInput> = (data) => {
    if (!data.title.trim() || !data.content.trim()) {
      setError('title', { type: 'trim' });
      setToast(true);
      return;
    }
  };
  
  return (
    <div className="container">
      <form onSubmit={handleSubmit(onSubmit)} className="w-full px-[2rem]">
		{/* 페이지 UI 코드 중략*/}
      </form>
      {toast && (
        <ToastPopup
          setToast={setToast}
          message={'⚠️ 공백으로만 입력할 수 없습니다.'}
          position="bottom"
        />
      )}
    </div>
  );
}

export default BoardCreate;

 

작동 모습