Open18

RecoilでWrite系処理の設計パターンを考える

ピン留めされたアイテム
koushisakoushisa

もうWrite系処理はZustandでよくない?

以下のスクラップにも書いたようにRead系とWrite系は本質的に関心事は異なっている。
https://zenn.dev/link/comments/d2b4b80841c162

Redux, ZustandのようなFluxインスパイア系なライブラリはGET系のリクエストも更新系で扱う。
これは思想の問題であり、善し悪しで図れるものではないところ。
どちらかというと利用者が受け入れるか受け入れないかの問題。

ただしReact Concurrentのセマンティックと衝突するというのは認識しておいたほうがいいかもしれない。[1]

https://scrapbox.io/fsubal/Redux「GET_リクエストは更新系」

RecoilのSelectorはRead系にもなれるし、Write系にもなれる。
どのように扱うかは利用者に委ねられているため、Recoilのデータ設計においてはSelectorをどの抽象度で扱うかというのがキーとなる。

一度立ち返ってRead系とWrite系の関心事を整理する。

以下で想定しているアプリケーション規模は https://zenn.dev/link/comments/238de1fb4a497d に記載している
そもそもフロントエンドにこのような複雑さを持ち込まないのが理想的という前提はあるが、[2]
止むにやまれない事情はあるため、フロントエンドに閉じた形での考え方として提示しておく。

Read系

  • 処理をUIと近い位置に配置するコロケーションスタイルが適している
  • UIごとに再利用性の低い整形ロジックが必要となる
    • 具体的なロジックは子に置くため分散傾向にある
  • 冪等性が重要
  • 非同期処理がメインなので初期化時の遅延評価が可能な点とSuspenceに対応していると柔軟性が高まる
    • Render As Fetchパターン
  • リクエストがウォーターフォールとならないようにするためにキャッシュ先読みや並列リクエストが可能とする
    • リクエストの詳細は自身で持ちつつも発火は外部から制御することで設計とパフォーマンスを両立させる

Write系

  • UIとは少し離れた位置でロジックを管理する
    • 機能単位のルートでストアを作ったりする
    • Reduxの場合はアプリケーション全体でストアを持つ
      • RTKのSliceであれば擬似的に機能単位でストアを持てる
  • 副作用が主
    • 強整合性が求められる
    • Read系にデータが更新されたことを伝える手段が必要
  • 複雑性が高い
    • サービス固有のドメイン知識が必要
    • 複雑性のしきい値を超えてきたらValue Object, Domain Modelのようなモデリングを駆使するケースがある[3]
      • モデリングのモチベーション
        • 全体の処理フローを追いやすくする
        • テスタビリティの向上
          • 純粋関数の割合を増やす
          • モック可能にする
        • ドメイン抽象
          • Union Distribution, Structural Subtypingによるドメイン抽象
          • 永続化層(フェッチ)、ドメイン層(モデリング)、アプリケーション層(制御)を分離する事によるコードベースの言語化&構造化
