Reduxを利用してトースト通知を作ってみよう

  • ReactJS

アプリケーションでユーザーに一時的に情報を伝えるUIは種々あります。ポップアップ、スナックバーと呼ばれるものがあったりします。今回作ってみるのはトーストと呼ばれる通知UIです。(こういったUIのネーミングってセンスがありますよね 😁)

トーストは、「メールを送信しました」や「記事を保存しました」といったメッセージが画面の端にぴょこんと現れ、時間が経つと消える(または手動で消す)ものです。

React-Toastifyといったナイスなライブラリもあり、それを使えば済む話ではありますが、後学のために簡単なものから作ってみましょう!

仕様

仕様は以下のようにします。

  • 表示位置は画面下中央で固定
  • レベルが選択でき、カラーパターンを変える(エラー、警告、情報、成功)
  • 一定時間が経過すると消える
  • 秒数固定

シンプルなものからはじめて、勘がつかめたら秒数を変えたり、表示位置を変えられるようにしたりカスタマイズしてみましょう。

実装をはじめる前に

今回はReact.jsで実装します。また、トーストの状態を管理するためにReduxを利用します。Context APIで管理してもよいのですが、ある程度の規模のものに入れることを想定して、Reduxを選択しました。また、Reduxを扱うにあたりRedux Toolkitを利用します。Reduxを扱うツールとしてとても便利なライブラリです。

※ 記事を書いている時点ではRedux Toolkitのバージョンはv1.6です。今後変わることがあるので、都度読み替えていただければと思います。

実装

完成コードはGitHubのリポジトリにあげていますので、そちらを見つつ読んでいただければと思います。

https://github.com/nemuvski/demo-rtk-toast

次の流れで説明していきます。

  1. ストアの準備
  2. コンポーネントの作成
  3. トーストの表示・非表示の制御

ストアの準備

発行したトーストの状態をストアしておく場所(スライスと呼ぶ)を定義する前に、トーストのモデルを定義します。

ここではToastContentという名前とします。

👀 src/models/Toast.ts

import { nanoid } from '@reduxjs/toolkit'

/**
 * Toastの内容
 */
export interface ToastContent {
  id: string
  level: ToastContentLevel
  content: string
  // 作成日時のタイムスタンプ
  createdAtTimestamp: number
}

/**
 * Toastのレベル識別子
 */
export type ToastContentLevel = 'success' | 'info' | 'warning' | 'error'

/**
 * ToastContentのオブジェクトを生成
 */
export const buildToastContent = (level: ToastContentLevel, content: string): ToastContent => ({
  // IDはランダム生成(8文字)
  id: nanoid(8),
  level,
  content,
  createdAtTimestamp: Date.now(),
})

idというフィールドを持たせていますが、これはRedux ToolkitのEntityAdapterを用いるために利用します。内容はランダムに決定するようにしています。

このEntityAdapterを利用することで、ストアデータをノーマライズ(フラットな構成)で管理しておくことができます。これによりストアデータのCRUD操作が楽になり、パフォーマンス面でもよいです。複数のデータを扱うスライスには、このEntityAdapterを利用することをおすすめします。

📖 Normalizing State Shape

次にtoastスライスを用意します。

👀 src/stores/toast/slice.ts

export const toastAdapter = createEntityAdapter<ToastContent>({
  // 作成日時について降順とする
  sortComparer: (a, b) => b.createdAtTimestamp - a.createdAtTimestamp,
})

export const toastSlice = createSlice({
  name: 'toast',
  initialState: toastAdapter.getInitialState(),
  reducers: {
    addToast: (state, action: PayloadAction<ToastContent>) => {
      toastAdapter.addOne(state, action.payload)
    },
    removeToast: (state, action: PayloadAction<string>) => {
      toastAdapter.removeOne(state, action.payload)
    },
  },
})

export const { addToast, removeToast } = toastSlice.actions

冒頭で createEntityAdapter() で専用のアダプター(EntityAdapter)を用意しています。新しいトーストが先に表示されるようにしたいので、作成日時について降順にソートする設定をしています。

次に createSlice() でスライスを作成しています。reducerとactionを定義しています。専用アダプターを介して、トーストの追加と削除をしているのが分かるかと思います。

