🔀

useSWR+CQRSパターンでローカル状態管理を極限まで減らしてみる

に公開

ちょっと株式会社, n13uです. 今回の記事では現在自分が取り組んでいる案件でuseSWR+CQRSパターンを採用しローカルでの状態管理を減らしてみた話をします.

前提条件

  • Next.js 14.x / Pages Router
  • Static Exportsで出力されSPAモードで動いている
  • Fluxパターンをもとにした自作の状態管理システムから新しい状態管理システムへの移行が必要になっていた
  • ほぼ掲示板型のアプリケーション
  • データベースはFirestoreでアプリケーション(クライアント)側は基本的にFirestoreの更新と表示を行うだけで良い(複雑なローカルでの状態管理が必要ない)

既存のFluxパターンで動く状態管理システム

グローバルステートはContext/Reducerを利用し、Reduxのようなアーキテクチャになっていた。 action.ts / reducer.ts / state.ts が状態ごとにあるイメージ. ちなみにFacebook(現Meta)が提唱した方式

https://github.com/facebookarchive/flux/tree/main/examples/flux-concepts

Fluxパターンの課題

  • state, action, reducerとファイルが分かれておりどこに何を書いていて, 状態更新そのものをどこで行なっているか, どうViewに影響するかがわかりづらいです(自分が慣れていないのも多分にある)
  • stateの大きさによっては意図せず状態更新が行われ, 画面描画が更新されうる事があります.
    • しかし, そこを把握しづらいという問題もあります.

Fluxパターン採用のもう1つの課題

今回の本題です. Fluxパターンを採用する上でのもう一つの課題が非同期処理との組み合わせが難しい点でした. Fluxパターンはそのサイクル内では強い力を発揮しますが, 非同期処理のような副作用と組み合わせると途端に管理が面倒になるイメージがあります.

今回の場合, ローカルの状態を更新しつつ外部のデータストアとその変更を同期しなければならない点が大きな課題でした.

というのも, ローカルの状態と外部の状態を同期する際にパッと考えられるだけでも同期の仕方にいくつか方法があります.

  1. ローカルを先に更新し、後から外部に反映させる(楽観的更新)
    • 例えば一時的にオフラインになるなど外部への反映が失敗すると画面をリロードした際に更新ができなくなる
  2. グローバルを更新し, APIの戻り値に結果を含み反映させる
  3. グローバルを先に更新しつつ, 同じ変更をローカルにも反映させる
    • 一見同じように見えるが外部のデータストアとローカルのデータで, データの持ち方を変える事ができる(外部ストアでは配列だがローカルではKey-Valueとして持たせることも可能)

今回のFluxパターンでは3.が採用されていました. が, そのために重要な非同期処理部分はuseStateuseEffectで実現されており, 描画更新がこれまた多発するなど新たな課題も増えていました.

これらを考慮しつつ, 描画タイミングを制御するのをReduxならまだしも自作のFluxシステム上で行うのは難しいと考えました.(Reduxの場合, redux-thunkなど非同期で状態を更新しつつ同期する仕組みを持っています.)

useSWR + CQRSパターンの採用

といったところでFluxパターンは捨て別の状態管理の仕組みを採用することを考えました.
今回のアプリケーションの特性上, 外部のデータストアにある値をほぼそのまま使い更新することで機能実装を満たす事ができます.

ということは, "外部ストアをグローバルストアと見做し, データフェッチングによる読み書きを利用した状態管理ができるのでは" と考えました.

データフェッチングライブラリを状態管理ライブラリっぽく使う

今回データフェッチングライブラリとしてuseSWRを採用しました. useSWRの詳細は他の記事に譲りますが端的に言えばReact向けVercel製のシンプルなライブラリです.
利点はシンプルさに起因したバンドルサイズの小ささと, 実装の手軽さにあります.

機能面ではもう1つの代表格TanStack Queryの方が優れているのですが, そこまで必要としていないためuseSWRを採用しています(TanStack Routerを使っていないのも1つの要因)

useSWR(TanStack Queryも)では, 内部にキャッシュ管理の仕組みが備わっておりキャッシュの有効期限が切れていない限りはネットワークリクエストが発生せず状態を取得できます.

