😎

Recoil, Jotaiの設計論とDIKWピラミッド

2022/07/11に公開
1

Reactの状態管理ライブラリであるRecoil, Jotaiは宣言的かつシンプルにデータフローグラフを構築するライブラリです。
原始的な機能の集合体であり、直感的に状態管理を実装できる反面、潜在的に壊れやすいコード(Reactの哲学と反するコード)もかけてしまいます。

コンポーネントとAtomが密結合になっている例
https://github.com/koushisa/recoil-dikw/blob/bf62c48863fecf744d36289671d33dec536e9e73/src/Counter/1.Bad/badCounterState.ts#L3-L8

https://github.com/koushisa/recoil-dikw/blob/bf62c48863fecf744d36289671d33dec536e9e73/src/Counter/1.Bad/BadCounter.tsx#L5-L26

Atomを外部に公開してしまうと、途端に状態管理が無秩序となります。
内部データの取り扱いや更新ロジックなどの解釈が利用者へ委ねられるためです。
これによって、状態の不整合が発生しやすくなります。

行儀よく状態を扱うには、DIKWピラミッドの考え方が参考になるかもしれません。

DIKWピラミッド
DIKWピラミッド

DIKWピラミッドは情報をデータ、情報、知識、知恵の4階層構造に分ける考えで、情報に関するものであればどのような分野でも応用の効く考え方です。

データ情報だけでは、文脈がわからないので、正しい活用が難しいですよね。
情報に5W1Hを始めとする文脈を組み合わせることで初めて他者と会話が可能な知識となります。
そして複数の知識を組み合わせて解決するのが知恵です。

分類 開発の文脈 Recoilの文脈
データ 変数(primitive, object含む)
ローカルスコープ
- Atom
- Selector (外部リソースの抽象)
情報 データ構造
ローカルスコープ
- Selector (他のノードへ依存グラフを持つ)
知識 事実(データ+情報)の振る舞い - 外部に公開するSelector (参照 or 更新)
- React Hooks
- Loadable
知恵 知識を組み合わせ問題解決を行なう単一機能 - React Component

リファクタリング

DIKWピラミッドを当てはめてさきほどのコードをリファクタリングしてみます。

https://github.com/koushisa/recoil-dikw/blob/f52fdd2ff07153b6b4cf3fec7649b1a4eaa3fb19/src/Counter/2.Normal/normalCounterState.ts#L4-L16

https://github.com/koushisa/recoil-dikw/blob/f52fdd2ff07153b6b4cf3fec7649b1a4eaa3fb19/src/Counter/2.Normal/normalCounterState.ts#L18-L26

これでコンポーネントは知識の表現に集中できます。
ロジックとビューがそれぞれ独立しており見通しが良いですね。

https://github.com/koushisa/recoil-dikw/blob/bf7f4c9fc6421240069aafe8a297642efa6c41a5/src/Counter/2.Normal/NormalCounter.tsx#L4-L17

モジュール化する

再利用性と凝集度を高めるために関数でモジュール化してみましょう。

モジュールはRTKに倣ってスライス(Slice)とよぶことにします。

https://github.com/koushisa/recoil-dikw/blob/bf7f4c9fc6421240069aafe8a297642efa6c41a5/src/Counter/slices/counter.slice.ts#L13-L45

特筆点としては振る舞いの意図を明確にするために 参照(reader)更新(updater) にインタフェースを分けて公開していることです。
また、keyのデフォルト値をnanoidで生成することで、key管理を気にしなくてよくなるのも地味に嬉しいですね。

スライスは特定の関心のデータとロジックを凝集し、独立させるものです。
表現したいドメインのデータや情報といった事実ではなく、文脈を組み合わせた知識となる関数(振る舞い)のみを公開するとよいでしょう。
振る舞いの元となるデータ構造は内部に閉じているので改修が容易です。

https://github.com/koushisa/recoil-dikw/blob/bf7f4c9fc6421240069aafe8a297642efa6c41a5/src/Counter/slices/counter.slice.ts#L8-L11

これらのカスタムフックは以前と同様に利用できます。

https://github.com/koushisa/recoil-dikw/blob/bf7f4c9fc6421240069aafe8a297642efa6c41a5/src/Counter/3.Slice/SlicedCounter.tsx#L6-L18

