React Toast 직접 구현하기

Lemon·2025년 4월 11일
1

React

목록 보기
23/23
post-thumbnail

💡아래 링크를 통해 구현된 코드, 데모, 문서를 확인할 수 있습니다!
Github : React-toast-notification 코드 보러가기
Demo : Toast Demo 미리보기
Storybook : Toast Storybook 문서보기

회사 프로젝트 모노레포 통합 과정에서 CRA에서 Vite로 바꾸면서 알림창 라이브러리로 사용하던 react-toast-notifications에서 Warning오류가 발생했어요.

Warning: DefaultToast2: Suppor for defaultProps will be removed from components in a future major release. Use JavaScript default parameters instead. Error Component Stack

React 17 이상부터는 함수형 컴포넌트에서 defaultProps 대신 ES6 기본 매개변수 사용을 권장하고 있는데, 라이브러리 내부에서 사용하는 defaultProp를 사용하고 있기 때문에 React가 경고를 띄운 상황이에요.


🔗 https://github.com/jossmac/react-toast-notifications

공식 깃허브에서도 학습용 프로젝트여서 더이상 유지보수하지 않는다고 나와있고, react-hot-toast를 고려해보거나 직접 구현하라고 권장하고있어요. (실제로 마지막 업데이트가 4년 전으로 나옵니다.)

다른 라이브러리를 찾아보자

업데이트가 최근이고, 원하는 기능이 있는 라이브러리로 찾아봤어요.

1. react-toastify

구글 검색 시 가장 많이 나왔던 react-toastify 라이브러리는 최근 업데이트는 되었지만 Unpacked Size가 536KB로 react-toast-notifications 사이즈보다 10배가 넘게 사이즈가 컸어요.

2. react-hot-toast

그래서 공식 깃허브에서 대안으로 추천했던 react-hot-toast도 찾아보니 최근 업데이트지만 Unpacked Size가 180KB로 차이가 많이 났어요.

직접 구현하자

회사에서 사용하는 알림의 형태는 위 4가지 정도로 단순했기때문에 차라리 직접 구현하는게 낫겠다 싶어서 상의 후 직접 구현하기로 결정했습니다.

구현 조건은 아래와 같아요.

  • Redux를 사용해서 구현할 것
  • 속성은 아래를 포함할 것
    • autoDismiss : 자동 닫힘 상태 결정하는 속성
      • Type : Boolean
    • appearance : 스타일은 선택하는 속성
      • Type : 'info' | 'success' | 'warning' | 'error’
    • placement : 알림의 위치를 선택하는 속성
      • Type : 'top-left' | 'top-center'| 'top-right'| 'bottom-left'| 'bottom-center'| 'bottom-right'
    • showClose : Close 버튼의 표시 여부를 선택하는 속성
      • Type : Boolean

Toast 작업 과정

작업에 들어가기 전에 오픈소스를 분석했어요. 깃허브로 들어가서 코드 파일을 다운받고, 해당 코드들을 보면서 작업했어요.

오픈소스 보니 가장 크게 변경해야될 것은 함수형 컴포넌트로 바꾸는 것과 ContextAPI에서 Redux로 변경하는 과정이었어요.

1. 프로젝트 초기세팅

Vite + React + Typescript + Storybook 과 CSS를 위한 emotion/css를 조합해서 개발 환경을 구성했어요.

  1. Vite 기반의 React 프로젝트 생성
yarn create vite ./
yarn

현재 디렉토리에 Vite 기반의 React 프로젝트 생성합니다.
생성 후에 package.json에 명시된 의존성을 설치합니다.

  1. emotion/css 설치
yarn add @emotion/css

CSS-in-JS 방식으로 스타일을 작성하게 해주는 Emotion 의 core 패키지를 설치합니다.

  1. ReduxRedux Toolkit을 설치
yarn add @reduxjs/toolkit react-redux

react-redux는 React에서 Redux를 사용할 수 있게 연결해주는 라이브러리이고, Provider, useSelector, useDispatch등을 제공합니다.

reduxjs/toolkit은 Redux의 공식 툴킷으로 보일러플레이트 코드를 줄이고, createSliceconfigureStore 등의 API를 제공해서 상태 관리를 쉽게 해줍니다.

🗨️이번 작업을 통해 Redux를 처음 다뤄봐서 더 자세한 내용은 아래에 추가로 작성했어요!

  1. Storybook 설치
npx storybook@latest init

Storybook을 초기화하고 현재 프로젝트에 Storybook 설정을 추가합니다.
💡 Storybook은 UI 컴포넌트를 독립적으로 개발하고 테스트할 수 있는 도구에요. 컴포넌트를 시각적으로 확인하고 문서화 할 수 있어요. 완성 후에 어떤 화면으로 나오는지는 아래에 더 자세하게 설명했답니다.

  1. vite-tsconfig-paths 설치
yarn add vite-tsconfig-paths -D

Vite가 tsconfig.json에 정의된 paths 별칭을 인식하도록 도와주는 플러그인입니다.
절대 경로 설정을 편하게 해주고, 가독성 있는 경로 관리를 가능하게 해줍니다.

  1. @types/node 설치
yarn add @types/node -D

TypeScript에서 Node.js 관련한 전역 타입을 사용할 수 있도록 도와주는 타입 정의 패키지에요. Storybook이나 Vite 설정할 때 에러를 방지하게 위해 필요합니다.

절대경로 설정

📜tsconfig.app.jsonbaseUrl, path 추가합니다.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
  }
}

📜vite.config.ts설정
vite-tsconfig-paths 플러그인을 Vite 설정 파일에 추가합니다.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
    plugins: [react(), tsconfigPaths()],
});

스타일 설정 추가 (📁StyleConfig)

스타일에 사용될 객체들을 먼저 만들었어요.

📜Icons.tsx
알림의 svg로 사용할 아이콘들을 모은 파일입니다.

export const ToastAlert = () => (
  <svg
    height={16}
    width={16}
    viewBox="0 0 16 16"
    style={{
      display: "inline-block",
      verticalAlign: "text-top",
      fill: "currentColor",
    }}
  >
    <path
      fillRule="evenodd"
      d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"
    />
  </svg>
);

export const ToastCheck = () => (
  <svg
    height={16}
    width={12}
    viewBox="0 0 12 16"
    style={{
      display: "inline-block",
      verticalAlign: "text-top",
      fill: "currentColor",
    }}
  >
    <path fillRule="evenodd" d="M12 5.5l-8 8-4-4L1.5 8 4 10.5 10.5 4 12 5.5z" />
  </svg>
);