Recoilでのドメイン抽象例
 // 予約メニューを選ぶみたいな想定
 
 ---
 // 永続化層
 
 type MenusEntity = {
   value: Menu[]
   hasMenu: boolean
   hasMultiple: boolean
   hasContents: boolean
   head: Menu | undefined
   defaultMenu: Menu | undefined
 }
 
  // APIレスポンスを基にエンティティを作成する純粋関数。これはテストする。
  const createMenusEntity = (menus: Menu[]): MenusEntity => {
    /*~*/
  }
 
 // データフェッチを行ってエンティティを返す
 // Repositoryパターンと同義
 export const menusEntity = selector<MenusEntity>({
   key: 'menusEntity',
   get:
     async ({ get }) =>
       const menus = await fetchMenus()
       return createMenusEntity(menus)
     },
 })
 
 ---
 
 // ドメイン層
 // サービス固有のドメイン層
 // エンティティの状態<->ドメインの状態をマッピングする
 // ユビキタス言語が使えたら◎だが、言語補完的な意味合いも強い
 // エンティティの状態を特定の課題解決のために抽象化したもので、これがアプリの状態制御の核となる
 // 例ではタグで区別できるようにした
 // タグにより後述のアプリケーション層でドメインモデルの状態によるポリモーフィックを実現できる
 
 type MenusDomainModel =
   | {
       type: 'blank'
     }
   | {
       type: 'single'
       menu: Menu
     }
   | {
       type: 'multiple'
       menus: Menu[]
     }
   | {
       type: 'skippable'
       defautMenu: Menu
     }
 
 // エンティティを受け取り、ドメインモデルを返す純粋関数。これはテストする。
 const createMenusDomainModel = (menusEntity: MenusEntity): MenusDomainModel => {
    const {
     value: menus,
     head,
     hasMenu,
     hasMultiple,
     hasContents,
     defaultMenu,
    } = menusEntity
    
    if (!hasMenu && !hasContents) {
      return {
        type: 'blank',
      }
    }
 
     if (defaultMenu !== undefined) {
       return {
         type: 'skippable',
         defaultMenu,
       }
     }
 
    if (!hasMultiple) {
      return {
        type: 'single',
        menu: head,
      }
    }
 
    return {
      type: 'multiple',
      menus,
   }
 }
 
 // 永続化層のエンティティがSelectorで抽象化されていることでキャッシュが効く
 // エンティティのユースケースごとに複数のドメイン層を派生させることも簡単
 // 規模によっては`menusEntity`, `createMenusEntity`と統合してしまっても良い
 export const menusDomainModel = selector<MenusDomainModel>({
   key: 'menusDomainModel',
   get({ get }) {
     return createMenusDomainModel(get(menusEntity))
   },
 })
 
 ---
 // コンポーネント

 // 該当コンポーネント専用のPresentationModel
 const presentationModel = selector({
  key: "menusPresentationModel",
  get: ({get}) => {
    // ドメインモデル
    const menus = get(menusDomainModel)
    // ビジネスロジック    
    return someBusinessLogic(menus)
  }
 })

 // ドメインモデルを受け取り、特定のユースケースを達成するビジネスロジック
 // ユースケースに依存するのでViewの関心も含んでよい(むしろViewと同じファイルに配置するのがオススメ)
 // I/Oが分離されてるのでビジネスロジックを純粋関数化しやすい、テスタビリティが高い
 const someBusinessLogic = (menus: MenusDomainModel) => {
   switch(menus.type) {
    /*~*/
   }
   return /*~*/
 }
 
  
 const Menus = () => {
   // 対象のデータ取得とビジネスロジックをコンポーネントから分離できた
   const menus = useRecoilValue(presentationModel)
   
   return <>/*~*/</>
 }

Recoilはどうなのか

これを踏まえてみたときに、RecoilはRead系への解決策を示している。

  • データフローグラフ
  • 遅延評価
  • Suspence
  • Loadable
  • Concurrent

上記のドメイン抽象のキモは永続化層とアプリケーション層が切り離されていることで、それによってアプリの制御構造と依存関係が分離される。

宣言的プログラミングにおいてはリソースの依存関係こそがドメインの本質であり、オーバーフェッチを気にすることなく永続化層(menusEntity)から派生するSelectorをいくらでも作ることができる。
また、データフローグラフでは参照時に依存ノードが全て同期的に評価されるため、フェッチが終わっていないことを気にする必要もない。

つまりユースケースごとに時と場所を選ばずドメイン抽象が可能なのでスケールするということ。
一般的なアプリであればこれだけでも十分に価値がある。

また、Loadableによってエラーハンドリングをコンポーネント側まで遅延させられるので全体的なロジックをピュアに保つことができる。