このようなスライスがデータフローグラフにおけるモジュールの最小単位と思っていただければ良いかなと思います。

ここまでのまとめ

  1. データ、情報といった事実は受け取り手によって解釈が異なる
    • この微妙な認知の違いがアプリケーションを複雑にする
  2. DIKWピラミッドの概念を参考に状態設計を行なうといい
    • 情報は人間と機械の両観点で扱いやすいように構造化する
    • モジュールは事実知識を区別して公開する
  3. コンポーネントはモジュールを通して知識を得て知恵を表現する
    • これがユーザー体験に繋がり、プロダクトの価値となる

応用編 再利用可能なデータフェッチのスライスを作成する

最近、Recoil-Relayがリリースされて界隈が一時ざわつきましたね
Recoilがこれから推していきたい方向性が垣間見えました。

適切にモジュール化できていればローカルステート以外の全ての状態をデータフローグラフに乗せる世界観はアリだと思います。
これにあやかり、Recoilと統合したデータフェッチのユーティリティを作成してみます。

  • FetchをRecoilでラップする
  • URLなどのパラメータはRecoil-Relayのように利用者がデータフローグラフでインジェクションできるようにする
  • エンドポイントはGET https://jsonplaceholder.typicode.com/posts
    • カウンターの値に応じて同じパスでAPIを叩く
      • 例: カウンターが1のとき、posts/1、2のときはposts/2...
  • コンポーネントは非同期処理をSuspenseをベースとする

成果物は以下となります。
https://github.com/koushisa/recoil-dikw/blob/d1663a05f862d8c9e9f066e32e0d9ec932506f43/src/lib/recoil/fetcherSlice.ts#L32-L35

細かいコードの説明は省略させていただきますが、パラメータをデータフローグラフでアドホックに指定可能で汎用性が高いものとなっています。
React-Queryと同様、useEffectを使わずとも依存データの変化に反応してAPI呼び出しが走る宣言的データフェッチと言えるでしょう。
https://github.com/koushisa/recoil-dikw/blob/d1663a05f862d8c9e9f066e32e0d9ec932506f43/src/Counter/slices/counter.query.slice.ts#L4-L33

SuspenseとLoadableを組み合わせているため、コンポーネントは非同期であることを意識しなくともよいです。

https://github.com/koushisa/recoil-dikw/blob/d1663a05f862d8c9e9f066e32e0d9ec932506f43/src/Counter/4.Fetcher/CounterResponse.tsx#L19-L29

まるでFunctional Reactive Programmingのような感覚ですね。
このようにデータフローグラフで状態の依存関係を繋いでいくことで全てがリアクティブになります。
データフローグラフではこのようなスライスを小さく作っていくことが重要です。

off topic: Writable Selectorについて

使い所が難しいWritable Selectorですが、筆者もいまのところは積極的に使うものではないという見解です。

Writable Selectorはgetとsetのシグネチャが対になっています。利用側のユースケースが1つだけであればよいのですが、更新手段が複数あり、それぞれシグネチャが異なる場合はatomCallbackでReducerのように扱うほうが取り回しが良いと感じています。

Recoil-RelayはWritableSelectorを公開することによりリモートデータをローカルステートかのように抽象化しています

この考え方はユニークなもので、革新的です。
この例から学ぶとすれば、Writable Selectorインピーダンスマッチングの抽象化用途として利用されるのを想定している気がします。
たとえばサーバのレスポンスと表示の構造に大きなミスマッチがあるときなどの腐敗防止層としての役割を作れそうです。

https://github.com/koushisa/recoil-dikw/blob/5e606db4ef2023abd47463f3ff9b7f32f98c4651/src/Counter/5.Shell/CounterShell.tsx#L25-L42

上記のリンクでは腐敗防止層をShellとして定義しています。
利点は以下かなと思います。

  • 単純にスコープを狭く出来る
  • コンポーネントの依存ノードがShellに集約されるのでテストしやすい
  • REST APIでもキャッシュを利用したフラグメントコロケーションのような実装が可能となる
  • AtomEffectsを利用して永続化するなど拡張性も高い

ReactやRecoilのチームは3年先の未来を見据えているので、個人的にはReact Server componentsとRecoilSyncを組み合わせてUniversalなSmart UIパターンを実装する時に特定のコンポーネントを包む役割として出番がきそうな気がします。

off topic: 複雑さはどこからくるのか

