ReduxとReact Hooksを活用してトーストを実装する


「保存に成功しました」だったり「クーポンの適応に失敗しました」だったり
ユーザーに何かしらの内容を一時的に伝えたい時があると思います。

そのような時に用いられる通知のUIの一種であるトーストを実装していきます。
イメージとしてはこのようなものです

トーストの要件

下記の要件を満たすトーストを作っていきます。

  • 1つずつ順番に表示される
  • 表示されてちょっと経ったら自動で消える
  • どのコンポーネントからでも新しいトーストを追加できる
  • デザインはがんばらない

実装

1. トーストの情報を保持する場所を用意する

どこからでも追加と参照ができるように、ReduxのStoreに持たせます。
Store自体の設定については割愛します。

// toast.ts
import { Reducer, AnyAction } from 'redux'
import { isType, actionCreatorFactory } from 'typescript-fsa'
import { useSelector } from 'react-redux'

export type Toast = {
  message: string
}
type Toasts = Toast[]

// action
const actionCreator = actionCreatorFactory('TOAST')
export const Push = actionCreator<{ toast: Toast }>('PUSH')
export const Shift = actionCreator('SHIFT')

// reducer
const initialState: Toasts = []
export const reducer: Reducer<Toasts> = (
  state: Toasts = initialState,
  action: AnyAction,
) => {
  if (isType(action, Push)) {
    const { toast } = action.payload
    return state.concat([toast])
  }

  if (isType(action, Shift)) {
    return state.slice(1)
  }

  return state
}
export default reducer

// selector
export const GetToasts = (): Toasts => useSelector(
  (state: { toasts: Toasts }) => state.toasts,
)

Stateをファイルの中で定義していますが
プロジェクトに合わせて読み込むと良いと思います。

2. トーストを表示するコンポーネントを用意する

このコンポーネントでは表示の状態を管理するためにuseState
トーストの変化を監視するためにuseEffectといったReact Hooksの機能を用います。

トーストの情報はStoreに保存し、selectorも用意してあるので
どこからでも簡単に呼び出せます。

// ToastContainer.tsx
import React, { useState, useEffect } from 'react'
import { useDispatch } from 'react-redux'

import { GetToasts, Shift } from 'toast.ts'
import style from 'style.scss'

const timeout = async (ms: number) => (
  new Promise((resolve: () => void): void => {
    setTimeout(() => resolve(), ms)
  })
)

const ToastContainer: React.FC = () => {
  const toasts = GetToasts()
  const [visible, setVisible] = useState(false)
  const dispatch = useDispatch()

  useEffect(() => {
    if (toasts.length === 0 || visible) {
      return
    }

    const showToast = async () => {
      setVisible(true)
      await timeout(3000)
      setVisible(false)
      await timeout(500)
      dispatch(Shift())
    }

    showToast()
  }, [toasts])

  return (
    <div className={style.toastContainer}>
      <div className={`${style.toast} ${visible && style.visible}`}>
        {toasts.length > 0 && toasts[0].message}
      </div>
    </div>
  )
}

export default ToastContainer

トーストの表示の流れはこのようになります

  1. toastsが増えたら監視しているuseEffectが発火
  2. トーストが見えるようにvisibletrueに変更
  3. ちょっと経ったらvisiblefalseに変更して隠す
  4. dispatch(Shift())で先頭のtoastsを削除
  5. toastsが更新されるのでuseEffectがもう一度発火
  6. まだtoastsがあったら2に戻る

あとは適当にcssを当ててあげればOKです

// style.scss
.toast {
  width: 320px;
  padding: 16px 0;
  color: #fff;
  text-align: center;
  background-color: red;
  opacity: 0;
  transition: opacity .6s;

  &.show {
    opacity: 1;
    transition: opacity .2s;
  }
}

3. トーストを追加する

Reduxでトーストを管理しているので、どこで追加しても大丈夫です。
例えば何かしらのAPIを呼んだときはこのようにトーストを追加します

save()
  .then(() => dispatch(Push({ toast: { message: '保存しました' } })))
  .catch(() => dispatch(Push({ toast: { message: '保存に失敗しました' } })))

トーストの内容に応じて色を変えたりしたい場合は、typeなども持たせましょう。

まとめ

バリデーションや表示の仕方に関しては実装したい仕様に合わせて適宜変更して下さい。
React Hooksを使うと煩雑だった処理がすっきり書けるので楽しいです。

参考