ドメイン層(menusDomainModel)が独立しているのでRead系、Write系でも実装を使い回すことができる点も魅力的。

  • Read系ではコンポーネントのローカルスコープでselectorを作って整形処理を挟むことができる
  • Write系ではAction内のドメインロジックをselectorに移譲してロジックをシンプルに保てる
    • 本質的なロジックの見通しがよくなる
    • Actionが肥大化することを防ぐ

このような抽象化はFluxインスパイア系の状態管理では難しく、ここ数年Recoilを使っていて感じたパラダイムシフトだと考えている。

Read系とWrite系の技術スタックを分けるという考え方

少し話が広がるがCQRSでアーキテクチャを組む場合、Read系にWrite系で技術スタックを分けるといった考え方があるらしい。

これは上述のように関心が異なり、Read系は柔軟さ、Write系は厳密さが求められるからだと思う。[4]

この考え方を借りれば

  • Read系: Recoil(Jotai)
  • Write系: Zustand

という考え方もあながち間違っていないのかもしれない。
RecoilとZustandをつなぐやり方を考えてみる。

脚注
  1. 認識していないだけで対応されているのかもしれない。でもActionを発火することで非同期処理が走ってその結果ストアが更新されるというフローは変わらないんじゃないかな。 ↩︎

  2. 全てフロントエンドでやろうとせず、一歩引いて視野を広げてみるとどこがボトルネックになっているかが見えてきて全体最適化の視点での本質的な解決策が見つかるかもしれない。 ↩︎

  3. 経験則として、フロントエンドのVO, DOが必要になってくるのは100画面ある内の割合でいうとサービス固有の強いドメインを持つ1~3画面程度。予約、お金周りなどミッションクリティカルかつ多種多様なリソースが集約された画面のようなイメージ。 ↩︎

  4. Read系とWrite系のデータソースが切り離されることでパフォーマンス観点でのスケールアウトの容易性や責務のスリム化が得られる。連携はEvent Sourcingによるメッセージパッシングによって疎結合に行われる。 ↩︎

koushisakoushisa

https://zenn.dev/koushisa/articles/1d5272454576e1#モジュール化する

上記を書きながらRecoilのWrite系処理の設計はどうあればよいかと思ったのを考える

似た話がGitHubのissueに残っていたが、Recoilはあくまでもシンプルさを重視することから公式としてAction/Reducerパターンを入れることは考えていない様子
https://github.com/facebookexperimental/Recoil/issues/571

「自分たちの要件に合わせて抽象化してくれ、そのために合成しやすいシンプルな基盤用意しとくから」的なRecoilの思想を感じた。

なので、自分の文脈でほしくなった設計パターンを考える。

koushisakoushisa
  • やりたいこと
    • atomの更新ロジックを列挙できる
    • atomを非同期に更新できる
    • Recoilのロジック層とコンポーネントのビュー層を分離できる
    • 全て型がつく
    • 薄くつくる
koushisakoushisa

counter: numberのようなプリミティブな値であればこれで十分だということがわかった。

ただし、リアルワールドにおいてはオブジェクトにしたいこともあるし、オブジェクトの特定プロパティの変更だけを監視できることも求められる。

代表的なものとして、Reduxはメモ化を駆使してオブジェクトのデータでもパフォーマンスを損なわずに扱えるようになっている。

次はatomをオブジェクトにして更新と参照を分離できないかを考える。

koushisakoushisa

単純に更新と参照を分離したいだけならuseSyncExternalStoreでもいい。
Recoilの強みは

  • データフローグラフ
    • データの宣言、ソース、ロジックの分離
    • キャッシュ管理をプレゼンテーション層から分離する
    • 遅延評価可能
  • Suspenceで非同期処理を同期的に扱えること。

この特性を活かしつつ、以下の要件で考えることにする

  • 初期値を非同期 & 宣言的にセットできるようにする
    • データフローグラフの仕組みにのっかって参照時に初期化される & Suspendする
  • 厳密な純粋性にはこだわらない、更新が分離されて関数でできればヨシとする
    • 更新は非同期可能
  • 参照はプロパティ単位で監視可能
  • コールバックの中で現在値を取得可能
