🔔

Recoilで新着通知管理を行う

2023/04/12に公開

実装中のWebアプリケーションにて試行錯誤しながらRecoilでの状態管理へと移行していたが、或る程度目処が立ったため記事としてまとめてみることにした。今後似たようなものを作ろうと思ったときの備忘録。

記事内にもあるようにまだ1処理内selectorやhooksを何度も作り直すレベルなので、間違いや改良点などあればご指摘頂けると幸いです。

概要

今回実装したのは

  • 所属グループ単位でのチャットボードへの新着
  • イベントのリマインド
  • システムからの通知
    これらを一緒くたにRecoilで管理してみようという試み。

実行環境

React: 18.2.0
TypeScript: 4.9.4
Nextjs: 13.2.4
Recoil: 0.7.6

開発途中に何度かバージョン上げたので react 17nextjs v12 も通過済み。

背景

Recoilを試そうと思ったきっかけ

何十個ものuseState, useContext, useEffectで管理される副作用祭りのコードで「修正すべき箇所」探しに苦戦したため、今更ながらRecoilデビューしてみることにした。

go to definition で完結していればまだ良いのだが、useContextを介すとそのProvider+valueを探したうえで定義を参照する必要がある。valueでsearchを掛けると何十個ものコンポーネントがヒットする、見つけたと思ったら別関数でも編集されている、useEffectでも更新されている…といろいろツラミはあった訳だが、何よりctrl + clickで完結しなかった(useContext)のが決定的かもしれない。

Recoilを触ってみた所感

Recoilでの状態管理は

  • atom/selector/hooks間の依存関係の設計が難しい
    • どこで何を起こすか?をしっかり整理しないと混乱する
    • 複数人で開発するとき大変かもしれない(本事例は私一人のため気にしていない)
  • 元々の問題は半分解決、半分未解決
    • 複数のatom, selectorを参照したhooksなどではけっきょく原因究明が大変になるかも
    • とはいえ"辿りやすさ"という意味ではロジックがselectorかhooksに集約されるため(そういう書き方をしている)随分気楽

と言った感じ。

hooksでの加工箇所をselectorで書き直したりselector箇所をatomに分解したりと最初こそ何度も作り直したものの、或る程度特性が掴めてきてatom, selectorを気軽に追加できるようになってきた。慣れたら上記の問題も気にならなくなるかもしれない。

実装内容

作成したものを順に貼り付け。

atom

  • getLatestCreatedAtom<string, GroupId> : 最後に観測したチャットボードの最新メッセージ投稿日時
  • getNewCountAtom<number> : 新着メッセージ件数
  • notificationAtom<string[]> : 各種新着情報を取りまとめた通知メッセージarrayを作成(=最終的な集約先)

selector

  • notifyGroupMessageSelector<string[], GroupId> : 新着メッセージ件数 を元に "**件の新着メッセージがあります" を作成
  • notifyEventSelector<string[]> : 日付とイベント取得結果を元に "**のイベントがあります" を作成
  • notifySystemInfoSelector<string[]> : 管理者側からの通知を元に作成

hooks

  • useNewMessageCount : 新着件数 set
  • useNotification : 通知内容 set

イメージ

コンポーネント名は気にせず、あくまで依存関係だけ見る感じで。
何度も整理しながら書き直した図(5, 6個目くらい)。 get, set = state, setState と同じ感覚。atom, selectorが更新されることで連鎖的に更新が起こり、コンポーネントで表示される通知メッセージ一覧が変更される。

実装例

記事用に手元のコードを一部手動で書き換えたので正しく動かない箇所あるかも。然るべき値に読み替えて読んで頂けると。。

ex1. グループ別新着件数管理用hooks

新着件数を更新するための副作用hooks。コンポーネント側で useRecoilValue(getNewCount()) で最新の値を取るが、hooks内で取得して返却しても良い。
試した範囲では最新の値取得のためには useRecoilState とは別に useRecoilValue で値を取り出したほうが確実なように感じたけど、実際のところどうなのだろう…?

