개발자
류준열

훅, 컴포넌트 밖에서 사용가능한 toast 유틸 만들기

React에서 useToast()를 커스텀 훅으로 만들어쓰고 있었다.

const toast = useToast();

toast({
  type: "success",
  content: "저장되었습니다!",
});

useToast 훅의 문제점

혹은 컴포넌트 내부에서만 호출 할 수 있다는 제약때문에 React 외부에서는 토스트를 띄울 수 없다.

axios intercepter에서 토큰만료 안내를 할 때는 브라우저 alert를 사용해야했다.

axios.interceptors.response.use(
  (res) => res,
  async (error: AuthAxiosError) => {
    ...
    const isTokenExpired =
      status === 401 
			
    if (isTokenExpired && error.config.url === REFRESH_API_URL) {
      ...
      alert("세션이 만료되어 로그인 페이지로 이동합니다.");
      logout();
      ...
    }
  },
);

우리팀의 제품은 일관된 UI를 지향하는데, React의 제약에 체념한 상태였다.

하지만 'this는 메소드가 자신을 호출한 객체를 가리킨다' 는 성질을 이용하여 React 외부에서도 토스트를 띄울 수 있다. (+ 클로저)

전역 toast 유틸 객체 만들기

utils폴더안에 toast.ts를 다음과 같이 만들자.


// toast.ts

const toast = {
  showToast: (newToastOption: ToastOptionType) => {
    console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
  },

  _setFunctions(add: (newToastOption: ToastOptionType) => void) {
    this.showToast = add;
  },
};

export default toast;

이 toast 객체는 React 바깥을 포함한 모든 전역에서 접근 가능하다. 초기에는 아무 기능도 없지만, 아래와 같이 Provider가 마운트되면서 내부 함수를 주입한다.

Provider안에서 React 내부 setter 주입


// toast.ts

const toast = {
  showToast: (_newToastOption: ToastOptionType) => {
    console.warn("ToastProvider가 아직 초기화되지 않았습니다.");
  },

  _setFunctions(add: (newToastOption: ToastOptionType) => void) {
    this.showToast = add;
  },
};


// Provider.tsx

export const Provider= ({...}:ToastProps) => {
  ...
	const showToast = useToast();
	
  useEffect(() => {
    toast._setFunctions(showToast);
  }, []);
	
	...
	
	return <>{children}</>
}

useToast 는 함수를 반환하는데 이 함수는 생성될 때 주변 환경을 기억한다. (주변환경 = toast, setToast)

toast._setFunction는 this가 해당 객체(toast)를 가리킨다.

덕분에 toast.showToast에 React setter를 주입 할 수 있다.

사용방식

// utils/handleError.ts

import toast from "@/utils/toast";

export function handleError(message: string) {
  toast.showToast({
    type: "error",
    content: `에러 발생: ${message}`,
  });
}

정리

React 커스텀 훅은 범위에 제한이 있다. 그 한계를 벗아나기 위해서는 this 바인딩을 활용하는 것이 좋은 해결책이 될 수 있다.

이 패턴을 구현하면서 클로저를 다시 한번 느꼈다. useToast가 선언될때의 setState는 렉시컬환경에 기록되어 있기 때문에 해당 함수가 react 외부의 toast 객체로 넘겨져도 setState를 실행할 수 있는 것이다.

이런 성질을 이용해서 toast뿐 아니라, 전역 알림, 로딩 처리등 모든 컴포넌트에 확장할 수 있다.

zustand getStore 쓰지 않은 이유

당시에는 근본적인 해결책은 react외부에서 setState를 일으켜야 하는 것이고 그러다보니까 zustand는 오히려 react에 해당하는 내 방향성과 대척점에 있는 영역이라고 생각했다.

나중에 zustand getState로 쉽게 해결할수있다는걸 알게되었다.

한마디로 '몰랐음'

의의를 생각해보자면 context, zustand 등 라이브러리와 무관하게 react 외부에서 일으킬 수 있는 방식을 썡으로 구현하면서 react가 프레임워크가 라이브러리라는 것을 몸소 느낄 수 있는 경험이었다.

cleanup

cleanup을 하지 않으면, Provider가 언마운트 - 마운트 되는 사이에 옛 toast 함수가 작동하지만 렌더링할 Provider는 언마운트되어 있는 상황이 있을 수 있다.

ToastProvider를 Route 외부에서 렌더링시키고 있었기 때문에 SPA특성상 ToastProvider는 언마운트되는 상황이 없을것이라 생각했다.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToastProvider } from "@/components/ToastProvider";
import App from "./App.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ToastProvider>
      <App />
    </ToastProvider>
  </StrictMode>
);

새로고침이나 페이지 이동등 어플리케이션 자체가 언마운트 될때만 ToastProvider가 언마운트 되는데 이때는 toast고 뭐고 다 내려가는 상황이니까 신경 안썼는데, Strict 모드에서 마운트 -> 언마운트 -> 마운트 과정을 거치기 때문에 clean up이 필요하다는걸 알게 되었다.

또한 의미상 React 외부의 객체에 React 내부에서 가져온 setter를 주입하는 것이기 때문에, Provider가 언마운트 되면 연결된 toast 객체까지 cleanup 하기로 추가 하였다.

const ToastInjector = () => {
  const showToast = useToast();

  useEffect(() => {
    toast._setFunctions(showToast);

    // cleanup 함수 추가
    return () => {
      toast._setFunctions(() => {
        throw new Error("Toast functions are not available");
      });
    };
  }, [showToast]);

  return null;
};