フロントエンドにおいては状態と時系列の掛け合わせが複雑さを生んでいます。

時系列で変化する状態を扱うプログラムはただでさえ複雑です。
たとえば非同期処理一つをとっても取得前、取得中、取得後の3パターンがあります。
更にIFの条件分岐が入るとすぐに10種類以上のパターンが発生することになります。これら全てを考慮するのは現実的ではなく、動作を予測することは困難になります。

データフローグラフとSuspenseやはこれらの時間軸を抽象化しています。

データフローグラフでは値は参照時のコンテキストで遅延評価されます。
Suspenseでは非同期処理の取得前、取得中という状態はもはや考慮する必要がなくなり時間軸による差分プログラミングから解放されました。取得後の最新の値のみを考慮すればよいのです。

2つを組み合わせながらSelectorを切り出すことでUnion Distributionや、Structural SubtypingのようなTypeScriptの型を生かしやすいことも利点ですね。

両者を組み合わせると状態を同期的(静的)に扱えるため、パターンマッチングのみで処理を完結することも可能です。これまで処理の制御のために必要だったIF文や時間軸要素を限りなく減らすことで全体をシンプルに保つことが可能となるでしょう。

個人的にはもはやSuspenseは使わない理由はないと言えるレベルの概念です。
また、Recoilは後方互換性(backwards-compatible)というワードを多用しています。
メジャリリースはまだですが、最近のリリースもパフォーマンスチューニングばかりなのでコアな部分は安定しています。そういう意味では破壊的な変更が起こる可能性は低いと思います。

参考

要はOOP

スライスは特定機能の属性(Attribute)と振る舞い(Operation)をまとめたOOPと同じような感覚です。
React Hooksにより可能となった関数型OOP(FRP?)といったところでしょうか。

特定責務をスライスに凝集させることで状態を安全に扱えるようになりました。
それによって開発のアジリティを高まることができます。
コンポーネント階層をリファクタリングする場合にもスライスは影響を受けませんから、状態管理が壊れる心配が減るかと思います。

一方、我々の目的はビューを構築し、優れたユーザー体験を提供することです。
スライスをユーザー体験にどう作用させるかといった意図も重要になります。

コードベースの意図を明確にする

GUI開発はインタラクションと見た目に関するコードが混ざりがちです。
このコードはどこに作用するのか、といった意図や構造が埋もれがちで属人化しやすいですね。
Container/Presenterはここに境界線を作ります。

結局最後はコンポーネント設計という基礎へ戻ってきましたね。

  • Containerは状態とインタラクション、パフォーマンス
  • Presenterはアクセシビリティ、セマンティック、デザイン

カスタムフックで気軽にロジックを分離出来るようになったので不要と言われがちなこのパターンですが、ある程度コードベースが複雑化してきたらContainerとPresenterを分けるという考え方は現代でも有効だと思っています。
最初から分ける必要性はありませんが、このような棲み分けはプロジェクトの規模や組織構造に依存します(コンウェイの法則)ので、その都度チームで合意を取るとよいでしょう。

Presenterとなるコンポーネントのpropsは見た目やイベントを主体に命名しましょう。

type CounterPresenterProps = {
  counter: number;
  multiplied: number;
-  increment: () => void;
+  onClickPlus: () => void;
-  incrementAsync: () => void;
+  onClickPlusAsync: () => void;
};

Containerでカウントのイベントハンドラにインタラクションを追加します。