const useNewMessageCount = ({ groupName }: Props) => {
  // atomによるセッション管理 + cookieによる永続管理を組み合わせ
  const { getCookies } = useCookies()
  const latestDateFromCookie = getCookies(COOKIE_KEY)
  const latestDateFromAtom = useRecoilValue(getLatestCreated(groupName))
  const latestDate = latestDateFromAtom || latestDateFromCookie
  // 新着件数の set + get
  const [, setNewCount] = useRecoilState(getNewCount(groupName))

  const { data, isLoading } = useSWR(PATH, FETCHER)
  const length = (!isLoading && data.length) || 0
  setNewCount(length)
}

ex2. 新着メッセージ更新用selector + hooks

  • messagesTextSelector でatomを元に単一グループに対しての表示メッセージを作成
  • notifyGroupMessagesSelector でそれをグループ横断して取りまとめ、通知シリーズでフォーマットを揃えて返却。他通知も同様に実装して useNotification hooksではそれらを取りまとめる副作用を担当
  • コンポーネントでは notificationAtom を取得

notificationAtom の配列長は通知件数、それらの要素を逐次表示すれば通知内容一覧、それらをhrefに入れれば通知先リンクをそれぞれ表示可能、という仕組み。

const messagesTextSelector = selectorFamily<string | null, GroupName>({
  key: "messagesTextSelector",
  get:
    (groupName) =>
    ({ get }) => {
      const newCount = get(getNewCount(groupName))
      return newCount === LIMIT_LENGTH
        // 特定件数以上は固定表示したり
        ? `${groupName}${LIMIT_LENGTH}件以上の新着メッセージがあります`
        : newCount > 0
        ? `${groupName}${newCount}件の新着メッセージがあります`
        : null
    },
})

const notifyGroupMessagesSelector = selectorFamily<UserNotification[], GroupName[]>({
  key: "notifyGroupMessagesSelector",
  get:
    (groupNameList) =>
    ({ get }) => {
      return (
        groupNameList
          .map((groupName) => {
            // 上記メッセージを更に別selectorで読み込み
            const content = get(messagesTextSelector(groupName))
            if (!content) return null
            // 他通知とフォーマットを揃えるために加工
            return {
              type: "group",
              content: content,
              createdAt: new Date().toISOString(),
              link: `/group/${groupName}`,
            } as UserNotification | null
          })
          .filter((n) => n) as UserNotification[]
      )
    },
})

const useNotification = (groupNameList: GroupName[] | undefined) => {
  const [, setNotifications] = useRecoilState<UserNotification[]>(notificationAtom)
  const notifications = useRecoilValue<UserNotification[]>(notificationAtom)
  // 新着メッセージ通知
  const messages = useRecoilValue(notifyGroupMessagesSelector(groupNameList))
  // イベント情報通知
  const events = useRecoilValue(notifyEventSelector)
  // システム情報通知
  const notes = useRecoilValue(notifySystemInfoSelector)
  setNotifications([
    ...messages,
    ...events,
    ...notes,
  ])
  return { notifications }
}

まとめ

初の試みなので試行錯誤の時間がかなり長かった。
延々書き直したり更新されない値の原因究明に追われたりと時間を取られたものの、その後別プロジェクトでの活用はかなりスムーズに進むようになった。その事例は別記事で。

現状の悩みはatom, selector, hooks(, component) での依存関係の可視化。今のところまだ イメージ に書いたような手書き整理をしないと混乱してしまうので先駆者のノウハウを知りたい。

他参考記事は脚注に。[1] [2] [3] [4]

脚注
  1. 【Recoil】Reactの状態管理ライブラリ基礎学習 ~リファクタ編~ ↩︎

  2. Recoilで快適フロントエンド開発 ↩︎

  3. ProviderタワーをRecoilに置き換える ↩︎

  4. Recoilにロジックを載せる運用戦略 ↩︎

Discussion

Hidden comment