(React) 동시에 여러 개 토스트 띄우기

개발차 DevCHA·2023년 5월 18일
1
post-thumbnail

개발을 시작한지 얼마 되지 않았을 때 조건부 렌더링 방식으로 토스트를 구현했던 적이 있다. 당시에 친구가 포스트를 읽고 만약 토스트가 사라지기 전에 또 버튼을 클릭하면 어떻게 되냐고 물었는데, 실행해보니 토스트가 존재하는 동안에는 상태가 변하지 않았다. (showToasttrue인 상태에서 버튼을 다시 클릭해도 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;

동시에 여러개 존재하는 토스트

두 번째 고비는 토스트가 동시에 여러 개 존재할 수 있어야 한다는 거였다. 고민 끝에 토스트를 배열로 관리해야겠다고 생각했다. 원리는 다음과 같다:

  1. 버튼을 클릭하면 토스트가 생성되어 배열에 추가된다.
  2. 일정 시간이 지나면 생성된 토스트가 배열에서 삭제된다.
  3. <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;

0개의 댓글