본문 바로가기

웹 프론트엔드

Context API를 활용하여 선언적으로 Toast 띄우기

서언

Context API는 쓰는 사람에 따라서 다양한 방식으로 사용됩니다. 그 이유는 Context API에서 전역적인 상태를 선언할 수 있고, 동시에 컴포넌트도 반환 해줄 수 있기 때문입니다. Context API 사용 예시를 찾아보면, 대부분 상태를 전역적으로 제공하는 역할을 하고, children을 그대로 반환해주는 형태를 많이 볼 수 있습니다. 하지만 컴포넌트를 반환할 수 있다는 특징을 활용하면 Toast, Modal, Loading 같은 UI를 선언적으로 사용할 수 있지 않을까 고민하였습니다. 이번 글은 [꼭꼭 팀]이 Context API를 사용하여 상태를 가진 UI를(Toast) 선언적으로 보여주는 방법에 대해 설명드리고자 합니다. 먼저 명령적인 Toast 사용을 알아보고, 하나씩 개선해나가 보겠습니다.

명령적인 Toast 사용

interface ToastProps {
  message: string;
}

const Toast = ({ message }: ToastProps) => {
  return <p>{message}</p>;
};
import { useRef, useState } from "react";
import { Toast } from ".";

const SimpleComponent = () => {
  const [message, setMessage] = useState("");
  const [isOpenToast, setIsOpenToast] = useState(false);
  const toastTimer = useRef<NodeJS.Timeout>();

  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  const handleClick = () => {
    showToast("토스트가 3초간 띄워집니다.");
  };

  return (
    <div>
      <button onClick={handleClick}>Show Toast!</button>
      {isOpenToast && <Toast message={message} />}
    </div>
  );
};

export default SimpleComponent;

전혀 추상화하지 않은 원초적인 Toast 사용 방법입니다. 컴포넌트 내부에서

  1. 상태를 정의하고,
  2. Toast 로직을 정의하고,
  3. isOpenToast 상태에 따라 Toast 컴포넌트를 렌더링합니다.


이 방식은 어떤 단점이 있을까요?

  1. Toast를 사용할 때마다 상태를 정의하고, 조건문에 따라 Toast를 렌더링시켜야 한다.
  2. Toast 로직이 컴포넌트에 속해 있다.
  3. 컴포넌트가 unmount되면 Toast 또한 unmount 된다.


Toast 로직이 매번 반복되며, Toast 로직 때문에 컴포넌트를 읽기 어렵습니다. 그러므로 Toast 로직을 Hook으로 분리하여 재사용 가능한 Toast을 만들 필요가 있습니다.

Toast 로직을 Hook으로 분리하기

import { useRef, useState } from "react";

const useToast = () => {
  const [message, setMessage] = useState("");
  const [isOpenToast, setIsOpenToast] = useState(false);
  const toastTimer = useRef<NodeJS.Timeout>();

  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  return { isOpenToast, message, showToast };
};

export default useToast;

위와 같이 훅으로 분리합니다. 이제 재사용이 가능해졌습니다. 컴포넌트에서 한번 사용해보겠습니다.

import { Toast } from "./ToastProvider";
import useToast from "./useToast";

const SimpleComponent = () => {
  const { isOpenToast, message, showToast } = useToast();

  const handleClick = () => {
    showToast("토스트가 3초간 띄워집니다.");
  };

  return (
    <div>
      <button onClick={handleClick}>Show Toast!</button>
      {isOpenToast && <Toast message={message} />}
    </div>
  );
};

export default SimpleComponent;

이와 같이 Toast를 사용한 적이 있었습니다. 이 방법의 결정적인 단점이 존재합니다. 바로 SimpleComponent가 unmount 되면, Toast 도 함께 unmount 된다는 것입니다. 이벤트가 트리거되었을 때 동작의 성공/실패를 알려주기 위해 Toast 를 사용합니다. 때때로 페이지가 전환되는 등 기존 컴포넌트가 unmount 되기도 합니다. 이런 상황에도 대응할 수 있어야 합니다.