+  const handleClickPlus = () => {
+    increment();
+
+    console.log("thanks.");
+  };

  return (
    <CounterPresenter
      counter={counter}
      multiplied={multiplied}
-      increment={increment}
-      incrementAsync={incrementAsync}
+      onClickPlus={handleClickPlus}
+      onClickPlusAsync={incrementAsync}
    />

インタラクションと見た目でコンポーネントが分離されているとこのコードはどこに書くべきか、どこに作用するのかといった意図が明確になり、他者がコードリーディングを行う際の助けとなります。

また、状態変化は基本的にインタラクションが契機とすることも重要です。
useEffectで状態変化を行わないようにしましょう。
このメンタルモデルは副作用を扱いやすくしてくれます。

この辺りの説明はReact 18のドキュメントに譲ります。

React 18へ向けて ※本筋と異なる話なので読み飛ばしていただいて大丈夫です

周知のとおりReactは思想が強いです。

現在進行中のReact 18のドキュメントにはその思想の背景やフックのベストプラクティスが詰め込まれています。かなり仕上がって来ているので興味のある方はぜひご一読されるとよいでしょう。

また、Concurrent Modeのセマンティクス(Suspense, Transition)では、末端コンポーネントでも状態を持つ必要があります。それによってコンポーネントツリーの中に埋もれてしまう状態や兄弟間の依存が生ずる場合のスケールが困難となる課題はあるかと思います。

データフローグラフはコンポーネントツリーと状態を切り離して分割統治が可能です。
そういう意味ではReact 18の思想とも相性が良いといえます。
RecoilのTransitionもunstableですが公開されていたり、Recoil Syncの永続化など、今後が期待できますね。

ReactはGUIとインタラクションの抽象化によって良好な(開発者|ユーザー)体験の提供を支えてくれます。

フロントエンジニアの仕事はJSONのつなぎこみだけではなく、ユーザー体験にかかわる全てです。
そのため設計時にはユーザーとUIの間でどんなインタラクションが発生するか、そしてインタラクションを契機にどのような状態変化が必要なのかといった両目線での検討が必要です。

画面の裏側にあるドメインが解決したいことや、向こう側にいるユーザーのことを思い浮かべ、
ドメインエキスパートやデザイナーとコミュニケーションを重ねるのも重要です。
実装してみて初めて分かることもあります。ユーザー体験を引き出せる案があればデザインレベルで考えなおしても良いでしょう。
短期的には手戻りですが、長期的には大きな価値を生み出すものと思います。

デザインとコンポーネント設計は密接ですから、デザインの裏にある意図を理解することでコンポーネントツリーも自然と綺麗になります。
それによりインタラクションをハンドリングすべきコンポーネントも明確となって状態管理の道は自ずと見えてくるはずです。
ここは根気が必要なのでチーム一体で取り組む必要があります。

まとめ

Recoil/Jotaiは自由度と拡張性が高いです。

一方、Reduxのような強い制約はないため、大規模なアプリケーションで通用するかは設計者の力量に依存する部分も大きいです。

自由には責任が伴います。

逆に言えば、「明確な思想を持ち責任を負うことによって自由を手に入れられる」という考え方もできるのかもしれません。

もちろん精度の高い設計を行なうには相応のコストがかかります。
実際の開発現場ではドメインモデルとデザインを行ったり来たりしながらデータを構造化することが一番難しかったりします。

設計よりも事業の流動性が激しいフェーズでは設計する時間が惜しいといったこともあるかもしれません。事業の変化によって必要な知識、知恵が連鎖的に変化しほぼ作り直しとなるのはフロントエンドの定めです。
しかし事実というコアな部分が変わる頻度はそこまで高くないと言えるのではないでしょうか。

最後になりますが、データフローグラフは情報を構造化し、依存関係を構築するものです。
要件に応じたスリムな設計を部分的、段階的に育てていけることが強みであり、グローバルステートとしても、モデリングとしても扱えます。

Selectorをうまく利用すると非同期処理さえも純粋・冪等に数珠つなぎにできます。
クライアント内で変化する状態に対して副作用を含むロジックをコンポーネントから分離しつつも一貫性のあるレンダリングを得られるため、Reactの宣言的なメンタルモデルに沿ったプログラミングを後押しするでしょう。
Recoil, Jotaiは特定要件に特化したアジリティの高いフレームワークを構築するためのライブラリとしても役に立つのではないでしょうか。

事業とのバランスを取りながら一定の品質を保つためにも、事実、情報、知識、知恵に境界線を引き、モジュール間の依存関係を制御する知識の引き出しの数は重要です。
そのような文脈でDIKWピラミッドのような考え方は応用がきくので便利かもしれませんね。

その都度ゼロべースで現状分析をし、

  1. 普遍的な 事実 を捉え
  2. 人間と機械が扱いやすいように 情報 を組み立て
  3. 知識 によって文脈と一貫性を加え
  4. 客観的に伝わる 知恵 へと昇華させる

"変わらないもの"、"変わり続けるもの"の境界線を対話によって紐解いていくことが設計力ではないでしょうか。

Discussion

koushisakoushisa

公開して時間がたったため一部アップデートしました

  • 補足情報とリンクをトップに追加