IT/React

[Zustand] Toast 알림을 전역에서 관리

솔B 2024. 5. 11. 01:32

Toast 알림을 구현하면서 어디서든 간편하게 사용할 수 있도록 하기 위해 전역 상태관리가 필요했다.

요즘 Zustand가 핫한 거 같아서 사용해 봤다! (+ 곰이 귀엽기도 하고 ㅎㅎ)
추가로 나는 Next.js 14 App router를 사용 중이다.

 

Zustand

다른 상태관리 툴과 비교했을 때 장점은

  • Context API와 달리 상태 변경 시 불필요한 리렌더링이 일어나지 않는다.
  • 동작을 이해하기 위해 알아야 하는 코드 양이 적다. 
  • 한 개의 중앙에 집중된 형식의 스토어 구조를 활용하면서 상태를 정의하고 사용하는 방법이 단순하다.

설치

# npm
npm install zustand

# yarn
yarn add zustand

 

전역상태관리를 통해 Toast 알림 만들기

store 생성

toast 알림은 여러 개가 나타날 수 있기 때문에 배열(toastList)로 만들었다.

 

store/toast.ts

import { ToastItemType } from '@/types'
import { create } from 'zustand'

interface ToastState {
  toastList: ToastItemType[] // Toast 알림 List
  addToastList: (elem: ToastItemType) => void // Toast 알림을 추가하는 함수
  subtractToastList: (id: string) => void // Toast 알림을 제거하는 함수
}

export const useToastStore = create<ToastState>((set) => ({
  toastList: [],
  addToastList: (toast: ToastItemType) =>
    set((state) => ({ toastList: [...state.toastList, toast] })),
  subtractToastList: (id: string) =>
    set((state) => ({
      toastList: state.toastList.filter((toast) => toast.id !== id),
    })),
}))

관련 Type은 아래와 같다.

성공, 경고, 에러의 경우를 나눠 ToastType을 설정하고

ToastItemType에는 고유한 id, ToastType(성공, 경고, 에러), 전달할 message를 담았다.

export type ToastType = 'success' | 'warning' | 'error'
export type ToastItemType = {
  id: string
  type: ToastType
  message: string
}

ToastList component

전역 상태에서 toastList를 가져와 각각의 요소를 반복을 돌렸다.

클라이언트에서만 실행되도록 하고 React portal을 통해서 상위 요소인 #toast-root에서 나타나도록 했다.

'use client'

import { useToastStore } from '@/store/toast'
import React from 'react'
import ReactDOM from 'react-dom'
import styled from 'styled-components'
import ToastItem from './ToastItem'

function ToastList() {
  const { toastList } = useToastStore() // 전역 상태에서 toastList를 가져옴

  if (typeof document === 'undefined' || toastList.length === 0) return null // 클라이언트에서만 실행되도록
  const element = document.getElementById('toast-root')
  if (!element) return null

  return ReactDOM.createPortal(
    <ToastListWrapper>
      {toastList.map((toast) => (
        <ToastItem key={toast.id} toast={toast} />
      ))}
    </ToastListWrapper>,
    element,
  )
}

const ToastListWrapper = styled.div`
  position: fixed;
  top: 20px;
  right: 20px;
  z-index: 99;
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-width: 320px;
  overflow: hidden;
`

export default ToastList

 

#toast-root div를 추가해 해당 요소 아래에 Toast 알림이 생성되도록 했다.

app/layout.tsx

import CoreProvider from '@/providers/CoreProvider'

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="ko">
      <head />
      <body>
        <CoreProvider>{children}</CoreProvider>
        <div id="modal-root" />
        <div id="toast-root" /> // 여기 추가!
      </body>
    </html>
  )
}

CoreProvider 안에서 ToastList component를 불러올 수 있도록 했다.

import ToastList from '@/components/ToastList'
import ReactQueryProvider from './ReactQueryProvider'
import StyledComponentProvider from './StyledComponentProvider'

function CoreProvider({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <ReactQueryProvider>
      <StyledComponentProvider>
        <ToastList /> // ToastList component 추가
        {children}
      </StyledComponentProvider>
    </ReactQueryProvider>
  )
}

export default CoreProvider

ToastItem component

