토스트란 특정 이벤트에 반응하여 간단한 피드백을 짧은 텍스트로 일정 시간동안 제공하는 UI이다.
아래와 같이 특정 이벤트가 발생하는 상황을 상태로 관리하여 토스트를 띄울 수 있지만,
const [isError, setIsError] = useState(false);
...
{ isError && <Toast/> }
화면 이동 중에도 토스트가 떠 있어야 하고,
토스트가 떠 있는 시간 이후에는 언마운트해야 하지만 토스트가 다시 떠야 할 경우 마운트시켜줘야 하는 과정이 번거롭기 때문에
전역 변수로 토스트를 관리하고 토스트가 포함된 공간도 라우터보다 상단에 두어야 한다.
이 글에서는 위의 고민 내용을 바탕으로 Recoil
과 styled component
를 사용하여 토스트 컨테이너 컴포넌트를 구현하고 토스트를 사용하는 훅을 제작하는 로직을 다루었다.
https://github.com/Neogasogaeseo/Naega-Web/pull/78
@stores/toast
import { atom } from 'recoil';
export interface Toast {
id?: string;
content: string;
duration?: number;
bottom?: number;
}
export const toastState = atom<Toast[]>({
key: 'toastState',
default: [],
});
화면에 표시될 토스트들을 모두 보관하는 state를 만들어 두었다.
각각의 역할은 다음과 같다.
@hooks/useToast
import { Toast, toastState } from '@stores/toast';
import { getRandomID } from '@utils/etc';
import { useRecoilState } from 'recoil';
export function useToast() {
const [toasts, setToasts] = useRecoilState(toastState);
const removeToast = (toastID: Toast['id']) =>
setToasts((prev) => prev.filter((toast) => toast.id !== toastID));
const fireToast = (toast: Toast) => {
setToasts((prev) => [...prev, { ...toast, id: getRandomID() }]);
setTimeout(() => removeToast(toast.id), 600 + (toast.duration ?? 1000));
};
return { toasts, fireToast };
}
이 때, id를 설정해주는 함수는 아래와 같다.
@utils/etc
export const getRandomID = () => String(new Date().getTime());
위에서 만들어 둔 state를 조작하는 hook을 만든다.
toasts는 레코일에서 받아온 그대로 리턴하고, toast를 추가하고 duration 이후에 toast를 삭제하는 fireToast
라는 함수를 리턴해준다.
duration에 600을 더해준 이유는 토스트가 사라지는 애니메이션이 표시될 시간을 확보하기 위함이다.
@common/Toast/Item
import { Toast } from '@stores/toast';
import { useEffect, useState } from 'react';
import { StToastItem } from './style';
function ToastItem(props: Toast) {
const { content, bottom, duration } = props;
const [isClosing, setIsClosing] = useState(false);
useEffect(() => {
const setExistTimeout = setTimeout(() => {
setIsClosing(true);
clearTimeout(setExistTimeout);
}, duration ?? 1000);
});
return (
<StToastItem bottom={bottom} isClosing={isClosing}>
{content}
</StToastItem>
);
}
export default ToastItem;
toast가 언마운트될 때 애니메이션을 주기 위하여 isClosing
이라는 상태를 관리하고 duration 이후에 true로 바꾸어준다.
styled component에서 isClosing값을 받아와 다른 애니메이션을 먹여준다.
@common/Toast/Item/style
import styled from 'styled-components';
import { ANIMATION } from '@styles/common/animation';
export const StToastItem = styled.div<{ bottom?: number; isClosing: boolean }>`
position: absolute;
...
bottom: ${({ bottom }) => bottom ?? 26}px;
animation: 0.3s forwards
${({ isClosing }) => (isClosing ? ANIMATION.FADE_OUT : ANIMATION.FADE_IN)};
`;
@common/Toast/List
import { toastState } from '@stores/toast';
import { useRecoilValue } from 'recoil';
import ToastItem from '../Item';
import { StToastList } from './style';
function ToastList() {
const toasts = useRecoilValue(toastState);
return (
<StToastList>
{toasts.map((toast) => (
<ToastItem key={toast.id} {...toast} />
))}
</StToastList>
);
}
export default ToastList;
토스트 리스트를 넣어준다.
이 때, 어느 페이지에서도 fix된 형태로 보여야 하므로 position을 fixed로 준다.
@common/Toast/List/style
import styled from 'styled-components';
export const StToastList = styled.div`
bottom: 0;
left: 0;
position: fixed;
z-index: 1000;
`;
App.tsx
import GlobalStyle from '@styles/global';
import Router from '@routes/Router';
import ToastList from '@components/common/Toast/List';
function App() {
return (
<>
<GlobalStyle />
<ToastList />
<Router />
</>
);
}
export default App;
위에서 구현한 ToastList를 최상단에 넣어준다.
const { fireToast } = useToast();
fireToast({ content:"안녕" });
토스트가 생기고 없어지고 애니메이션이 입혀지는 등등의 과정은 컴포넌트 안에 추상화되어 있으므로 외부 컴포넌트에서는 fireToast라는 함수만 호출하면 된다.
이렇게 커스텀 훅과 레코일을 사용하여 편하게 토스트를 사용할 수 있다.
진짜 멋있네