話が少しそれますがReactにおいて状態管理ライブラリとして機能するには何が必要か考えると以下のようになります.

  • 状態をもつ
  • 状態を書き換える
  • 状態が書き変わったことを通知する

useSWRを使うことで, Firestoreに接続されている限りアプリケーション全体でのグローバルな状態管理が可能になります.

  • 状態をもつ⇨Firestoreのデータストア
  • 状態を書き換える⇨revalidate(再検証)やmutate(手動更新)
  • 状態が書き変わったことを通知する⇨useSWRが提供するre-renderの仕組み

この形に移行することで, FluxパターンからuseSWRライブラリ上でシンプルに非同期処理を利用しながら状態(Firestore)の読み書きができるようになりました. これでローカルでの状態管理を捨てることができました.

CQRSパターンの採用

かなりシンプルにはなったのですが, もう少し使い勝手を良くしていきます.
CQRSパターンの登場です.

CQRSパターンについて

CQRSはコマンドクエリ責任分離(Command Query Responsibility Segregation) の略で, データの更新と取得のインターフェースを分離する設計パターンです.


https://learn.microsoft.com/ja-jp/azure/architecture/patterns/cqrs

本来であればバックエンドやデータベースとのIOを最適化されるために用いられる設計パターン(だと思っています)ですが, 今回は更新と取得のインターフェースを分けるという点を活用します.

急に狭い話になりますが, 今回対象としているアプリケーションでは「削除」や「非表示」といった特定のアクションをUI上の様々なところから行えます. また, ユーザーアイコンやカード, リアクションボタンなどのデータ取得のみを必要とするコンポーネントも多いです.

コンポーネントを整理していくとざっくり「取得した結果を表示するだけのコンポーネント」と「更新処理を発火させるだけのコンポーネント」の2つに分けられます.

こうなると非同期処理自体も更新と取得を分けて考えられます. 表示用コンポーネントでは取得のみを更新処理では更新のみを行います.
これに則ると(なんちゃって)CQRSパターンが実現できます.

実装の際はuseSWRuseSWRMutationを組み合わせて表現します.

  • 取得処理:useSWRをラップしたDOごとのクエリhooksを実装
  • 更新処理:useSWRMutationをラップしたDOごとのコマンドhooksを実装

これらのkeyを共通化させることで, 更新時に取得処理側も更新させよりシンプルに実装が可能になります.

掲示板の「投稿」をモデルに図示すると以下のようなイメージです.

実装例を見ていく

以下のような仕様のアプリケーションを想定します.

  • 一般的な掲示板サイト
  • 投稿にはコメントといいねができる

この時掲示板の各投稿表示は以下のように表現できます.

use-query-posts.ts
export const useQueryPost = () => {
    const {data, isLoading} = useSWR(key, () => {
        return await getPosts();
    })

    return {data,isLoading}
})
use-create-post.ts
export const useCreatePost = () => {
    const {trigger, isValidating} = useSWRMutation(key, () => {
        await createPost();
    })

    return {trigger,isValidating}
})
post-list.tsx
export const PostList: React.FC<Props> = () => {
    const {data: posts, isLoading} = useQueryPosts()

    if(isLoading) return <Loading />

    return posts.map(post => <PostItem post={post} />)
}
post-form.tsx
export const PostForm: React.FC<Props> = () => {
    const {trigger(),isValidating} = useCreatePost() 
    return <form onSubmit={() => {trigger()}}>...</form>
}

更新と取得のタイミングを揃える

useSWRを活用し, CQRSパターンに則ると更新が反映しきる前にrevalidateが走り正しく更新されない場合があります. 今回のアプリケーションではFirestoreを利用していたのでonSnapshotを利用し手動でmutateを発火させることでタイミングを揃えました.

最後に

これでローカルでの状態管理や複雑な非同期処理との同期を行わずに, useSWR+CQRSパターンを採用しシンプルなWebアプリケーション構築を行うことができました.

全てのアプリケーションで適用できるものではないにせよ, 同じことを考えている人がいたら聞いてみたいといった気持ちもあり記事化しました. イベント等でお会いした際に一言いただけると嬉しいです.

chot Inc. tech blog

Discussion