疑似コード atomWithAction
const {
  // フックで更新、参照を提供する
  state: [fooState, useFoo, useFooActions],
  FooProvider,
} = atomWithAction({
  // 初期値
  initialState({ get }) {
    return {
      // 他のデータフローグラフから非同期に初期化出来る
      bar: get(barState),
      baz: 'baz',
      count: 0,
    }
  },

  // アクション
  // (cb: RecoilCallbackInterface) => (arg: ${Arg}) => ${Return}
  actions: {
    reset: (cb) => () => {
      cb.reset(fooState)
    },

    incrementAsync:
      ({ set }) =>
      async () => {
        await sleep(1000)

        set(fooState('counter'), (prev) => prev + 1)
      },

    updateCount:
      ({ set }) =>
      // ValOrUpdater<number>は ((prevState) => newState) | newState
      (valOrUpdater: ValOrUpdater<number>) => {
        set(fooState('counter'), valOrUpdater)
      },
  },
})

const FooWrapper = () => {
  return (
    // アンマウント時に状態をリセットする
    <FooProvider>
      <Component />
    </FooProvider>
  )
}

const Component = () => {
  const { getValues } = useFoo()
  const actions = useFooActions()

  const handleReset = () => {
    const currentCount = getValues('count')

    alert(`現在のcountの値: ${currentCount}`)

    actions.reset()
  }

  return (
    <>
      <button onClick={handleReset}>reset</button>
      <br />
      <Counter />
    </>
  )
}

const Counter = () => {
  const { watch } = useFoo()
  const actions = useForActions()

  // countの値の変更を監視
  const watchedCount = watch('count')

  return (
    <>
      <p>{watchedCount}</p>
      <button onClick={() => actions.updateCount(3)}>updateCount</button>
    </>
  )
}

命名は暫定でReact Hook Formからひっぱってきたがフォームとしてのユースケースは考えない(バリデーションなどが入ると複雑化するので)

上述の擬似コードはまだしっくりきていないので場合によってはこれRecoilじゃなくてもよくない?といった結論もあり得るな。
この辺は作りながら考える。

ReactのステートとRecoilのステートの重複管理は避けたいので基本的にデータの実態はRecoilに置く前提で、今回はスコープを絞ってオブジェクトのatomをいい感じにReducerっぽく扱うことを目標にする。
atomをオブジェクトで持てるとロジックが凝集されるので管理しやすいはず。

koushisakoushisa

先駆者であるReduxが流行ったのには何か理由があるはず。

設計上のReduxの利点を振り返る。
そこから逆算的に必要なもの、足りないものを把握して道筋を整えていく。

https://speakerdeck.com/takefumiyoshii/redux-falseli-dian-wozhen-rifan-ru?slide=32

  • ActionとReducerが多対多になっている
  • DispatcherがActionを抽象化してコンポーネントとロジックを疎結合にする
  • Actionを購読するReducerは関心事に分割することで高凝集に出来る
    • 実質action.typeのswitchを行うだけの関数なのでいくらでも分割出来る
    • Reducerがステートマシンの役割になっている
    • ReducerはActorとも言えるか
  • Dispatch→Action→Reducerの流れと役割を厳しい制約で縛ることで副作用を起こす層が限定的になること
    • 片手間フロントエンドの人でもデータフローを1から勉強する必要がない
      • ので設計として破綻しづらい
    • ここには反対意見もある
      • 学習コストがかかる
      • コード量が多くて直感的ではない

イベント駆動なデータフローとなっているため事実上いくらでもスケールするというのはReduxならではの利点。

ただ、最近はDispatchとActionをイベントで抽象化することのメリットを考えた時に、疎結合であることのメリットよりも保守のしづらさ、というデメリットが上回る場面も多いなとも思う。