4000ms 후 visiblefalse로 설정해 Toast 알림이 점점 사라지는 효과를 줬다.

애니메이션 효과를 위해 바로 제거하지 않고 타이머를 설정해 500ms의 간격을 두고 전역상태에서 제거했다.

사라질 때는 clearTimeout으로 설정된 타이머를 제거해 불필요한 코드 실행을 방지했다.

 

각각의 타입에 따라 다른 색으로 강조를 해줬다.

'use client'

import { useToastStore } from '@/store/toast'
import { ToastItemType, ToastType } from '@/types'
import React, { useEffect, useState } from 'react'
import styled, { keyframes } from 'styled-components'

const DURATION = 4000
const ANIMATION = 500

interface ToastItemProps {
  toast: ToastItemType
}

function ToastItem({ toast }: ToastItemProps) {
  const [visible, setVisible] = useState(true)
  const { subtractToastList } = useToastStore()
  const { id, type, message } = toast

  useEffect(() => {
    const timer = setTimeout(() => {
      setVisible(false)
    }, DURATION)

    const removeTimer = setTimeout(() => {
      subtractToastList(id) // toastList에서 제거
    }, DURATION + ANIMATION)

    return () => {
      clearTimeout(timer)
      clearTimeout(removeTimer)
    }
  }, [id, subtractToastList])

  return (
    <>
      <ToastBox id={id} $type={type} $visible={visible}>
        {message}
      </ToastBox>
    </>
  )
}

const getToastColors = (type: ToastType) => {
  switch (type) {
    case 'success':
      return { backgroundColor: '#E1F4EB', borderColor: '#64B78E' }
    case 'warning':
      return { backgroundColor: '#FCF4DE', borderColor: '#E7B416' }
    case 'error':
      return { backgroundColor: '#FCDDDD', borderColor: '#D9534F' }
    default:
      return { backgroundColor: '#ffffff', borderColor: '#000000' }
  }
}

const fadeIn = keyframes`
  from { opacity: 0; }
  to { opacity: 1; }
`
const fadeOut = keyframes`
  from { opacity: 1; }
  to { opacity: 0; }
`
const ToastBox = styled.div<{ $type: ToastType; $visible: boolean }>`
  background-color: ${({ $type }) => getToastColors($type).backgroundColor};
  border: 1px solid ${({ $type }) => getToastColors($type).borderColor};
  color: #000000;
  padding: 16px 24px;
  border-radius: 16px;
  font-size: 14px;
  animation: ${({ $visible }) => ($visible ? fadeIn : fadeOut)} 0.5s ease;
  transition:
    opacity 0.5s ease,
    transform 0.5s ease;
`

export default ToastItem

 

일단 이렇게 하면 준비는 완료다!

 

Custom hooks

이제 필요한 건 실행인데 실행도 간편하게 구현해 보자. (react-toastify처럼)

어디서든 type과 message를 받아 Toast 알림을 띄우고 싶었다. 

import { useToastStore } from '@/store/toast'
import { ToastType } from '@/types'
import { v4 as uuidv4 } from 'uuid'

export default function useToast() {
  const { addToastList } = useToastStore()

  const toast = (type: ToastType, message: string) => {
    const id = uuidv4() // 각각의 고유한 id를 사용
    addToastList({ id, type, message }) // toastList에 추가
  }

  return { toast }
}

 

이제 사용 시 hooks를 가져와 type과 message만 전달해 주면 사용할 수 있다. 🎉

'use client'

import useToast from '@/hooks/useToast'
import styled from 'styled-components'

export default function ToastTest() {
  const { toast } = useToast()

  return (
    <main>
      <StyledButton
        type="button"
        onClick={() => {
          toast('success', '성공적이에요.')
        }}
      >
        success
      </StyledButton>
      <StyledButton
        type="button"
        onClick={() => {
          toast('warning', '경고에요.')
        }}
      >
        warning
      </StyledButton>
      <StyledButton
        type="button"
        onClick={() => {
          toast('error', '오류에요.')
        }}
      >
        error
      </StyledButton>
    </main>
  )
}

const StyledButton = styled.button`
  background-color: #000000;
  color: #fff;
  border-radius: 16px;
  padding: 4px 8px;
`

 

완성된 Toast 알림은 아래와 같다.