들어가며
리액트에서 토스트를 선언적으로 보여주는 코드를 만들어보자. 그리고 이 코드를 조금씩 발전시켜 추상화까지 해본다.
1. 토스트 관리하기
아래와 같은 방식으로 토스트를 선언적으로 띄우고 싶다.
function Child(){
const { showToast } = useToast();
const handleButtonClick = () => showToast({
variant:'success',
message:'안녕하세요'
})
return <button onClick={handleButtonClick}>클릭</button>
}
먼저, 토스트의 인터페이스를 작성해보자.
type ToastVariant = "success" | "error";
type ToastType = {
variant: ToastVariant;
message: string;
};
토스트를 최상위에서 상태로 관리하기 위해, 토스트에 대한 상태를 Prvider컴포넌트에서 만들어 Context API로 뿌려줄 것이다.
export default function ToastProvider() {
const [toast, setToast] = useState<ToastType | null>(null);
const showToast = useCallback((toast: ToastType) => {
setToast(toast);
}, []);
const hideToast = useCallback((id: number) => {
setToast(null);
}, []);
return (
<ToastContext.Provider value={{ showToast, hideToast }}>
<S.ToastContainer>
<Toast {...toast} />
</S.ToastContainer>
{children}
</ToastContext.Provider>
);
}
showToast는 인자로 전달받은 ToastType 즉 variant와 message를 toast 상태로 설정한다. 그리고 상태가 변경되면 JSX반환부(return문)이 다시 실행되어 Toast컴포넌트에 toast상태를 그대로 전달해줄 것이다. 그리고 Toast 컴포넌트는 props로 전달받은 variant와 message를 바탕으로 사용자에게 UI를 보여주면 된다.
여기서 ToastProvider는 물론 App.tsx같은 최상위 레벨에서 컴포넌트들을 감싸면 된다.
function App() {
return (
<ToastProvider>
<Child />
</ToastProvider>
);
}
export default App;
2. 만약 여러 개의 토스트를 관리하고 싶다면?
현재 토스트 상태를 toast 하나로 관리하고 있다. 이를 배열로 관리해서 여러 개의 토스트를 한 번에 띄어보자.
export type ToastVariant = "success" | "error";
type ToastType = {
id: number;
variant: ToastVariant;
message: string;
};
// ..
export default function ToastProvider() {
const [toasts, setToasts] = useState<ToastType[]>([]);
const idRef = useRef(0);
const showToast = useCallback((toast: Omit<ToastType, "id">) => {
setToasts((prev) => [...prev, { ...toast, id: idRef.current++ }]);
}, []);
const hideToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
return (
<ToastContext.Provider value={{ toasts, showToast, hideToast }}>
<S.ToastContainer>
{toasts.map(({ id, ...toastProps }) => (
<Toast key={id} id={id} {...toastProps} />
))}
</S.ToastContainer>
{children}
</ToastContext.Provider>
);
}
토스트 상태에 id를 추가해서 어떤 토스트인지 식별한다. 그리고 map 배열 메서드를 활용해서 각 토스트를 렌더링한다.
3. 토스트 컴포넌트
토스트를 관리할 수 있게 되었으니 이제 토스트 컴포넌트를 작성해보자.
xport default function Toast({ id, variant, message, duration = 3000 }) {
const { hideToast } = useToast();
useEffect(() => {
const timer = setTimeout(() => hideToast(id), duration);
return () => clearTimeout(timer);
}, [duration, hideToast, id]);
return (
<S.Toast variant={variant}>
<S.ToastIcon>
<CheckIcon />
</S.ToastIcon>
<S.ToastMessage>{message}</S.ToastMessage>
<S.ToastClose onClick={() => hideToast(id)}>×</S.ToastClose>
</S.Toast>
);
}
useEffect를 통해 컴포넌트가 마운트되면 setTimeout을 실행하고, 이 setTimeout의 시간(duration)이 지나면 hideToast를 실행한다. 그럼 hideToast는 위 ToastProvider에서 작성한 것처럼 toasts배열에서 id에 해당하는 토스트를 제거할 것이다.
4.1. 토스트 애니메이션
현재 방식에서는 토스트에 애니메이션을 적용할 수 없다. 왜냐하면 toasts 배열에서 토스트가 제거되는 순간 컴포넌트가 바로 언마운트되어, 사라지는 애니메이션이 실행할 시간이 없기 때문이다. 애니메이션은 컴포넌트가 마운트된 상태에서만 동작할 수 있는데, 기존 방식에서는 토스트가 사라질 때 곧바로 DOM에서 제거되어 애니메이션이 보이지 않는다.
이 문제를 해결하기 위해 isVisible 상태를 별도로 두면 된다.
export default function Toast() {
const [isVisible, setIsVisible] = useState(false);
// ..
}
토스트의 isVisible에 따른 스타일링은 다음과 같다. 또한 transition을 통해 opacity 속성에 애니메이션을 주었다.
export const Toast = styled.div`
opacity: ${({ isVisible }) => (isVisible ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
// ..
`;
isVisible은 초기 상태가 false이다. 즉, 토스트의 opacity는 0이다. 그리고 컴포넌트가 마운트 되고 useEffect를 실행할 때 isVisible을 true로 바꿀 것이다. 즉, opacity를 0에서 1로 바꾸며 애니메이션을 넣을 것이다.
export default function Toast() {
// ..
useEffect(() => {
setIsVisible(true)
// setTimeout(() => setIsVisible(true), 0);
}, []);
}
이제, 토스트가 사라질 때 즉 duration만큼 시간이 지났을 때 애니메이션을 넣어보자.
export default function Toast() {
// ..
useEffect(() => {
if (!isVisible) return;
setTimeout(() => setIsVisible(false), duration);
}, [duration, isVisible]);
}
여기서 isVisible만 false로 만들면 ToastProvider에서 관리하는 toasts배열에는 토스트가 남아있을 것이다. 그래서 이는 애니메이션이 끝나면 해당 토스트를 제거해주도록 하자.
export default function Toast() {
// ..
const handleAnimationEnd = () => hideToast(id)}
return (
<S.Toast variant={variant} isVisible={isVisible} onTransitionEnd={handleAnimationEnd}>
// ..
)
}
- 결과
4.2. 토스트 애니메이션 개선
애니메이션을 많이 다뤄보지 않아 위와 같이 직접 isVisible상태를 따로 관리하고, 이 상태를 활용해서 애니메이션을 보여줘야하는 줄로만 알았다. 그런데 발표 스터디에서 한 크루덕분에 css animation을 활용하면 간단하게 해결할 수 있다는 것을 알게되었다. 이번엔 이 방식으로 애니메이션 코드를 깔끔하게 개선해보자.
export const Toast = styled.div<{ variant: ToastVariant; duration: number }>`
// ..
@keyframes active {
0% {
transform: translateX(-100%);
opacity: 0;
}
10% {
transform: translateX(0%);
opacity: 1;
}
90% {
transform: translateX(0%);
opacity: 1;
}
100% {
transform: translateX(-100%);
opacity: 0;
}
}
`
active라는 키 프레임을 만든다. 처음에는 opacity가 0이고 X값이 -100%지점에서 시작하고, 10%지점부터 90%지점까지는 opacity가 1이며 X값이 0%지점에 머무른다. 그리고 100%지점에 opacity를 0으로 만들며 X를 -100%지점으로 다시 이동시킨다. 즉, 왼쪽에서 정지점으로 나타났다가 다시 왼쪽으로 사라지는 애니메이션이다.
이를 css animation을 활용해 구현할 수 있다.
export const Toast = styled.div<{ variant: ToastVariant; duration: number }>`
// ..
animation: active ${({ duration }) => duration}ms ease forwards;
`
- 결과
맺으며
이렇게 showToast 한 줄로 토스트를 띄우는 목표를 달성했다. 돌아보면 isVisible 상태로 삽질했던 4.1이 이 글의 하이라이트다. CSS animation을 알았더라면 안 했을 고생이지만, 덕분에 언마운트와 애니메이션의 타이밍 문제를 제대로 이해하게 됐다.
react-toastify같은 토스트 라이브러리를 사용하며 마냥 ‘우와..’라는 생각이 들고는 했었는데 나도 이런 라이브러리를 만들 수 있겠다는 자신감이 생겼다. 나중에 여유가 된다면 모든 컴포넌트는 불가능하겠지만 토스트 라이브러리만큼은 계속해서 디벨롭해보는 것도 재밌겠다는 생각이 들었다.