イベント駆動には懐疑的 ※主張が激しい

設計のアプローチ

前提として設計のアプローチはMIT/Stanford vs New Jersey[1]でスタンスが異なるが

自分はNew Jersey, Worse is Betterの考えが強い。設計の正しさよりも目的に沿った流動的な設計を好んでいる。

これは、そもそもフロントエンドにまつわる変化が内部(プログラム)よりも外界(UI/UX)のほうが早いという環境上の理由と、伴って抽象化の難易度も高いという理由の2つからきている。

デザインのリブランディングや構造の変化で状態(State)の持ち方は当然代わる。
Reactの原則は UI = Component(State) であり、Stateの持ち方が変わればコンポーネントも多いに影響する。
そういう場合で今ある設計を壊さずに作るよりも、コンテキストを1から理解しなおして作り直すほうがコストが低く、経験上うまくいく場合が多い。[2]

特にデザインはユーザー体験もとい事業戦略とも深く関わるので優先度、変更頻度ともに上位に位置する。
時間をかけてきれいな設計をしてもデザイン変更でちゃぶ台返しなんてザラだ。
そのような環境で育ってきたため自然とWorse is betterな価値観が形成されているというバイアスはある。

きれいな設計は複雑さに境界線を作り、一定の制約と秩序を作る。
ただしその裏側には暗黙的なルールが生じており、他人へ学習コストをもたらす。

生成が隠蔽されていれば、どこで生成されてスコープがどうなっているのかを理解する必要がある。
データが隠蔽されていれば、どこでデータが変化して誰が参照するのかを理解する必要がある。

設計と学習コストは強く結びついており、設計者の意図を他者100%理解するのは不可能だろう。[3]

そしてイベント駆動の学習コストは非常に高く、いつどこで発行されるか分からないイベントによって状態管理を行うのは中々に難しい。

設計を言語化するということ

暗黙知を避けるために設計を言語化することが求められるのだが、そもそも設計は知識と経験がものをいう領域なので、それらを全て言語化することは非常にコストがかかる作業でもある。

そんなところに時間を掛けるよりもガンガン手を動かして手数を増やしながら事業(デザイン)の妥当性を仮説検証することがフロントエンドエンジニアに求められる、といったことは多いのではなかろうか。

そういう中では複雑できれいな実装よりも、ある程度は妥協して、無駄がないシンプルな実装へのモチベーションが高まってくる。
無駄がないシンプル = 捨てやすいという風に自分は読み替えていて、捨てやすいを意識していると自然と影響範囲が閉じてDRYやSOLIDの原則に則っているといった感じ。

その文脈でイベント駆動という特に言語化の難しい設計手法が自分の要求を満たすものかというと微妙なところである。

イベント駆動(Flux)は悪なのか

なぜここで唐突にFluxが出てきたかというと、Fluxはイベント駆動な設計となっているからだ。
(ここについて詳細は控えておく)

イベント駆動を否定するわけではない。
モノよりもコトに着目して、ユーザーのフローとプログラムの実装を一致させる理想系があるということはよくわかるし、実際に便利な場面はある。
非同期処理の親和性も高いし、イベントに反応して各モジュールに閉じた形で宣言的に書けるというメリットもある。副作用も分離しやすい。

けれどもFluxの登場期と比較すると今は技術の進化によって代替可能となってきている

  • 非同期処理をasync awaitでだいぶ楽になった
  • 分散型の設計による疎結合のメリットはRecoil, Jotaiのようなデータフローグラフでも享受できる
  • 複雑な処理がライブラリ(React Query, React Hook Form)によって隠蔽されてきている。

その結果、フロントエンドにおいてイベント駆動で疎結合にしたかった処理がもっと自然に表現できるようになった、という見解。
これが脱Reduxの流れを生み出したのかなと推測している。[4]

