프로젝트 제작 중 다른 팀원 파트에서 toast
메시지 시안이 존재했다. 현재까지 나온 디자인 시안 중 toast 메시지를 사용하는 곳은 저기밖에 없었지만 논의 결과 임시용이라 디자인 변경 여지도 있고 다른 파트에서도 toast를 사용할 여지가 있었고 이미 react-toastify
라는 좋은 라이브러리가 있지만 자유로운 커스터마이징과 최소한의 코드만을 사용하기 위해 컴포넌트로 제작에 들어가기로 했다.
toast
메시지는 사용자에게 간단한 정보를 잠깐 보여주는 UI 요소로 작은 팝업창이라 생각하면 됩니다.
여기 velog에서도 글을 작성하다 보면 우측 상단에 아래 사진과 같은 toast 메시지가 뜨는걸 확인할 수 있습니다.
다들 많이 사용하시는 toast 라이브러리로 react-toastify가 있는데 라이브러리 특성상 프로젝트에 불필요한 기능들이 들어가 있고 무엇보다 해당 프로젝트에서만 사용되는 커스텀 UI로 사용하기 위해 컴포넌트 제작을 진행했습니다.
❗️ 해당 코드는 React, TypeScript, Emotion, Redux Tool kit을 사용해서 작성했습니다
우선 createPortal
를 사용했습니다. createPortal은 DOM 상에서 부모 트리 밖에 요소를 그리기 때문에 위치에 관한 스타일링을 덜 복잡하게 처리할 수 있습니다.
해당 프로젝트는 아래서 위로 올라오는 애니메이션이 들어가 있는 toast 메시지를 만들어야하는데 Toast 부모 요소들에 위치 속성들이 들어가있으면 스타일링이 조금 복잡해지기 때문에 createPortal을 사용했습니다.
프로젝트마다 상태관리 라이브러리는 상이하겠지만 이 프로젝트는 redux tool kit
로 상태관리를 하고 있기 때문에 RTK로 상태관리 코드를 작성했습니다. 물론 useState
로 내부에서 관리하거나 useContext
로도 전역적으로 관리할 수 있지만 이미 전역 state 관리를 위한 RTK
의 코드들이 프로젝트에 작성되어 있기 때문에 RTK로 제작했습니다.
toast 메시지 로직에 사용되는 reducer는 add
, delete
2개입니다. addToast는 전역 state인 toastList에 push해주는 로직으로, deleteToast는 payload id 값의 toast를 필터링해서 state에 넣어주는 로직으로 작성했습니다.
const toastSlice = createSlice({
name: "toast",
initialState,
reducers: {
addToast: (state, action) => {
state.toastList.push(action.payload);
},
deleteToast: (state, action) => {
state.toastList = state.toastList.filter((toast) => toast.id !== action.payload);
},
},
});
toast 컴포넌트들이 렌더링 될 base 컴포넌트로 id 값인 toast-container를 찾아서 이후에 작성할 createToast로 하나씩 toast를 추가해준다고 생각하시면 됩니다.
const ToastBase = () => <div css={layoutStyle} id="toast-container" />;
이후 이 toast base는 main.jsx(index.jsx)
에 provider
안에 작성해줍니다.
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<ThemeProvider theme={theme}>
<Global styles={GlobalStyle} />
<App />
<ToastBase />
</ThemeProvider>
</Provider>
</React.StrictMode>,
);
이렇게 작성하고 개발자 모드로 코드를 확인해보면 toast-container가 따로 추가된 걸 확인할 수 있습니다.
toast list를 렌더링 시켜줄 컴포넌트입니다. 전역 state인 toastList
를 순회하면서 toast 메시지들을 하나씩 출력합니다.
const ToastContainer = () => {
const dispatch = useAppDispatch();
const { toastList } = useAppSelector((state) => state.toast);
return (
toastList.length > 0 && (
<>
{toastList.map(({ id, message, ...attributes }) => (
<Toast key={id} onClose={() => dispatch(deleteToast(id))} {...attributes}>
{message}
</Toast>
))}
</>
)
);
};
export default ToastContainer;
이제 위 사진에서 본 Toast 메시지 컴포넌트를 작성해줍니다.
const Toast = ({
variant = "default",
showDuration = 3000,
onClose,
children,
...attributes
}: ToastProps) => {
const [isAdded, setIsAdded] = useState(true);
const [isVisible, setIsVisible] = useState(true);
const showAnimationRef = useRef<number>();
const hideAnimationRef = useRef<number>();
const handleClose = useCallback(() => {
hideAnimationRef.current = setTimeout(() => {
setIsAdded(false);
onClose?.();
clearTimeout(showAnimationRef.current);
}, 600);
}, [onClose]);
useEffect(() => {
showAnimationRef.current = setTimeout(() => {
setIsVisible(false);
handleClose();
}, showDuration);
return () => {
clearTimeout(hideAnimationRef.current);
};
}, [handleClose, showDuration]);
return isAdded
? createPortal(
<div css={[toastStyle(isVisible), toastVariantStyle(variant)]} {...attributes}>
<span>{children}</span>
</div>,
document.getElementById("toast-container") as Element,
)
: createPortal(<div />, document.getElementById("toast-container") as Element);
};
export default Toast;
종류별로 default
, success
, error
를 variant
props로 받아서 상태별로 디자인 구분을 해줍니다.
showDuration
은 Toast 메시지를 보여줄 초를 얘기하며 단위는 ms입니다. 저는 3000인 3초를 default값으로 설정했습니다.
로직은 단순합니다. Toast 메시지가 생성되면 showDuration만큼 보여주고 사라지면서 toastList에서 삭제됩니다. setTimeout을 사용해 보여주고 사라지는 시간을 관리하고 animation을 적용시키기 때문에 메모리 누수 방지를 위한 clearTimeout을 해주고 toast 메시지의 상태를 바꿔줍니다.
다른 컴포넌트에서 toast 메시지를 생성하기 위해 useToast
라는 custom hook을 만들어줬습니다. toast 메시지를 사용할 모든 컴포넌트에서 재사용할 수 있기 위해서, 그리고 사용하는 컴포넌트에서 UI 로직과 비즈니스 로직 분리를 위해 custom hook으로 작성했습니다.
export const useToast = () => {
const dispatch = useAppDispatch();
const createToast = useCallback(
(message: string, variant: ToastType["variant"] = "default") => {
const newToast = { id: Number(new Date()), variant, message };
dispatch(addToast(newToast));
},
[addToast],
);
return { createToast };
};
id 값은 겹치지 않는 unique한 값을 사용하기 위해 현재 시간을 number type으로 바꿔서 지정해주었습니다.
이러면 아래 코드 처럼 다른 컴포넌트에서 createToast
를 통해 toast 메시지 생성이 가능합니다.
const TestPage = () => {
const { createToast } = useToast();
return (
<div>
<button onClick={() => createToast("토스트 테스트")}>toast test</button>
</div>
);
};
이렇게 프로젝트 어디서든 사용 가능한 custom toast를 제작해보았습니다. 제공되는 라이브러리를 사용해도 되지만 직접 만드는데 프로젝트에 필요한 기능들만 제공하고 custom이 쉽다는 장점이 있는 거 같습니다.
재사용성 높은 팀원들이 이해하기 쉬운 코드를 작성하고 팀원들이 사용하기 쉽게 기능을 구현하는건 항상 고민하는 부분이지만 쉽지 않은 거 같습니다.
전체 코드는 깃헙에서 확인 가능합니다.
감사합니다.