最後にここでは割愛しますが、作成したreducerをストアに設定すれば準備完了です。

設定は src/stores/store.ts に書いてあります。

コンポーネントの作成

コンポーネント側からトーストを追加・削除する時、いちいちdispatchを実行するために一連のコードを書くのが面倒なので、登録と削除のdispatchをする関数をまとめておきましょう。

これは必須ではないですが、毎回 useDispatch() を書いたりするのがイヤなので...

👀 src/hooks/useToast.ts

export const useToast = () => {
  const dispatch = useDispatch<AppDispatch>()

  const add = useCallback(
    (level: ToastContentLevel, content: string) => {
      dispatch(addToast(buildToastContent(level, content)))
    },
    [dispatch]
  )

  const remove = useCallback(
    (id: string) => {
      dispatch(removeToast(id))
    },
    [dispatch]
  )

  return {
    addToast: add,
    removeToast: remove,
  }
}

今回作成するコンポーネントは次の2つです。

  • Toaster
  • Toast

ToasterコンポーネントはToastコンポーネントの一覧を表示するもので、Toastコンポーネントは通知内容を表示するものです。

名前がややこしいですが、トーストがトースターにささっている様子をイメージしていただければよいかと思います。🍞

src/components 下にファイルがあるのでご覧ください。ファイルの内容は後で触れます。

トーストの表示・非表示の制御

今回はサンプルなので、ルートコンポーネントのAppにボタンを配置して、ボタンが押されたらトーストがストアに追加されて、画面に現れるだけの簡単なものにしています。

コードを見て分かるように、いたってシンプルです。

👀 src/components/App.tsx

const App = () => {
  const { addToast } = useToast()
  return (
    <>
      <ul>
        {['success', 'info', 'warning', 'error'].map((level) => (
          <li key={level}>
            <button type='button' onClick={() => addToast(level, 'Sample Text')}>
              {level}
            </button>
          </li>
        ))}
      </ul>
      <Toaster />
    </>
  )
}

export default App

トーストが追加されると、Toasterコンポーネントでストアからトーストのデータを取り出して、一覧に展開します。

👀 src/components/Toaster.tsx

const Toaster = () => {
  const toasts = useSelector(selectAllToasts)
  if (!toasts.length) {
    return null
  }
  return (
    <div className='toaster'>
      {toasts.map(({ id }) => (
        <Toast key={id} toastId={id} />
      ))}
    </div>
  )
}

export default Toaster

Toasterコンポーネントで取得されたトーストはToastコンポーネントに渡り、レンダリングされます。

この時、内容のレベルに応じてカラーパターンを変えているので、要素のクラス名に指定しています。今回スタイリングについては説明を端折ります。

💅 src/styles/toast.css

表示されたトーストは、一定時間後に自動的に非表示にする仕様なので、 setTimeout() を利用して指定した時間経過後に削除するようにしています。また、フェードアウトするために削除する直前に特定のクラスを付与してアニメーションさせています。

※ コード中では setTimeout() を実行するためのカスタムフック useTimeout() を用意して、実行しています。

👀 src/components/Toast.ts

type Props = {
  toastId: string
}

const Toast: React.FC<Props> = ({ toastId }) => {
  const toast = useSelector(selectToast(toastId))
  const [isHidden, setIsHidden] = useState(false)
  const { removeToast } = useToast()

  // 一定時間経過後に削除する
  useTimeout(() => setIsHidden(true), TOAST_DISPLAY_DURATION - TOAST_ANIMATION_DURATION)
  useTimeout(() => removeToast(toastId), TOAST_DISPLAY_DURATION)

  if (!toast) {
    return null
  }
  return <div className={clsx('toast', toast.level, { 'is-hidden': isHidden })}>{toast.content}</div>
}

export default Toast

コード中の TOAST_DISPLAY_DURATIONTOAST_ANIMATION_DURATIONsrc/constants.ts に定義してあります。

おわりに

興味がある方はトーストごとに表示時間や表示位置を変えられるようにカスタマイズしてみてください。

この記事を共有

アバター

K.Utsunomiya
男・20代
主にWebフロントエンド技術と気になった音楽について投稿していきます。
最近ハマっていることは、クロスバイクで走ることとジムでの運動です。
詳しいプロフィール

© 2020–2021 コレ棚