개선한 점

Toast 로직을 분리하여 재사용 가능하다.

개선해야할 점

컴포넌트가 unmount 되면 Toast 또한 unmount 된다.

Context API로 선언적인 Toast 호출

현재 컴포넌트에 종속되도록 Toast를 사용하고 있습니다. 그렇기 때문에 mount 여부도 해당 컴포넌트에 종속적입니다. 어떻게 컴포넌트에 종속적이지 않게 만들 수 있을까요?

바로 Toast를 최상단에 선언해두고, 이를 사용하는 컴포넌트에서는 on/off 만 수행해주는 것입니다. 최상단에 Toast 컴포넌트를 선언하며, 상태를 전역에서 관리하는 방법으로 Context API를 사용할 수 있습니다.

import { createContext, PropsWithChildren, useRef, useState } from "react";
import ReactDOM from "react-dom";

export const ToastContext = createContext({ showToast(message: string) {} });

const ToastProvider = ({ children }: PropsWithChildren) => {
  const [message, setMessage] = useState("");
  const [isOpenToast, setIsOpenToast] = useState(false);
  const toastTimer = useRef<NodeJS.Timeout>();

  const showToast = (message: string) => {
    setIsOpenToast(true);
    setMessage(message);

    if (toastTimer.current) {
      clearTimeout(toastTimer.current);
    }

    const timer = setTimeout(() => {
      setIsOpenToast(false);
      setMessage("");
    }, 3000);
    toastTimer.current = timer;
  };

  return (
    <ToastContext.Provider value={{ showToast }}>
      {children}
      {isOpenToast && <Toast message={message} />}
    </ToastContext.Provider>
  );
};

Context API는 상태를 전역적으로 사용할 수 있게 만들어주고, 컴포넌트까지 return 할 수 있습니다. 그리고 Provider를 최상단에 선언하고, 다른 컴포넌트의 mount 여부에 영향을 받지 않을 수 있습니다. 추가로 최상단에서 Toast를 선언하기 때문에 쌓임맥락(z-index)을 신경쓰지 않아도 됩니다.

어떤 의미에서 선언적이라고 할 수 있는지 하위 컴포넌트에서 직접 사용해보면서 보여드리겠습니다.

import { useContext } from "react";
import { ToastContext } from "./ToastProvider";

function SimpleComponent() {
  const { showToast } = useContext(ToastContext);

  const handleClick = () => {
    showToast("토스트가 3초간 띄워집니다.");
  };

  return (
    <div>
      <button onClick={handleClick}>Show Toast!</button>
    </div>
  );
}

export default App;

이전에는 컴포넌트 내에 상태를 끌고와서, 조건에 따라 Toast 컴포넌트를 보여주도록 하였습니다. Context API를 이용하면 최상단에 미리 Toast 컴포넌트를 선언해두고, 하위 컴포넌트에서 on/off 를 제어할 수 있습니다. showToast 함수만 실행시켜주면 toast를 띄울 수 있습니다.

즉, 컴포넌트가 조건문에 따라 띄워진다거나, isOpenToast 상태가 어떻게 되는지 알 필요 없이, showToast만 실행해주면 되기 때문에 선언적으로 Toast를 제어한다고 볼 수 있습니다.

결론

명령적으로 Toast 상태를 정의하고, 조건문에 따라 Toast를 띄워주는 형태에서 벗어나 선언적으로 Toast를 띄울 수 있도록 설계했습니다. 실제로 저희 [꼭꼭] 프론트엔드 팀은 이 방식을 도입하였고, 컴포넌트 단에서 선언적으로 Toast를 띄워주고 있습니다. 선언형 프로그래밍이 어떻게 개발 생산성을 높이는 지 깨달았습니다.

추가로 Modal, Loading과 같이 전역적으로 사용하는 UI에 적용할 수 있습니다. 이것간의 쌓임 맥락을 명시적으로 조절하기 위해서 Portal 을 활용할 수도 있을 것입니다.


꼭꼭 팀 코드 보러 가기

작성자 : https://github.com/euijinkk