それを踏まえると自身の観測範囲ではdispatchによる冗長さのメリットよりも認知コストが上回っていると感じるケースが多い。

イベント駆動は可読性を対価に疎結合を得られるが、結局フロントエンドはデザインと密結合なのでデザインの変化によってデータ構造やコンポーネントも変わる。

そこで疎結合の対価が牙を向いてくる。
ActionとReducerが多対多であるがゆえにどこでActionが発生して、その結果どのReducerが連動するのかというのを追う難易度は高い。
設計側もなるべくActionを集約させてuseEffectピタゴラスイッチのような事態にならないように意識せねばならない。
Actionを集約させるにはデザインの意図や、本当に集約させて良いものかのドメイン知識も必要。

それほどイベント駆動の設計は練度が求められるというのと副作用や状態遷移の設計はそもそも難しい。[5]

イベント駆動が必要になるデザイン

そもそもイベント駆動が必要になるデザインについても考え直す余地がある。
OOUIの考え方を借りれば、1. 処理対象を選ぶ→2. 対象への操作という風にするとイベントに反応するモジュールは基本的に少数となる。
タスクベースではなく、オブジェクトベースという風にドメインをモデリングした上で設計をシンプルに倒せば多対多の関係性はそう多くない(と思っている、主観的だが)

まとめ

要するに、前提として、

  • フロントエンドに設計のきれいさを求めてもデザインと密結合なのは変わらない
    • デザインの変化によってフロントエンドの設計への影響は大きい
  • きれいさを求めるべきはドメインエキスパート、デザイン、フロントエンドの共通言語となるドメインモデル
  • 多対多の関係性は作らないに越したことはない
    • ドメインとしても複雑なので、そもそも在るべき形を要検討する
      • 多対多のタスクベースなデザインになっているとしてそれが本質的かを事前検討する
    • 複雑なドメインは可能限りデザインでシンプルに落とし込む
      • シンプルなデザインはパターン化されており、フロントエンドの設計もシンプルになる
  • イベント駆動を採用する前に、もっと上のレイヤにコストをかけるべきでは
    • イベント駆動を採用する前にデザインレベルでドメインの理解とオブジェクトの関連性を整理しておく。
    • ドメインエキスパートとデザイナーを巻き込んで共通言語で言語する

という経緯で、フロントエンドの複雑さの解決策としてイベント駆動設計は本質的なのかは懐疑的。[6]

設計の正しさを追い求めることよりもドメインとデザイン、ユーザーを理解してチーム一体となっつユーザー体験に関して議論していくことにコストをかけるべき。
特にデザイナーがエンジニアリング的な要素(状態など)まで考慮してくれるのは稀なのでそこはフロントエンドエンジニアが架け橋となってあげるのが理想的。

フロントエンドの状態管理に疎結合を求めても得られるコストに対するメリットが薄いのでHooksから更新の関数を返すようにしておき、発火はコンポーネントのイベントハンドラにベタ書きする形でもロジックの分離には十分なんじゃ?という結論に至っている。

全てはトレードオフで、利点の裏には必ず制約とデメリットが出てきてしまうので両目線で考えるといいだろうか。

// Redux
const dispatch = useDispatch()
const handleChange = () => {
  dispatch({
    type: "HOGE_CLICKED",
    payload: {
      foo: "bar"
    }
  })
}

// RTK
const dispatch = useDispatch()
const handleChange = () => {
  dispatch(updateHoge({ foo: "bar" }))
}

