【hook設計】recoilでスナックバーを設計する
はじめに
今回スナックバーのリファクタリングをしていて,コンポーネントの設計について少し考える機会があったので共有させていただきます.
スナックバーなどの通知系のコンポーネントはトップあたりにひとつ置いておくだけで勝手に表示されるようにしたいです.
環境
React, Next.js, TypeScript, MUIなど
recoilとは?
Metaが作成しているReact向けの状態管理フレームワークです.
Reduxとは違い、全ての状態が 1 つの Store としてまとまっておらず、atom の組み合わせで状態を表現
書き味がReactのHookに似ているので直感的に理解することができます.
詳細についてはたくさん良い記事があるので参考に載せておきます.
現在(2022/11/13)では0.7.6
が最新となっており,未だ1
系のリリースがされていません.
MUI
今回はUIフレームワークとしてMUIを使用しています.
他のUIフレームワークや自作のデザインシステムを使用している場合はそれぞれの状況に合わせて置き換えながら読んでいただけたらと思います.
recoilをどう扱うか?
keyについて
recoilはatomを作成する際にプロジェクトで一意なkeyを設定する必要があります.
こちら(LINE証券の技術記事)ではRecoilKeys.ts
というファイル内でenumを使ってkeyを管理しています.
しかしサバイバルTypeScriptや他の記事でも言及されているように型安全の面から少し扱いづらい部分があります.
ということで今回はオブジェクトリテラルを使ってkeyを管理することにしました.
こうすることでkeyの一意性を担保することができます.
具体的にはこんなファイルを作成しました.
export const RecoilAtomKeys = {
SNACKBAR_STATE: 'snackbarState',
} as const
プロジェクトの方針にもよりますが,こちらのファイルはより上位のディレクトリに置いています.
今回はsrc/stores/
に作成しました.
hooksについて
recoilのatom定義や機能の提供は全てhooks.ts
というファイルに記述することにしました.
そしてこのhooks.ts
はsrc/domains/snackbar/
に置いています.
これもプロジェクトによって異なると思いますが,本プロジェクトではsrc/domains
の中にドメインに関わる機能(UIも含む)を作成するようにしています.
こうすることで新しい機能が必要な場合はsrc/domains/
に作成すればよく,さらに使用しなくなった場合はそのディレクトリを削除するだけで済みます.
import { AlertColor } from '@mui/material'
import { atom, useRecoilState, useSetRecoilState } from 'recoil'
import { RecoilAtomKeys } from '~/stores/recoilKeys'
type SNACKBAR_STATE = {
isOpen: boolean
text: string
severity: AlertColor
}
type SNACKBAR_PARAMS = Pick<SNACKBAR_STATE, 'text' | 'severity'>
const snackbarStateAtom = atom<SNACKBAR_STATE>({
key: RecoilAtomKeys.SNACKBAR_STATE,
default: {
isOpen: false,
text: '',
severity: 'info',
},
})
export const useSnackbar = () => {
const setSnackbarState = useSetRecoilState(snackbarStateAtom)
const openSnackbar = ({ text, severity }: SNACKBAR_PARAMS) => {
setSnackbarState({
isOpen: true,
text,
severity,
})
}
const closeSnackbar = () => {
setSnackbarState({
isOpen: false,
text: '',
severity: 'info',
})
}
return { openSnackbar, closeSnackbar }
}
export const useCustomSnackbar = () => {
const [snackbarState, setSnackbarState] = useRecoilState(snackbarStateAtom)
return { snackbarState, setSnackbarState }
}
recoilはuseRecoilValue
,useSetRecoilState
,useRecoilState
というようなAPIがありますが,それらをむき出しにしたくないのでラップしたものをexportします.
例外的にuseCustomSnackbar
はほぼ何もせずに機能を提供していますが,こちらはUIコンポーネントで使用するためだけのカスタムフックです.
実際に使用する際は
const { openSnackbar, closeSnackbar } = useSnackbar()
...
みたいに使う形になります.
ここで考慮した点は,
- ユーザーが使いたいのはスナックバーを開く,閉じるという行為のみ
- 開くときはメッセージ(text)とメッセージの重大度(severity)だけを指定したい
です.これを考慮することで使う側も非常に直感的に使用することができます.
UIについて
先ほどのhooks.ts
で定義したuseCustomSnackbar
をここで使います.
import { Alert, Snackbar } from '@mui/material'
import { useCustomSnackbar, useSnackbar } from './hooks'
export const CustomSnackbar = () => {
const { snackbarState } = useCustomSnackbar()
const { closeSnackbar } = useSnackbar()
return (
<Snackbar
open={snackbarState.isOpen}
autoHideDuration={6000}
onClose={closeSnackbar}
>
<Alert
onClose={closeSnackbar}
severity={snackbarState.severity}
sx={{ width: '100%' }}
>
{snackbarState.text}
</Alert>
</Snackbar>
)
}
ここはプロジェクトで使用しているUIフレームワークに合わせて作成していただければと思います.
useCustomSnackbar
をexportしたくないと考えるならばhooks.ts
でCustomSnackbar
をレンダリングする関数を返すようにしても良いかもしれません.(render hooksパターンと呼ばれるやつです)
今回はUIとhookを完全に分けたかったので上のような設計にしました.
こうすることで実はロジック部分をrecoilではなくuseContextを使った場合でもUIコンポーネントは変更せずにすみます.
(このファイルはMUIとhooks.tsにしか依存していません)
使い方
コンポーネントで使う場合はとてもシンプルに使うことができます.
import { useSnackbar } from '~/domains/snackbar'
const Sample = () => {
const { openSnackbar } = useSnackbar()
const onClick = () => {
openSnackbar({
text: 'クリックされました',
severity: 'info'
})
}
...
}
また_app.tsx
などに
RecoilRoot
とCustomSnackbar
を配置すればどのコンポーネントからでもスナックバーを使うことができます
おわりに
この設計はプロジェクトのディレクトリ構成に少し依存する部分があるので全ての方におすすめできるものではありませんが,今の状況では使いやすい設計だと思うので共有させていただきました.
もっと良い設計や,ここもう少しこうした方が良いという部分などありましたぜひコメントお願いします!
参考
Discussion