[React] createPortal을 이용해서 Toast 컴포넌트 만들기

    반응형

     

    지금까지 Dialog, Toast, Snackbar 같은 걸 만들 때 아래와 같이 만들었다

    return (
      /* HTML 내용 */
    
      <Dialog
        isOpen={modalOpen}
        setOpen={() => setModalOpen(false)}
        title={modalTitle}
        content={modalMessage}
        button={
          <Button
            styleType="primary"
            type="button"
            onClick={handleCloseModal}
            label="확인"
            className="px-4"
          />
        }
      />
    )

    dialog를 띄우고 싶은 페이지 또는 UI 컴포넌트 내에서 일일히 import를 하는 형태였다

    그러다가 MUI의 snackbar를 걷어내고 내부적으로 컴포넌트를 만들어서 사용하려는데 Toast 컴포넌트를 일일이 위와 같이 import를 해서 써야 하나? 심지어 내부에 들어갈 text도 상황에 따라 매번 바꿔서 보여줄 건데 그거 하나 하자고 state를 또 일일이 만들어줘야 하나?라는 생각이 든 거다

      showToast({
        open: true,
        message: "저장할 데이터가 없습니다.",
        type: "warning",
      });

    나는 간단하게 위와 같이 함수 내에서 상황에 맞게 호출하고 싶은데 무슨 방법이 있을지 알아보다가 createPortal를 쓰면 된다는 걸 알았다

    사실 엄청 기초적인 건데 createPortal를 쓸 생각조차 못했다... 아니 애초에 createPortal라는게 있는지도 몰랐고... 부끄러워지는 순간이다... ^_ㅠ... React를 그렇게 몇 년 쓰면서 늘 쓰던 것만 써서... 참으로... 암튼... createPortal라는 걸 쓰면 하위 컴포넌트든~ 함수 내부에서든~ 어디서든~ 원하는 DOM 위치에 렌더링을 할 수 있는 거다

    일단 공통으로 쓸 Toast 컴포넌트를 만들어준다

    function Toast({ message, type, onClose }) {
      return (
        <div
          className={`fixed px-6 py-4 rounded-md shadow-[0px_0px_25px_-2px_#5c5c5c5e] max-w-[600px] w-auto text-base left-1/2 -translate-x-1/2 z-[100] transition-all ${
            styleType[type].bg
          } top-14`}
        >
          <div className="flex gap-x-2">
            <div className={`w-6 ${styleType[type].text}`}>{iconType[type]}</div>
            <div>
              <p className="font-semibold leading-[1.6]">{typeKRString[type]}</p>
              <p className="mt-1 break-all">{message}</p>
            </div>
          </div>
        </div>
      );
    }
    
    export default Toast;

    대충 위와 같은 느낌으로 만들어주고 이 컴포넌트를 전역적으로 import 해서 어디서든 호출해서 쓰려면 Context를 만들어줘야 한다

    import { createContext, useContext, useState } from "react";
    import { createPortal } from "react-dom";
    import Toast from "~/components/Toast";
    
    interface IToast {
      open: boolean;
      message: string;
      type: "success" | "error" | "info" | "warning";
    }
    
    interface IToastContext {
      showToast: (props: IToast) => void;
    }
    
    const ToastContext = createContext<IToastContext | null>(null);
    
    export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
      const [toastState, setToastState] = useState<IToast>({
        open: false,
        message: "",
        type: "info",
      });
    
      const showToast = (props) => {
        setToastState(props);
    
        setTimeout(() => {
          setToastState({ open: false, message: "", type: "info" });
        }, 4500);
      };
    
      return (
        <ToastContext.Provider value={{ showToast }}>
          {children}
          {toastState.open &&
            createPortal(
              <Toast
                {...toastState}
                onClose={() =>
                  setToastState({ open: false, message: "", type: "info" })
                }
              />,
              document.body
            )}
        </ToastContext.Provider>
      );
    };
    
    export const useToast = () => useContext(ToastContext);

    위와 같이 Context를 만들어줬다. body 최하단에 렌더링 되는 거고 showToast를 호출해서 open이면 4500ms동안 노출 후 자동으로 사라지고(DOM 삭제) state를 초기화해준다

    function Root() {
      return (
        <BrowserRouter>
          <ToastProvider>
            <App />
          </ToastProvider>
        </BrowserRouter>
      );
    }

    Root에 context import를 해준다 여기까지 완벽하게 잘 작동한다!

    그러나 한 가지 문제는 나는 위에서 스르륵 나오고 빠지고 했으면 좋겠어서 open일 땐 top이 몇 픽셀, 사라질 땐 화면 밖으로 밀어내는 CSS를 주고 transition을 줬는데 원하는 대로 CSS가 동작하지 않는 거다

    이유는 transition이 작동하는 타이밍이 DOM이 생성되기 전에 실행되어 버리고, Toast의 open이 false로 될 때는 DOM이 먼저 사라져 버리니 transition 동작이 안 하는 것이다 

    그래서 DOM이 먼저 생성되고 CSS를 조작하는 방식으로 가야겠어서 Toast 컴포넌트 내부에 state를 만들어줬다

    function Toast({ open, message, type, onClose }) {
      const [visible, setVisible] = useState<boolean>(false);
    
      useEffect(() => {
        setVisible(true);
        const timer = setTimeout(() => {
          setVisible(false);
        }, 4000);
        return () => clearTimeout(timer);
      }, []);
    
      return (
        <div
          className={`fixed px-6 py-4 rounded-md shadow-[0px_0px_25px_-2px_#5c5c5c5e] max-w-[600px] w-auto text-base left-1/2 -translate-x-1/2 z-[100] transition-all ${
            styleType[type].bg
          } ${visible ? "top-14" : "-top-[100px]"}`}
          onTransitionEnd={() => {
            if (!visible) return onClose();
          }}
        >
          <div className="flex gap-x-2">
            <div className={`w-6 ${styleType[type].text}`}>{iconType[type]}</div>
            <div>
              <p className="font-semibold leading-[1.6]">{typeKRString[type]}</p>
              <p className="mt-1 break-all">{message}</p>
            </div>
          </div>
        </div>
      );
    }
    
    export default Toast;

    DOM이 생성되면 그때 top값이 바뀐다. 그러면서 transition이 동작하는 형태이다.

    그리고 DOM은 4500ms뒤에 사라지는데 그것보다 빨리 화면 밖으로 나가는 동작이 먼저 실행되어야 하기 때문에 타이머를 4000ms로 잡아준 것이다

    참고로 onTransitionEnd은 transition이 끝났을 때 트리거되는 이벤트인데 화면 밖으로 DOM이 이동된 후에 toast 내부 content를 초기화하는 함수를 호출하게 만들었다

    이유는 content가 초기화되는 로직이 먼저 실행되다 보니 밖으로 toast가 밀려나가는 중에 content가 없어서 width가 줄어들면서 나가는 현상이 발생하는 거다. 그게 보기 좋지 않다 보니 해당 로직을 추가해 준 거다

    이제 createPortal이라는 API를 제대로 알게 되었으니 기존 Dialog 컴포넌트도 createPortal를 활용하는 쪽으로 바꿔보려고 한다

    반응형

    댓글