개발을 시작한지 얼마 되지 않았을 때 조건부 렌더링 방식으로 토스트를 구현했던 적이 있다. 당시에 친구가 포스트를 읽고 만약 토스트가 사라지기 전에 또 버튼을 클릭하면 어떻게 되냐고 물었는데, 실행해보니 토스트가 존재하는 동안에는 상태가 변하지 않았다. (showToast
가 true
인 상태에서 버튼을 다시 클릭해도 false
로 바뀌지 않음) 즉, 토스트가 2초간 존재하도록 로직을 짰다면 2초간은 토스트를 트리거하는 이벤트가 다시 발생해도 토스트의 상태에는 변화가 없다.
아무튼 그때 구현 이후로 토스트는 마스터했지 ^^ 라고 생각하고 살아오다가 최근 과제를 받았는데, 난관에 봉착했다:
.... react-toastify 같은 검증된 라이브러리를 사용할까 하는 유혹에 시달렸지만 이겨내고 직접 구현해보기로 했다.
첫 번째 고비는 토스트가 모든 페이지에서 사용 가능해야 한다는 거였다. 조건부 렌더링 방식을 사용할 때는 토스트 컴포넌트를 토스트를 트리거하는 버튼이 존재하는 컴포넌트에 위치시키면 됐었는데, 이 프로젝트에서는 버튼(북마크) 컴포넌트가 모달에도 존재하고 아이템에도 존재해서 해당 방법을 쓰는 것이 불가능했다.
react-toastify 사용 방법에서 영감을 얻어, <ToastContainer />
라는 컴포넌트를 만들고 최상위 컴포넌트(나의 경우 App)에 배치시켰다.
import Header from './components/layout/Header';
import Footer from './components/layout/Footer';
import { Outlet } from 'react-router-dom';
import ToastContainer from './components/view/ToastContainer';
function App() {
return (
<>
<Header />
<Outlet />
<ToastContainer />
<Footer />
</>
);
}
export default App;
두 번째 고비는 토스트가 동시에 여러 개 존재할 수 있어야 한다는 거였다. 고민 끝에 토스트를 배열로 관리해야겠다고 생각했다. 원리는 다음과 같다:
<ToastContainer />
에서 해당 배열을 렌더링한다.클릭으로 생성된 토스트가 정확히 어떤 요소인지 알아야 배열에서 삭제할 수 있기 때문에, 각각의 토스트는 고유한 id
를 가지며, 북마크 추가와 제거 이벤트를 구별해 각각 다른 UI를 그려내기 위해서 isBookmarked
값도 추가했다.
// toast 배열
[
{id: 1684417573864, isBookmarked: false},
{id: 1684417876989, isBookmarked: true},
{id: 1684123123424, isBookmarked: false}
]
toast 배열이 비어있어서 눈에 보이지 않을 뿐 <ToastContainer />
는 App에 위치하므로 항상 화면에 존재하고 있다. <ToastContainer />
의 CSS 속성을 fixed
로 하고 top
, bottom
, left
, right
를 이용해 적절한 위치에 배치시키면 다른 페이지로 이동하더라도 토스트가 유지된다.
원리에 대한 설명은 끝났으니 리덕스 툴킷을 이용해 본격적으로 구현해보자!
리덕스 툴킷에서는 슬라이스를 만들어서 상태를 관리한다. toastSlice
라는 이름으로 슬라이스를 생성하고 초기값으로 빈 배열을 주었다. 리듀서는 총 2개가 필요하다:
setToast
deleteToast
import { createSlice } from '@reduxjs/toolkit';
export const toastSlice = createSlice({
name: 'toast',
initialState: [],
reducers: {
setToast: (state, action) => [...state, action.payload],
deleteToast: (state, action) => state.filter((state) => state.id !== action.payload)
},
});
export const { setToast, deleteToast } = toastSlice.actions;
export default toastSlice.reducer;
이렇게 생성한 슬라이스는 store에서 export하여 전역에서 사용할 수 있다.
import { configureStore } from '@reduxjs/toolkit';
import toastSlice from './modules/toastSlice';
export default configureStore({
reducer: {
toast: toastSlice,
},
});
그 다음은 북마크 이벤트 발생시 실행될 이벤트 핸들러를 구현해야 한다. 토스트는 화면에 잠시 존재했다가 사라져야 하므로 setTimeout
을 이용했다. 고유한 아이디 생성에는 Date.now()
를 썼다. (같은 아이템을 연달아 클릭할 경우 아이템의 정보는 중복될 수 있기 때문) 이벤트 핸들러에서 토스트 관련 로직만 발췌하면 아래와 같다.
const dispatch = useDispatch();
const handleToast = () => {
const toastId = Date.now();
dispatch(setToast({ id: toastId, isBookmarked }));
setTimeout(() => dispatch(deleteToast(toastId)), 2000);
};
마지막으로 <ToastContainer />
에서 useSelector
를 이용하여 상태를 구독한다. 이렇게 하면 dispatch
로 인해 toast 배열이 변경될 경우 즉각 화면에 반영될 것이다.
import Toast from '../ui/Toast';
function ToastContainer() {
const toasts = useSelector((state) => state.toast);
return (
<section className='fixed bottom-7 right-7'>
{toasts.map((toast) => (
<Toast key={toast.id} isBookmarked={toast.isBookmarked} />
))}
</section>
);
}
export default ToastContainer;