export const ToastFlame = () => (
  <svg
    height={16}
    width={12}
    viewBox="0 0 12 16"
    style={{
      display: "inline-block",
      verticalAlign: "text-top",
      fill: "currentColor",
    }}
  >
    <path
      fillRule="evenodd"
      d="M5.05.01c.81 2.17.41 3.38-.52 4.31C3.55 5.37 1.98 6.15.9 7.68c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.01 8.68 2.15 5.05.02L5.03 0l.02.01z"
    />
  </svg>
);

export const ToastInfo = () => (
  <svg
    height={16}
    width={14}
    viewBox="0 0 14 16"
    style={{
      display: "inline-block",
      verticalAlign: "text-top",
      fill: "currentColor",
    }}
  >
    <path
      fillRule="evenodd"
      d="M6.3 5.71a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 8.01c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V8v.01zM7 2.32C3.86 2.32 1.3 4.86 1.3 8c0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 1c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"
    />
  </svg>
);

export const ToastClose = () => (
  <svg
    height={16}
    width={14}
    viewBox="0 0 14 16"
    style={{
      display: "inline-block",
      verticalAlign: "text-top",
      fill: "currentColor",
    }}
  >
    <path
      fillRule="evenodd"
      d="M7.71 8.23l3.75 3.75-1.48 1.48-3.75-3.75-3.75 3.75L1 11.98l3.75-3.75L1 4.48 2.48 3l3.75 3.75L9.98 3l1.48 1.48-3.75 3.75z"
    />
  </svg>
);

📜Color.ts
사용할 색상들을 모은 객체 파일도 만들었어요.

const Color = {
  Green: "#006644",
  Green80: "#36B37E",
  Green10: "#E3FCEF",
  Red: "#BF2600",
  Red80: "#FF5630",
  Red10: "#FFEBE6",
  Yellow: "#FF8B00",
  Yellow80: "#FFAB00",
  Yellow10: "#FFFAE6",
  Navy20: "#505F79",
  Blue: "#2684FF",
  Blue80: "#2b6cb0",
};

export default Color;

📜Appearances.ts
그리고 위 스타일들을 조합해서 각각의 알람 스타일을 그룹화해서 묶은 객체를 만들어줬어요

import * as Icons from "@/StyleConfig/Icons";
import Color from "@/StyleConfig/Color";

const Appearances = {
  success: {
    icon: Icons.ToastCheck,
    text: Color.Green,
    fg: Color.Green80,
    bg: Color.Green10,
  },
  error: {
    icon: Icons.ToastFlame,
    text: Color.Red,
    fg: Color.Red80,
    bg: Color.Red10,
  },
  warning: {
    icon: Icons.ToastAlert,
    text: Color.Yellow,
    fg: Color.Yellow80,
    bg: Color.Yellow10,
  },
  info: {
    icon: Icons.ToastInfo,
    text: Color.Navy20,
    fg: Color.Blue,
    bg: "white",
  },
};

export default Appearances;

📜Placements.tsx
알림창이 화면의 어디에 나타날지 배치를 묶은 객체 그룹도 만들어줍니다.

import { css } from "@emotion/css";

const Placements = {
  "top-left": css`
    top: 0;
    left: 0;
  `,
  "top-center": css`
    top: 0;
    left: 50%;
    transform: translateX(-50%);
  `,
  "top-right": css`
    top: 0;
    right: 0;
  `,
  "bottom-left": css`
    bottom: 0;
    left: 0;
  `,
  "bottom-center": css`
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
  `,
  "bottom-right": css`
    bottom: 0;
    right: 0;
  `,
};

export default Placements;

Toast 기능을 위한 Redux 상태 관리 로직 구현 (추가 / 삭제 등)

Redux는 상태(State)를 중앙에서 한번에 관리하는 도구입니다.
컴포넌트 간에 상태 공유가 복잡해질 때 중앙에서 관리하면 데이터 흐름 예측이 가능하고 일관성있게 됩니다. 지금처럼 Toast 알림창을 어디서든 띄우고 싶다면 Redux를 쓰면 좋습니다.

Redux의 핵심 흐름을 간단하게 설명하면

사용자 액션
   ↓
dispatch(action)
   ↓
store는 action을 받고 reducer를 실행함
   ↓
reducer는 state를 바꾼다
   ↓
컴포넌트가 useSelector로 구독 중이면 다시 렌더됨
  1. 사용자가 어떤 행동(ex. 버튼 클릭)을 하면
  2. 그에 대한 액션(action)이 발생하고
  3. reducer가 상태를 바꾸고
  4. 그 상태는 중앙 저장소인 store에 저장되고
  5. UI는 바뀐 상태를 구독해서 리렌더링합니다.

로직 구현

📦Redux
 ┣ 📜Hooks.ts
 ┣ 📜Slice.ts
 ┗ 📜Store.ts

📜Interface/index.ts

우선 Slice에서 사용될 Toast의 타입을 먼저 작성합니다.

import Appearances from "@/StyleConfig/Appearances";
import Placements from "@/StyleConfig/Placements";

interface IToastStyleConfig {
  appearance: keyof typeof Appearances;
  placement: keyof typeof Placements;
}

interface IToast extends IToastStyleConfig {
  id: string;
  content: React.ReactNode;
  autoDismiss?: boolean;
  showCloseButton?: boolean;
}

interface IToastState {
  toasts: IToast[];
}

export type { IToast, IToastState };

📜Slice.ts
Slice는 상태(state) + 상태를 바꾸는 방법(reducer)를 정의하는 곳입니다.

import generateUEID from "@/Utils/generateUEID";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { IToast, IToastState } from "@/Interface";

const initialState: IToastState = {
  toasts: [],
};

const Slice = createSlice({
  name: "toasts",
  initialState,
  reducers: {
    addToast: (state, action: PayloadAction<Omit<IToast, "id">>) => {
      const id = generateUEID();
      state.toasts.push({ id, ...action.payload });
    },
    removeToast: (state, action: PayloadAction<string>) => {
      state.toasts = state.toasts.filter(
        (toast) => toast.id !== action.payload
      );
    },
    removeAllToasts: (state) => {
      state.toasts = [];
    },
    updateToast: (
      state,
      action: PayloadAction<{ id: string; changes: Partial<IToast> }>
    ) => {
      state.toasts = state.toasts.map((toast) =>
        toast.id === action.payload.id
          ? { ...toast, ...action.payload.changes }
          : toast
      );
    },
  },
});