// Hooks
const updateHoge = useUpdateHoge()
const handleChange = () => {
  updateHoge({ foo: "bar" }))
}
脚注
  1. http://chasen.org/~daiti-m/text/worse-is-better-ja.html , https://speakerdeck.com/twada/worse-is-better-understanding-the-spiral-of-technologies-2019-edition?slide=30 , https://engineers.ntt.com/entry/2022/07/06/084307 ↩︎

  2. プログラムは書くよりも読む難易度のほうが高い ↩︎

  3. そして8割のエンジニアは目の前の問題を解決できればそれでよく、きれいな設計などに興味はない ↩︎

  4. 昔だれかがFacebookはReduxは使っていないがFluxは使っているという言葉を耳にした記憶がある。 ↩︎

  5. 以前にイベント駆動で設計したときはコストが高いのと属人化しちゃうなと思ってヤメた経験がある ↩︎

  6. バックエンドのCQRSやマイクサービスみたいなのは"必要に迫られたとき"の優れたパターンだと解釈している。ただし最初から検討するものではないのも周知の事実。 ↩︎

koushisakoushisa

多分、求めているのはイベント駆動じゃなくてUIと副作用を伴うロジックの抽象化な感じはする。

↓これじゃなくて

// hooksの命名をCRUDのような技術的な関心毎を元にしてしまうとスケールしづらい
// スケールにあわせuseUpdateHogeの中にFoo, Fugaなどのアクターが増えたときに名前と処理が乖離してくる
const updateHoge = useUpdateHoge()

return (
  /*~*/
  <button type="button" onClick={updateHoge}
  /*~*/
)

↓これ

// hooksの名前をUIから見た時の関心(イベント)にする
// コンポーネントは具体的な処理をしらない
// イベントに起因するロジックが隠蔽される
// 進化しても乖離しづらい
const onHoge = useHogeEvent()

return (
  /*~*/
  <button type="button" onClick={onHoge}
  /*~*/
)

アクターの多い複雑GUIに関しては、副作用の関係性が最初は1:1でも、スケールするにあたってN:Nとなる場面もある。
CRUDのような技術的な関心毎で管理すると、その目的の説明がされていないので、責務が不明瞭となったり、単なるCRUDの中に重要なロジックが入って肥大化したり、命名と処理が乖離しやすくなる。

こういう場合はUIのイベントに注目して状態管理の設計を行うとよさそう。

というかjQuery時代はそうだった。
けれどもあの時代はコンポーネント指向ではなくて、イベントとロジックの結びつきが本当によくわからなかった。
今はコンポーネント指向とhooksによってイベントとロジックが近い位置で管理しやすいし、型も効く。
上述のuseHogeEventの中身はProcessManager的に各アクターへ副作用を起こす必要があることを伝えるだけ、という程度(フロー制御だけに関心を持つ)薄くつくっておけばいい。
これでhooksベースでもイベント駆動は出来る。

// イベントの発火はコンポーネントと密結合
// あえて同一ファイル内にセットで同居させて手続き的に書いたほうが追いやすそう
const useHogeEvent() => {
  const callback = (event) => {
    // eventを元にアクターへ副作用を伝える。
    // この例だと、formState, someStateがアクター
    // アクター自体はデータ構造とロジックを凝集しているオブジェクトになっていればいい
    // ここだけ見るとReduxのreducerっぽい
    // しかし実際はzustandのstoreのような機能ごとのsliceを想定している
    // 便宜上updaterと呼ぶことにする

    formState.updateByHoge(event)
    someState.updateByHoge(event)
  }

  return callback
}

const Hoge = () => {
  const onHoge = useHogeEvent()
  /*~*/
}

副作用を扱う際の注意点として、不要な結合を避けるためにupdaterの戻り値は基本的にPromise<void>を返すように設計すべきということ。
ただ、updaterの実行順が重要なケースはまああるかも。先に実行されたupdaterによって変化した後のstateを参照したいとか。
あとはエラーハンドリングでResult型みたいなのも欲しくなるかもしれない。
あまり考えてすぎて複雑にしたくはないが、そのあたりは追々考えていく。(ただしsagaパターンは好きではない...)

時代は変われど本質は変わらないのでUIが起こすイベントは何かという理解は今も大事そう。

koushisakoushisa

また、Reduxは非同期処理に弱いという欠点がある。
状態遷移の純粋性が重要なので非同期処理はmiddlewareを介する必要がある。