export const { addToast, removeToast, removeAllToasts, updateToast } =
  Slice.actions;
export const toastReducer = Slice.reducer;
export default toastReducer;

initialStatetoast라는 배열을 기본값으로 갖는 상태입니다.
reducers 안에 있는 각각의 함수들은 아래와 같은 역할을 합니다.

  • addToast: 새로운 토스트 메시지를 추가
  • removeToast: 특정 ID를 가진 토스트를 제거
  • removeAllToasts: 전부 제거
  • updateToast: 특정 토스트의 내용 일부를 수정

위 처럼 toasts 상태를 어떻게 바꾸는지에 대한 정의입니다.

📜Store.ts
Store는 중앙 저장소로 모든 state가 모이는 곳입니다.

import { configureStore } from "@reduxjs/toolkit";
import toastReducer from "@/Redux/Slice";

export const store = configureStore({
  reducer: {
    toast: toastReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export default store;

모든 상태를 관리하는 중앙 저장소인 store에서 toastReducertoast에 대한 state를 관리합니다.

RootState는 전체 상태의 타입을 자동으로 추론하고, AppDispatchdispatch함수의 타입을 나타냅니다.

📜Hooks.ts
Hooks 파일은 useSelector, useDispatch 의 타입을 안전하게 사용하도록 도와주는 곳입니다.

import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "@/Redux/Store";

export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

useDispatch를 store에서 작성했던 AppDispatch 타입으로 래핑합니다.
useSelector를 store에서 작성했던 RootState로 래핑합니다.
이렇게하면 타입스크립트에서 타입 에러 없이 사용할 수 있습니다.

위 Redux 를 사용해서 Toast를 하나 추가한다고 가정하면 아래처럼 사용할 수 있게됩니다.

const dispatch = useDispatch();

const handleClickButton = () => {
	dispatch(
	  addToast({
	    content,
	    appearance,
	    autoDismiss,
	    placement,
	    showCloseButton,
	  })
);

addToast라는 액션이 dispatch되고, Redux가 내부적으로 Slice.tsaddToast reducer를 찾아서 실행합니다. state.toasts.push(…)로 상태가 바뀌고, 바뀐 새로운 state가 store에 저장됩니다.

useAppSelector((state) => state.toast.toasts)

같은 곳에서 이걸 구독하고 있으면 자동으로 UI가 새로 렌더링됩니다.

요약하면
State 화면에 보여주는 데이터
Action 어떤 행동(ex: "토스트 추가")
Reducer 그 행동에 따라 상태를 어떻게 바꿀지 (상태를 어떻게 바꿀지 정의한 함수)
Store 그 모든 상태(state)들의 본거지 (앱 전체 상태의 저장소)
Dispatch "이 행동 할게!"라고 Redux에 말하는 것 (특정 reducer 함수를 실행하도록 store에 알림)
Selector 상태에서 필요한 데이터를 꺼내오는 도구 (store에 저장된 상태를 읽는 도구)
근데 이렇게 하면 사용할 때마다 행동을 한다고 알리는 useDispatch()를 매번 불러와야합니다.
한번만 불러와서 사용할 수 있도록 커스텀 Hook을 만듭니다.

useToasts 커스텀 훅으로 상태 및 액션 추상화

📜Interface/index.ts

Hook에 사용할 ToastOptions interface 추가합니다.

...
interface IToastOptions extends IToastStyleConfig {
  autoDismiss?: boolean;
  showCloseButton?: boolean;
}
...

export type { IToast, IToastState, IToastOptions };

📜Hooks/useToasts.tsx

import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/Redux/Store";
import {
  addToast,
  removeToast,
  removeAllToasts,
  updateToast,
} from "@/Redux/Slice";
import { IToast, IToastOptions } from "@/Interface";

export const useToasts = () => {
  const dispatch = useDispatch();
  const toastStack = useSelector((state: RootState) => state.toast.toasts);

  return {
    addToast: (content: React.ReactNode, options: IToastOptions) => {
      dispatch(addToast({ content, ...options }));
    },
    removeToast: (id: string) => {
      dispatch(removeToast(id));
    },
    removeAllToasts: () => {
      dispatch(removeAllToasts());
    },
    updateToast: (id: string, changes: Partial<Omit<IToast, "id">>) => {
      dispatch(updateToast({ id, changes }));
    },
    toastStack,
  };
};

위 처럼 커스텀 훅을 만들어두면 이제 사용할 때 매번 dispatch를 불러오지 않고 사용할 수 있습니다.

const { addToast } = useToasts();

onClick={() =>
  addToast(toast.content, {
    appearance: toast.appearance as keyof typeof Appearances,
    autoDismiss: true,
    placement: "top-right",
  })
}

컴포넌트 작업

이제 기본적으로 구현에 필요한 파일들을 작성했으니 화면에 보여지는 컴포넌트 작업을 시작합니다.
기존 오픈소스에는 아주 작은 단위의 컴포넌트 단위를 나눠서 사용했더라고요. 예를 들면 아래와 같이 말이죠.

<Element
		appearance={autoDismiss}
    transitionState={transitionState}
    onMouseEnter={onMouseEnter}
    onMouseLeave={onMouseLeave}
>
    <div className="toastContent">
        <Icon autoDismiss={autoDismiss} isRunning={isRunning} />
        <div className="content">{children}</div>
        {showCloseButton && onDismiss && (
            <Button onClick={onDismiss}>
                <Icons.ToastClose />
            </Button>
        )}
    </div>
</Element>

항상 이게 고민이에요… 보기에는 코드가 깔끔해 보이지만 그만큼 하나의 태그만 들어간 컴포넌트도 쪼개서 파일화 하다보니 import도 늘어나고 props가 너무 깊어져서 중간에 실수해서 꼬이는 상황이 발생하더라고요.

결국 똑같이 작은 단위의 컴포넌트를 나눠서 작업하다가 Props가 깊어져서 풀어서 작업했어요.

<div
    ref={elementRef}
    className={Style({
        transitionState,
        height,
        appearance,
        placement,
    })}
    onMouseEnter={onMouseEnter}
    onMouseLeave={onMouseLeave}
>
    <div className="toastContent">
        <div className="iconContainer">
            <CountdownBar opacity={autoDismiss ? 1 : 0} isRunning={isRunning} />
            <div className="icon">
                <Glyph />
            </div>
        </div>
        <div className="content">{children}</div>
        {showCloseButton && onDismiss && (
            <button className="closeButton" onClick={onDismiss}>
                <Icons.ToastClose />
            </button>
        )}
    </div>
</div>

그래서 저는 너무 작은 단위는 쪼개지 않고 사용하는 방향으로 작업했어요.

1. CountdownBar

CountdownBar

CountdownBar는 알림창의 왼쪽 부분에 아이콘과 함께 나오는 Bar로 남은 시간을 시각적으로 보여주는 애니메이션 바에요! 위에서 아래로 내려오는 모양이고, props로 받아오는 opacity값과 isRunning값으로 CSS를 조절합니다.
autoDismissture일 경우에 CountdownBar 애니메이션이 5초동안 진행되고, 그 후에는 사라지도록 작업해야하고, 마우스를 올렸을 때 Bar의 진행이 멈추고, 마우스가 알림창 밖으로 벗어나면 다시 진행됩니다.

📜Interface/index.ts

interface IToastCountdownBar {
  opacity: number;
  isRunning: boolean;
}

export type {
  IToastCountdownBar,
};

props 받아야될 opacityisRunning 타입을 정의해줍니다.
여기서 opacity는 바의 투명도를 제어하는 값이고, isRunning은 애니메이션을 재생할지 일시정지할지를 제어하는 값입니다.

📜Components/Toast/CountdownBar.tsx

import { css, keyframes } from "@emotion/css";
import { IToastCountdownBar } from "@/Interface";

export default function CountdownBar({
  opacity,
  isRunning,
}: IToastCountdownBar) {
  const shrinkKeyframes = keyframes`
        from { height: 100%; }
        to { height: 0%; }
    `;

  return (
    <div
      className={css`
	      position: absolute;
        left: 0;
        bottom: 0;
        width: 100%;
        height: 100%;
        animation: ${shrinkKeyframes} 5000ms linear;
        animation-play-state: ${isRunning ? "running" : "paused"};
        background-color: rgba(0, 0, 0, 0.1);
        opacity: ${opacity};      `}
    />
  );
}

배경은 반투명 검은 색상이고, opacity의 값은 외부에서 넘겨줍니다. opacity가 1일때만 CountdownBar가 보여집니다.
shrinkKeyframesheight 100%에서 height0%로 줄어들도록 해서 아래에서 위로 줄어드는 애니메이션을 만들어줍니다.
css animation속성에 넣어서 height의 값의 변호를 5000ms(5초)동안 linear속도로 진행되도록합니다.
여기서 animaion-play-stateisRunning 값에 따라서 애니메이션을 일시정지(paused)하거나 재생(running)합니다.

2. Toast

알림 메시지를 화면에 띄우는 역할을 하는 Toast 알림창의 모습을 구현한 컴포넌트입니다.
애니메이션, 아이콘, 자동 닫힘 타이머 바, 닫기 버튼 등의 기능을 포함합니다.

📜Interface/index.ts

interface IToastStyleConfig {
  appearance: keyof typeof Appearances;
  placement: keyof typeof Placements;
}

interface IToastComponent extends IToastStyleConfig {
  children: React.ReactNode;
  autoDismiss: boolean;
  isRunning: boolean;
  showCloseButton: boolean;
  onDismiss?: () => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
}

interface IToastStyle extends IToastStyleConfig {
  transitionState: string;
  height: number | string;
}

Toast 컴포넌트에 사용할 타입들을 interface 파일에 작성합니다.
위에 구현 조건에 맞춰서 필요한 속성들을 적었어요.
각 속성의 역할은 다음과 같습니다.

  • children : 메시지의 내용을 작성합니다.
  • appearance : 알림창의 스타일 종류를 선택(ex. success, error 등)하면 거기에 맞는 스타일이 적용됩니다.
  • placement : 알림창의 위치를 선택합니다. 선택에 따라 화면에서 어디에 나타낼 지 배치를 결정합니다.
  • autoDismiss : 자동 닫힘 여부를 선택합니다.
  • isRunning : CountdownBar의 애니메이션 재생 여부를 결정합니다.
  • showCloseButton : 닫기 버튼 표시 여부를 결정합니다.
  • onDismiss : 닫기 버튼 클릭하면 실행될 함수입니다. Toast의 id를 삭제하는 역할을 합니다.
  • onMouseEnter : 마우스 오버 시 실행 함수입니다. (애니메이션 실행이 멈추도록 합니다.)
  • onMouseLeave : 마우스 아웃 시 실행 함수입니다. (애니메이션 실행이 진행되도록 합니다.)

📜Toast/index.tsx

import React from "react";
import CountdownBar from "@/Components/Toast/CountdownBar";
import * as Icons from "@/StyleConfig/Icons";
import Appearances from "@/StyleConfig/Appearances";
import Style from "@/Components/Toast/Style";
import { IToastComponent } from "@/Interface";

export default function Toast({
  children,
  appearance,
  placement,
  autoDismiss,
  isRunning,
  showCloseButton,
  onDismiss = () => {},
  onMouseEnter,
  onMouseLeave,
}: IToastComponent) {
  const [height, setHeight] = React.useState<string | number>(0);
  const [transitionState, setTransitionState] = React.useState<
    "entering" | "entered" | "exited"
  >("entering");
  const elementRef = React.useRef<HTMLDivElement>(null);
  const Glyph = Appearances[appearance].icon;

  React.useEffect(() => {
    if (transitionState === "entering" && elementRef.current) {
      setHeight(elementRef.current.offsetHeight + 8);
      setTransitionState("entered");
    }
    if (transitionState === "entered") {
      setHeight("auto");
    }
    if (transitionState === "exited") {
      setHeight(0);
    }
  }, [transitionState]);

  return (
    <div
      ref={elementRef}
      className={Style({
        transitionState,
        height,
        appearance,
        placement,
      })}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="toastContent">
        <div className="iconContainer">
          <CountdownBar opacity={autoDismiss ? 1 : 0} isRunning={isRunning} />
          <div className="icon">
            <Glyph />
          </div>
        </div>
        <div className="content">{children}</div>
        {showCloseButton && onDismiss && (
          <button className="closeButton" onClick={onDismiss}>
            <Icons.ToastClose />
          </button>
        )}
      </div>
    </div>
  );
}

UI 구조에 맞게 태그를 구성하고, Props로 애니메이션 상태와 CSS 를 조절합니다.

const Glyph = Appearances[appearance].icon;

Glyphappearance 속성으로 받은 알림창의 스타일 종류의 아이콘을 추출해서 보여줍니다.

React.useEffect(() => {
  if (transitionState === "entering" && elementRef.current) {
    setHeight(elementRef.current.offsetHeight + 8);
    setTransitionState("entered");
  }
  if (transitionState === "entered") {
    setHeight("auto");
  }
  if (transitionState === "exited") {
    setHeight(0);
  }
}, [transitionState]);

transitionState 상태로 높이를 설정합니다.
entering : 처음 렌더 시 offsetHeight + 8 만큼 높이를 설정해서 부드러운 진입 애니메이션을 만듭니다.
entered : 애니메이션이 끝나면 height: auto로 변경해서 내용이 자동으로 조절되도록 합니다.
exited : 퇴장 시 height: 0으로 설정해서 사라지도록 합니다.

📜Toast/Style.tsx

import { css } from "@emotion/css";
import Appearances from "@/StyleConfig/Appearances";
import { IToastStyle } from "@/Interface";

export default function Style({
  transitionState,
  height,
  appearance,
  placement,
}: IToastStyle) {
  const isBottom = placement.includes("bottom");
  const key = `${transitionState}${isBottom ? "Bottom" : "Top"}`;
  const tansitionStyle: Record<string, string> = {
    enteringTop: "translateY(-20px)",
    enteringBottom: "translateY(20px)",
  };

  return css`
    height: ${height};
    transform: ${tansitionStyle[key]};
    transition: height 220ms, transform 220ms;

    .toastContent {
      display: flex;
      margin-bottom: 8px;
      max-width: 100%;
      width: 360px;
      color: ${Appearances[appearance].text};
      background-color: ${Appearances[appearance].bg};
      border-radius: 4px;
      box-shadow: 0 3px 8px rgba(0, 0, 0, 0.175);
    }

    .iconContainer {
      position: relative;
      flex-shrink: 0;
      padding: 8px;
      width: 30px;
      text-align: center;
      color: ${Appearances[appearance].bg};
      background-color: ${Appearances[appearance].fg};
      border-top-left-radius: 4px;
      border-bottom-left-radius: 4px;
      overflow: hidden;
    }

    .icon {
      position: relative;
    }

    .content {
      flex-grow: 1;
      padding: 8px;
      font-size: 14px;
      line-height: 1.4;
    }

    .closeButton {
      flex-shrink: 0;
      padding: 8px 12px;
      opacity: 0.5;
      transition: opacity 150ms;
      cursor: pointer;

      &:hover {
        opacity: 1;
      }
    }
  `;
}

emotion/css로 각 태그에 스타일을 작성합니다. props로 받은 appearancedplacement속성으로 아이콘과 color 등을 결정합니다.

3. Controller

Toast 컴포넌트를 감싸는 Container 컴포넌트로 Toast 컴포넌트의 로직 담당 컴포넌트에요. 자동 사라짐(autoDismiss) 기능과 관련된 타이머를 컨트롤하고, 마우스 호버 시 일시정지, 마우스 떠나면 재시작 등과 같은 행동을 처리해요.

📜Interface/index.ts

interface IToastStyleConfig {
  appearance: keyof typeof Appearances;
  placement: keyof typeof Placements;
}

interface IToastController extends IToastStyleConfig {
	children: React.ReactNode;
  autoDismiss: boolean;
  showCloseButton: boolean;
  onDismiss?: () => void;
}

props로 들어올 속성들의 타입을 정의합니다.

children으로 알림 내용이 들어오고, autoDismiss로 자동 닫힘 설정 여부를 선택하고, showCloseButton으로 close 버튼이 보여질지의 여부를 선택합니다. onDismiss로 close 버튼 클릭 시 실행될 함수를 넘깁니다.

📜Hooks/useTimer.tsx

우선 Timer Custom Hook인 useTimer을 만들어서 Toast의 자동 사라짐을 제어하는 핵심 로직을 작성합니다.

import React from "react";

export const useTimer = (callback: () => void, delay: number) => {
  const timerRef = React.useRef<NodeJS.Timeout | null>(null);
  const startRef = React.useRef<number>(Date.now());
  const remainingRef = React.useRef<number>(delay);

  const start = () => {
    startRef.current = Date.now();
    timerRef.current = setTimeout(callback, remainingRef.current);
  };

  const pause = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      remainingRef.current -= Date.now() - startRef.current;
    }
  };

  const resume = () => {
    start();
  };

  const clear = () => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
  };

  React.useEffect(() => {
    if (delay !== Infinity) {
      start();
    }
    return clear;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [delay]);

  return { pause, resume, clear };
};

일정 시간이 지난 뒤 콜백을 실행하는 타이머를 제어할 수 있게 해주는 기능을 담당합니다.

  • 타이머 시작 (start)
  • 일시정지 (pause)
  • 재개 (resume)
  • 초기화 (clear)

위와 같은 기능이 있습니다.

callback은 일정 시간이 지난 후 실행할 함수고, delay는 실행까지 남은 시간(ms)입니다.

const timerRef = React.useRef<NodeJS.Timeout | null>(null);
const startRef = React.useRef<number>(Date.now());
const remainingRef = React.useRef<number>(delay);

내부 상태들을 관리할 useRef입니다.
timerRef : 현재 설정된 타이머 객체 (setTimeout)
startRef : 타이머가 시작된 시각
remainingRef : 타이머에 남은 시간 (일시정지할 때 갱신됩니다.)

💡 상태관리에 useRef를 사용한 이유값이 변해도 리렌더링을 일으키지 않기때문입니다. 타이머나 시각처럼 단순 숫자 상태는 굳이 리렌더링에 영향을 줄 필요가 없습니다! 때문에 불필요한 리렌더링을 줄이고 퍼포먼스를 최적화하기 좋습니다.

const start = () => {
  startRef.current = Date.now();
  timerRef.current = setTimeout(callback, remainingRef.current);
};

start()는 타이머 시작 함수로 현재 시간을 저장합니다. remainingRef.current만큼 뒤에 callback을 실행합니다.

진행 과정을 설명하면
startRef.current에 현재 시각(ex 1000ms)이 저장됩니다.
callback으로는 알림창이 사라지는 함수(onDismiss())를 넣어줄 예정이고, delay는 5000ms 또는 Infinity가 들어올 예정입니다.
즉, remainingRef.current는 초기에 5000ms이니까 setTimeout(onDismiss(), 5000)이 실행되면서 5초 후에 onDismiss()가 실행됩니다.

const pause = () => {
  if (timerRef.current) {
    clearTimeout(timerRef.current);
    remainingRef.current -= Date.now() - startRef.current;
  }
};

pause()는 타이머를 일시정지시키는 함수입니다. 타이머를 멈추고, 지난 시간을 계산해서 남은 시간만 저장하게 해줍니다. 이후 resume()시 남은 시간만큼 다시 시작합니다.
위 예시에서 이어진다고 생각하고 start() 실행 2초 후에 pause()를 실행했다면 우선 clearTimeout으로 setTimeout을 멈추면서 callback을 취소합니다. 그 후 startRef.current는 1000ms고, 현재 시간이 1000ms의 2초 뒤니까 3000ms입니다. 즉, Date.now()-sartRef.current3000ms - 1000ms 입니다. 이건 2000ms 만큼 시간이 지났다는걸 의미하고,
remainingRef.current의 값에서 남은 시간을 뺀 5000ms-2000ms = 3000ms을 저장합니다.

const resume = () => {
  start();
};

resume()은 타이머를 재개하는 함수입니다. start() 호출해서 다시 타이머를 시작합니다. 남은 시간은 remainingRef에 저장되어있습니다.
이렇게 하면 타이머를 중단하고도 정확히 남은 시간만큼만 재시작할 수 있습니다.

const clear = () => {
  if (timerRef.current) {
    clearTimeout(timerRef.current);
  }
};

clear()는 타이머를 정지시키고 초기화시킵니다.

React.useEffect(() => {
  if (delay !== Infinity) {
    start();
  }
  return clear;
}, [delay]);

useEffect로 초기 실행을 처리합니다. delayInfinity가 아니라면 처음에 한 번 start()를 실행합니다. 컴포넌트가 언마운트되면 타이머를 정리합니다.
이 커스텀 훅은 Controller 컴포넌트에서 자동사라짐, 호버 시 일시정지, 호버 해제 시 재개하기 위해 사용됩니다.

📜Components/Toast/Controller.tsx

import React from "react";
import { useTimer } from "@/Hooks/useTimer";
import Toast from "@/Components/Toast";
import { IToastController } from "@/Interface";

export default function Controller({
  children,
  appearance,
  placement,
  autoDismiss = false,
  showCloseButton,
  onDismiss = () => {},
}: IToastController) {
  const { pause, resume, clear } = useTimer(
    onDismiss,
    autoDismiss ? 5000 : Infinity
  );
  const [isRunning, setIsRunning] = React.useState<boolean>(autoDismiss);

  const onMouseEnter = () => {
    setIsRunning(false);
    pause();
  };

  const onMouseLeave = () => {
    setIsRunning(true);
    resume();
  };

  const handleMouseEnter = autoDismiss ? onMouseEnter : () => {};
  const handleMouseLeave = autoDismiss ? onMouseLeave : () => {};

  React.useEffect(() => {
    if (autoDismiss) {
      setIsRunning(true);
      return;
    }
    clear();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoDismiss]);

  return (
    <Toast
      children={children}
      appearance={appearance}
      placement={placement}
      autoDismiss={autoDismiss}
      onDismiss={onDismiss}
      isRunning={isRunning}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      showCloseButton={showCloseButton}
    />
  );
}

useTimer을 이용해서 MouseEnter했을 때랑 MouseLeave했을 때의 함수를 작성합니다. 마우스를 올리면 pause()가 실행되면서 멈추면서 isRunningfalse로 바뀌고 애니메이션도 멈춥니다. 마우스가 벗어나면 resume()이 실행되면서 남은 시간만큼 다시 타이머가 실행되면서 isRunningture로 바뀌고 애니메이션이 다시 시작합니다. 해당 함수들은 autoDismisstrue일 때만 실행됩니다.

React.useEffect(() => {
  if (autoDismiss) {
    setIsRunning(true);
    return;
  }
  clear();
}, [autoDismiss]);

autoDismissfalse로 바뀔 때 타이머의 clear()로 정리를 보장하고, autoDismisstrue로 바뀔 때 타이머의 재시작을 보장합니다. prop의 변화에도 안정적으로 작동하게 하는 보호 로직입니다.

4. Container

Controller를 감싸는 Container 컴포넌트로, 각 위치(placement)에 맞게 Toast 알림을 감싸는 레이아웃 역할을 합니다. 위치 지정과 이벤트 처리를 담당하고 있어요.

📜Interface/index.ts

interface IToastContainer {
  children: React.ReactNode;
  placement: keyof typeof Placements;
  hasToasts: boolean;
}

children : 포함할 토스트의 컴포넌트 들입니다.
placement : 알림창의 위치를 나타냅니다.
hasToasts : 현재 위치에 토스트가 하나 이상 있는지 여부를 나타냅니다.

📜Components/Toast/Container.tsx

import { css, cx } from "@emotion/css";
import Placements from "@/StyleConfig/Placements";
import { IToastContainer } from "@/Interface";

export default function Container({
  children,
  placement,
  hasToasts,
}: IToastContainer) {
  const toastContainerStyle = (hasToasts: boolean) => css`
    box-sizing: border-box;
    max-height: 100%;
    max-width: 100%;
    overflow: hidden;
    padding: 8px;
    pointer-events: ${hasToasts ? "auto" : "none"};
    position: fixed;
    z-index: 1000;
  `;

  return (
    <div className={cx(toastContainerStyle(hasToasts), Placements[placement])}>
      {children}
    </div>
  );
}

hasToasts 속성을 받아서 pointer-event 동작 여부를 결정합니다. pointer-event 는 토스트가 없을 때 클릭 이벤트를 막기 위해 none 처리 합니다. pointer-eventnone이면 유저가 이 영역을 클릭할 수 없습니다.
emotion/css에서는 cx() 제공 함수를 사용하면 두 개의 클래스 병합이 가능해요.
placement 속성을 받아서 placements[placement]로 실제 위치를 설정합니다.

5. Renderer

Toast 시스템의 최종 렌더러 역할을 합니다. 전체적으로 toastStack에 있는 토스트들을 위치별로 분류해서 각각 알맞은 위치(placement)에 뿌려주는 구조에요.

📜Components/Toast/Renderer.tsx

import { createPortal } from "react-dom";
import { useToasts } from "@/Hooks/useToasts";
import Container from "@/Components/Toast/Container";
import Controller from "@/Components/Toast/Controller";
import Placements from "@/StyleConfig/Placements";

export default function Renderer() {
  const { toastStack, removeToast } = useToasts();

  // placement 별로 그룹화
  const groupedToasts = toastStack.reduce((acc, toast) => {
    if (toast.placement) {
      if (!acc[toast.placement]) acc[toast.placement] = [];
      acc[toast.placement].push(toast);
    }
    return acc;
  }, {} as Record<string, typeof toastStack>);

  return createPortal(
    <>
      {Object.entries(groupedToasts).map(([placement, toasts], index) => (
        <Container
          key={`${placement}+${index}`}
          placement={placement as keyof typeof Placements}
          hasToasts={toastStack.length > 0}
        >
          {toasts.map(
            ({ id, appearance, content, autoDismiss, showCloseButton }) => (
              <Controller
                key={id}
                placement={placement as keyof typeof Placements}
                appearance={appearance}
                autoDismiss={autoDismiss ?? false}
                onDismiss={() => removeToast(id)}
                showCloseButton={showCloseButton ?? true}
              >
                {content}
              </Controller>
            )
          )}
        </Container>
      ))}
    </>,
    document.body
  );
}

toastStack에 담긴 여러 개의 토스트를 placement 기준으로 그룹화하고, 각각의 위치에 맞게 Container 안에 렌더링합니다. 그리고 포털(createPortal)을 통해 전역 DOM (document.body)에 삽입합니다.

const groupedToasts = toastStack.reduce((acc, toast) => {
  if (toast.placement) {
    if (!acc[toast.placement]) acc[toast.placement] = [];
    acc[toast.placement].push(toast);
  }
  return acc;
}, {} as Record<string, typeof toastStack>);

같은 위치(placement)끼리 모아 배열로 정리합니다.

{
  "top-right": [Toast1, Toast2],
  "bottom-left": [Toast3]
}

위와 같이 top-right, bottom-left 등에 따라 나눠집니다.

return createPortal(
  <JSX />,
  document.body
);

createPortal 는 컴포넌트를 현재 DOM 트리 밖에 있는 body 아래로 강제로 렌더링해줍니다. Toast 알림창이 페이지 전체 어디서든 보이게 하기 위해서 사용합니다.

{Object.entries(groupedToasts).map(([placement, toasts], index) => (
  <Container key={`${placement}+${index}`} placement={...}>
    {toasts.map(toast => (
      <Controller
        key={id}
        appearance={...}
        autoDismiss={...}
        showCloseButton={...}
        onDismiss={() => removeToast(id)}
      >
        {content}
      </Controller>
    ))}
  </Container>
))}

placement별로 한 Container를 만들고, 그 안에 Controller를 감싼 Toast들을 나열합니다.

각각의 토스트는 닫기 버튼(showCloseButton)이나 자동 닫기 (autoDismiss)도 가능합니다.

App에 Toast 알림 표시 예제 추가

Toast 알림 표시 예제

모든 작업을 마쳤으니 버튼 클릭 시 Toast 알림창이 보이도록 사용하는 컴포넌트를 만듭니다.

📜 Components/Button/Style.tsx

import { css } from "@emotion/css";
import Appearances from "@/StyleConfig/Appearances";
import Color from "@/StyleConfig/Colors";

export default function Style(appearance: keyof typeof Appearances) {
  const appearanceStyles: Record<
    keyof typeof Appearances,
    { bg: string; hoverBg: string }
  > = {
    error: { bg: Color.Red, hoverBg: Color.Red80 },
    success: { bg: Color.Green, hoverBg: Color.Green80 },
    warning: { bg: Color.Yellow, hoverBg: Color.Yellow80 },
    info: { bg: Color.Blue, hoverBg: Color.Blue80 },
  };
  const { bg, hoverBg } = appearanceStyles[appearance];

  return css`
    display: block;
    margin: 10px;
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 600;
    color: white;
    cursor: pointer;
    transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out;
    background-color: ${bg};

    &:hover {
      background-color: ${hoverBg};
      opacity: 0.9;
    }

    &:active {
      transform: scale(0.95);
    }
  `;
}

📜 Components/Button/index.tsx

우선 클릭할 버튼을 만들어줍니다.

import { useToasts } from "@/Hooks/useToasts";
import Appearances from "@/StyleConfig/Appearances";
import Style from "@/Components/Button/Style";

export default function Button() {
  const { addToast } = useToasts();

  const ToastData = [
    {
      id: 1,
      content: "Error 알림창입니다.",
      appearance: "error",
      autoDismiss: true,
      placement: "top-right",
    },
    {
      id: 1,
      content: "Success 알림창입니다.",
      appearance: "success",
      autoDismiss: true,
      placement: "top-right",
    },
    {
      id: 1,
      content: "Warning 알림창입니다.",
      appearance: "warning",
      autoDismiss: true,
      placement: "top-right",
    },
    {
      id: 1,
      content: "Info 발생 알림창입니다.",
      appearance: "info",
      autoDismiss: true,
      placement: "top-right",
    },
  ];

  return (
    <>
      {ToastData.map((toast) => (
        <button
          className={Style(toast.appearance as keyof typeof Appearances)}
          onClick={() =>
            addToast(toast.content, {
              appearance: toast.appearance as keyof typeof Appearances,
              autoDismiss: true,
              placement: "top-right",
            })
          }
        >
          {toast.appearance} Toast Button
        </button>
      ))}
    </>
  );
}

버튼을 클릭하면 useToasts Hook에서 addToast를 이용해서 toastStack에 해당 알림 추가합니다.

const { addToast } = useToasts();

onClick={() =>
  addToast(toast.content, {
    appearance: toast.appearance as keyof typeof Appearances,
    autoDismiss: true,
    placement: "top-right",
  })
}

📜App.tsx

import { Provider } from "react-redux";
import store from "@/Redux/Store";
import Renderer from "@/Components/Toast/Renderer";
import Button from "@/Components/Button";

function App() {
  return (
    <Provider store={store}>
      <Renderer />
      <Button />
    </Provider>
  );
}

export default App;

ProviderRedux전역 상태 관리를 설정합니다.

다양한 Toast 알림을 발생시키는 Button 컴포넌트로 클릭 시 useToasts()로 새 알림을 추가하고 Renderer에서 감지해서 보여줍니다.

Renderer로 화면 어딘가에 표시되는 토스트 알림 메세지들을 렌더링합니다. useToasts() 훅으로 Redux에 있는 toastStack을 읽고 placement에 따라 위치를 나누고, 각각의 Toast를 Controller 컴포넌트를 통해 보여줍니다.

Storybook 작성

StorybookToast 컴포넌트 스토리 추가합니다.

📜stories/Toast.stories.tsx

import { Meta, StoryObj } from "@storybook/react";
import { Provider } from "react-redux";
import Store from "@/Redux/Store";
import { useToasts } from "@/Hooks/useToasts";
import Renderer from "@/Components/Toast/Renderer";
import Appearances from "@/StyleConfig/Appearances";
import Placements from "@/StyleConfig/Placements";
import Style from "@/Components/Button/Style";
import { IToast } from "@/Interface";

const meta: Meta = {
  title: "Toast",
  component: Renderer,
  parameters: {
    layout: "centered",
    docs: {
      description: {
        component: "상태를 확인하고 알림을 화면에 나타냅니다.",
      },
    },
  },
  decorators: [
    (Story) => (
      <Provider store={Store}>
        <Renderer />
        <Story />
      </Provider>
    ),
  ],
  tags: ["autodocs"],
  argTypes: {
    content: {
      description: "Toast 안에 들어갈 내용을 작성합니다.",
      control: "text",
      table: {
        category: "필수속성",
        type: {
          summary: "ReactNode",
        },
      },
    },
    autoDismiss: {
      description: "자동으로 알림창이 닫히게할지 여부를 선택합니다.",
      type: "boolean",
      control: "boolean",
      table: { category: "옵션속성", defaultValue: { summary: "false" } },
    },
    appearance: {
      description: "알림창의 스타일을 선택합니다.",
      control: "select",
      options: Object.keys(Appearances),
      table: {
        category: "필수속성",
        type: {
          summary: "string",
          detail: Object.keys(Appearances)
            .map((appearance) => `'${appearance}`)
            .join(" | "),
        },
      },
    },
    placement: {
      description: "알림창의 위치를 선택합니다.",
      control: "select",
      options: Object.keys(Placements),
      table: {
        category: "필수속성",
        type: {
          summary: "string",
          detail: Object.keys(Placements)
            .map((placement) => `'${placement}`)
            .join(" | "),
        },
      },
    },
    showCloseButton: {
      description: "닫힘 버튼이 보일지를 선택합니다.",
      type: "boolean",
      control: "boolean",
      table: { category: "옵션속성", defaultValue: { summary: "true" } },
    },
  },
};

export default meta;

const Toast = ({
  content,
  appearance,
  autoDismiss,
  placement,
  showCloseButton,
}: Omit<IToast, "id">) => {
  const { addToast } = useToasts();

  const handleClickButton = () => {
    addToast(content, {
      appearance,
      autoDismiss,
      placement,
      showCloseButton,
    });
  };

  return (
    <button onClick={handleClickButton} className={Style(appearance)}>
      {appearance} Toast
    </button>
  );
};

export const Error: StoryObj = {
  render: (args) => (
    <Toast
      content="This is a toast message"
      appearance="error"
      autoDismiss={false}
      placement="top-right"
      showCloseButton={true}
      {...args}
    />
  ),
};

export const Success: StoryObj = {
  render: (args) => (
    <Toast
      content="This is a toast message"
      appearance="success"
      autoDismiss={false}
      placement="top-right"
      showCloseButton={true}
      {...args}
    />
  ),
};

export const Warning: StoryObj = {
  render: (args) => (
    <Toast
      content="This is a toast message"
      appearance="warning"
      autoDismiss={false}
      placement="top-right"
      showCloseButton={true}
      {...args}
    />
  ),
};

export const Info: StoryObj = {
  render: (args) => (
    <Toast
      content="This is a toast message"
      appearance="info"
      autoDismiss={false}
      placement="top-right"
      showCloseButton={true}
      {...args}
    />
  ),
};

Renderer 컴포넌트를 중심으로 Toast 알림 예제 구성했어요. 에러, 성공, 경고, 정보 유형의 알림 버튼 추가하고, appearance, content, autoDismiss 등 prop 제어 가능하도록 argTypes 설정해서 미리보기를 보면서 바꿀 수 있도록 했습니다. Redux ProviderRendererdecorators로 감싸서 스토리북에서도 실제 동작 확인 가능해요!
가장 상단에 있는 Storybook 문서보기 링크를 통해서 속성이 바뀌고 나면 어떻게 동작하는지 확인해볼 수 있어요.

전체 폴더 구조

마무리 된 코드의 폴더 구조는 아래와 같습니다.

📦src
 ┣ 📂Components
 ┃ ┣ 📂Button
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┗ 📜Style.tsx
 ┃ ┗ 📂Toast
 ┃ ┃ ┣ 📜Container.tsx
 ┃ ┃ ┣ 📜Controller.tsx
 ┃ ┃ ┣ 📜CountdownBar.tsx
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜Renderer.tsx
 ┃ ┃ ┗ 📜Style.tsx
 ┣ 📂Hooks
 ┃ ┣ 📜useTimer.tsx
 ┃ ┗ 📜useToasts.tsx
 ┣ 📂Interface
 ┃ ┗ 📜index.ts
 ┣ 📂Redux
 ┃ ┣ 📜Hooks.ts
 ┃ ┣ 📜Slice.ts
 ┃ ┗ 📜Store.ts
 ┣ 📂stories
 ┃ ┗ 📂Toast
 ┃ ┃ ┗ 📜Toast.stories.tsx
 ┣ 📂StyleConfig
 ┃ ┣ 📜Appearances.tsx
 ┃ ┣ 📜Colors.ts
 ┃ ┣ 📜Icons.tsx
 ┃ ┗ 📜Placements.tsx
 ┣ 📂Utils
 ┃ ┗ 📜generateUEID.ts
 ┣ 📜App.tsx
 ┣ 📜index.css
 ┣ 📜main.tsx
 ┗ 📜vite-env.d.ts

이렇게 모든 작업이 끝났습니다!
Redux를 처음 써봐서 천천히 찾아가면서 적용하다보니 생각보다 오래걸리더라고요😭
그래도 완성하고나서 굉장히 뿌듯하고 배울게 많았던 작업이었답니다.
이제 위 코드에서 유닛 테스트, E2E 테스트, Storybook 테스트도 추가하고, 동일 위치 Toast 스택 개수 제한도 하면서 코드를 조금씩 개선해볼까 생각중이에요!
직접 사용해보고 싶으신 분들은 깃허브 링크를 참고해주세요:)

profile
개미는 뚠뚠..오늘도 뚠뚠🐜

0개의 댓글