ここが設計上での悩みどころで、ActionやReducerに本来凝集されるべき処理が分散してしまったりなど扱いが難しい。コンポーネントと離れた位置でロジックを管理するため、1つ改修に対してファイルを跨いで複数箇所を治す必要があったりなど、影響範囲が広くならないように気をつける必要がある。
型アノテーションが頻出するのも地味に大変。

RTK Queryもあるが、なんというかtoo much感とDSLが強すぎてというか1年後見た時に「どうやって動いてるのかわからん」みたいな事態になりそうでイマイチ手を出しづらい。
というのとServerStateはアプリケーション全体で共有したいわけではなくて特定機能(またコンポーネント)のスコープで管理したい。SSoTのReduxとは相性が悪い気もする。
食わず嫌いはよくないので後から試す。
OpenApiからデータフェッチのhooksを生成できるのは便利ではある、でもReduxじゃなくてもよくない?

本チケットはRecoilでReducerを作るのがゴールだが、その先の最終的な目的地は、

  • 捨てやすく、スケーラブルなデータフロー
  • Reduxから学び、スケールさせるべき箇所とそうでない箇所の見極めを付ける
  • 自分達のドメインに適したアーキテクチャを作らねばならないときの勘所を養う
koushisakoushisa

話がReduxに脱線してしまったがここまでのまとめ

本チケットのゴール

  • Recoilでオブジェクトのデータに対する更新と参照が分離されたReducerっぽいパターンを考える
  • 厳密な純粋性にはこだわらず、Actionで非同期処理/状態遷移を行えるようにする
    • Action/Reducerで分離は可能だが、強制はしない [1][2]
  • イベント駆動にはしない
  • 試しに実装する
    • 画面要件は後段で考える

チケットクローズ後に考えていきたいこと

  • イベント駆動が適した要件の整理
  • スケールを前提としたときの勘所
  • 処理フローに制約をもたせる
    • 非同期処理を扱う箇所
    • ステートを更新する箇所
    • テストする箇所
脚注
  1. 一般的なアプリでは状態遷移を分離しなくとも必要十分 ↩︎

  2. テスタビリティや管理上の問題などによってその都度柔軟に考えられる設計が重要 ↩︎

koushisakoushisa

やっぱRecoilじゃなくてもよくない感が強いな。
ReduxをシンプルにしたいだけならZustandで十分な気がする。

https://github.com/pmndrs/zustand

zustandの概要

A small, fast and scalable bearbones state-management solution using simplified flux principles. Has a comfy api based on hooks, isn't boilerplatey or opinionated.

Don't disregard it because it's cute. It has quite the claws, lots of time was spent to deal with common pitfalls, like the dreaded zombie child problem, react concurrency, and context loss between mixed renderers. It may be the one state-manager in the React space that gets all of these right.

deepl和訳

単純化されたフラックスの原理を用いた、小さく、速く、スケーラブルなbearbonesステートマネジメントソリューションです。フックに基づいた快適なAPIを持ち、boilerplateyでもなく、意見も言いません。

かわいいからと言って無視してはいけません。ゾンビチャイルド問題、reactの並行処理、混合レンダラー間のコンテキストロスなど、よくある落とし穴に対処するために多くの時間が費やされています。React空間において、これらすべてを正しく理解できる唯一のステートマネージャかもしれない。

小さく、速く、スケーラブル
フックに基づいた快適なAPIを持ち、boilerplateyでもなく、意見も言いません。
ゾンビチャイルド問題、reactの並行処理、混合レンダラー間のコンテキストロスなど、よくある落とし穴に対処するために多くの時間が費やされています。

ドキュメント見た感じすでに自分がWrite系処理で欲しかった物が全部入ってる。(middleware系とか)
それなのにバンドルサイズ1.16kb??神か。

もうWrite系処理はZustandでよくない?