Concurrent, Suspenseの文脈でキャッシュ管理と理想的なデータフェッチを考える (Recoil, Aspida, React Query, GraphQL)
上記を書きながらRecoilにデータフェッチロジックを寄せることに可能性を感じたので考える。
正確には去年からぼんやりと考えていたのだが、プロダクションでRecoilとAspidaを運用しているうちに主にキャッシュ面で辛いところと、具体的にこうするとよさそうという設計案がいくつかでてきた。
思考整理を兼ねてRecoilでのデータフェッチを深堀りする。
論点整理が長いので、結論だけ見たい人は下記のリンクから具体的なコードが見られる。
データフェッチで考えられるシナリオ
- パラメータ無しのGET
- パラメータ有りのGET
- キャッシュ保持
- キャッシュのinvalidate
- キャッシュしたデータに対するモデリング
- プリフェッチ(+並行リクエスト)
- SSR対応
- APIコールを行わずに初期値を渡せる
最大要件とインタフェースはReact Queryを参考にする
すごいオプションあるけど実際に作ってて欲しくなるのは以下
- onStart
- onEnd
- onSuccess
- onError
- refetchOnMount( or
staleTime
)
また、ユーザー体験の観点で以下の要件を満たせると良い
- 楽観的UI
- Suspendせずにリフェッチ可能 (画面のチラつき防止)
こうして考えたときにReact Queryはほぼ全ての要求を満たしていてさすがだと思う。
キャッシュも効くのでReduxのサーバーステートをReact Queryに移行する人たちがいるのも理にかなっている。
アプリの規模によってはサーバーステートは全てReact Queryに寄せても問題なさそう。
でもReact Queryだけでは辛い場面もある。
Recoil(atomic state management)利用するモチベーションを整理する。
コンテキスト
大前提として、ライブラリは規模や目的にそって選定すべきなので、コンテキストを明確にしておく。
いわゆる、複雑なフロントエンドだと仮定しよう。
- SaaSのような事業モデルで開発コストが重視され、全体として開発効率や品質面への意識が高い
- ソフトウェアの息が長い
- 顧客の声を聞きながら継続的な改善や運用保守が必要
- 管理画面のSPA (SoR > SoE)
- 更新頻度が高くリアルタイムな整合性が求められる
- キャッシュを使うときとそうでないときの戦略を都度切り替えられる
- マスタデータを元に動く画面がある
- ユーザーのインタラクションによってN個のリソースをフェッチするようなケースが多め
- ReadモデルとWriteモデルが一致しない
- 状態の変化を素早く伝える必要がある
- データ量が多く、パフォーマンスが求められる
- 更新頻度が高くリアルタイムな整合性が求められる
- グローバルステートが存在する (ログインユーザー, マルチテナント ...etc)
- APIと画面が1:1ではない
- 画面を表示するために複数のAPIコールが発生する
- ウォーターフォールの注意が必要
- 複雑な画面ではJSON自体もネストが深い
- コンポーネントツリーも引きづられて深くなる
- フロントエンドにもある程度のビジネスロジックが求められる
- クライアントのみの状態(フォーム, URL)からN個のAPIを集約してモデリングするなど
- 権限によって画面切り替えるとか
- チーム開発 (開発と改善チームに分かれて3~10人程度で並行開発する)
- 相互レビュー, タスク分担を容易にするために構造に一定の秩序を求める
- ライブラリや設計の意思決定は客観的かつ論理的に判断される必要がある
React Queryの悩み
設計の文脈ではReact Queryだとスケール性が悩ましい。
- コンポーネントとサーバーステートが密結合になる
- キャッシュキーの管理
- 一応メンテナが推奨している方法はある
- https://tkdodo.eu/blog/leveraging-the-query-function-context#object-query-keys
- エンドポイント毎にカスタムフック作るの...?
- 全体整合性を取るためにルールを決めてレイヤードアーキテクチャっぽくせざるを得ない
- これを一つずつ手で運用するのはちょっと大変そう
- 一応メンテナが推奨している方法はある
- そもそもhooksはロジックの分離には適していてもデータ構造をモデリングする用途には向いていない
- クライアント状態を合成するところが肥大化しがち
- カスタムフックがカスタムフックに依存していく
- 経験のあるフロントエンドの専任者がいないと設計が破綻していく
よくあるカスタムフック肥大化の例
// component.ts // コンポーネントはカスタムフックによって一見クリーンに見えるが... const Component = () => { const [selectedDate, someData, actions] = useSomeData() return <>/*~*/</> } --- // useSomeData.ts // ロジックを抽象化したカスタムフック。これが肥大化していく // キャッシュキー const Keys = { data: "data" } // フェッチャー // OpenApiとかAspidaとか const fetchData = (payload) => { return fetch(payload) } // カスタムフックの本体 const useSomeData = () => { // 画面上でカレンダーから日付選ぶみたいなの想定 const [selectedDate,setSelectedDate] = useState<string>() // データフェッチに利用する引数を組み立てる (下部のカスタムフック) const payload = useSomePayload(selectedDate) // React Queryでデータフェッチ // フェッチャーはパフォーマンス改善のためにメモ化しておく (可読性が低いので個人的に苦手だが...) const data = useQuery(Keys.data, useMemo(() => fetchData(payload), [payload])) // ビジネスロジックとかモデリングとか const domainModel = useMemo(()=> { return someBusinessLogic(data) }, [data]) // コンポーネントに返すコールバック const actions = useCallback(() => { return { updateDate(date:string){ setSelectedDate(date) }, findDataById(id:number){ return domainModel.find(data => data.id === id) } /* コンポーネントのユースケースによって配列操作などのコールバックも増えていく 規模によってはここのコールバックやReact Queryのmutations、キャッシュ破棄系もカスタムフック化される。 そうなると手に追えない。 管理方法もきちんとチームで明文化して運用する必要があり、メンテナンスコストが増大する。 データをhooksに寄せているためコールバックもhooksの中に混ざる。 状態が更に増えるとパフォーマンス問題も発生する。 再レンダリングを抑えるためのメモ化も辛くなってくる。 コードベースが肥大化していく。 これがhooksベースで設計する時の辛いところ。もっとシンプルにできないか。 */ }, },[date, domainModel]) // 利用側には日付、ドメインモデル、コールバックを返す return [selectedDate, domainModel, actions] as const } // クライアント状態を合成する const useSomePayload = (selectedDate: string) => // 他のサーバーステートとの突き合わせ (カレンダーからの候補日を引いてくるみたいな) const candidateDates = useCandidateDatesFromSelectedDate(selectedDate) // フォームステート const { form: { getValues }, } = useParentFormContext() // URLステート const { id } = useRouter().query // グローバルステート const user = useCurrentUser() const payload = { productId: id, userId: user.id, at: candidateDates ? getValues('at') : undefined, /*~*/ } return payload }
React Query運用の参考記事
上記の肥大化したカスタムフックを高凝集、かつ小さくまとめるためにはモデリングレイヤが必要。
そこでRecoilの出番となる。
以前書いたコードを参考に
Recoilにデータを寄せれば色々と分離できて捗りそう。
ただキャッシュ系は完璧に隠蔽してしまうとエスケープハッチがなくなるのでオプションで制御できるようにしたい
利用側だけ見るとこんなかんじ?
// Recoilのstateとカスタムフックをtuple返す的な
const [[useTodos],[useTodosMutation]] = atomWithFetcher(fetchTodos)
const Component = () => {
// RecoilLoadableでローディング系は抽象化する
const todos = useTodos().getValue()
const {
// リフェッチ
refetch,
// プリフェッチ
prefetch,
// パラメータを指定してリフェッチ
reload
} = useTodosMutation()
return (
/*~*/
)
}
リロードのイメージ
// fetcher
type FetchTodosParams = {
order: "asc" | "desc"
name: string
}
const fetchTodos =(FetchTodosParams) => {
return fetch(url,{ body})
}
const [[,useTodos], [useTodosMutation]] = atomWithFetcher(fetchTodos)
// component
const Component = () => {
const todos = useTodos().getValue()
const {
refetch,
prefetch,
reload
} = useTodosMutation()
// reloadを呼ぶと指定したパラメータで再実行する
const toOrderAsc = () => {
reload({
order: "asc"
})
}
return (
/*~*/
)
}
Read系とWrite系を分離して提供するというのがポイント。
これらは似ているようで本質的な関心毎は異なる。
Read系はクライアント都合で仕様変更が行われやすく、再利用性の低いロジックが多い。
抽象化や共通化を深く考えなくてもよくて捨てやすく、影響範囲が閉じていることが重要。ドメイン貧血症になってもいい。
Write系の特徴は大まかにReadの逆で整合性を担保するためにドメイン要件の抽象化が重要で、ロジックも多い。
このイベントハンドラによりどのようなWrite処理が走り、影響を受けるRead系はどこか、といったところを綿密に設計する必要がある。
分離自体に意味があるのではなく、分離しておくと任意のタイミングでコンポーネントをRead/Writeで責務を分けられるのが大きい。
Write系を分離できるということはフォームなどで入力の項目数が多い場合にコンポーネントの再レンダリングによるパフォーマンスの影響も局所化することが可能となる。
const TodosWrapper = () => {
return (
<div>
絞り込み: <TodosFilter />
TODO一覧: <Todos/>
</div>
)
}
// Read系
const Todos = () => {
// 表示のための整形とか挟める
const todos = useTodos().getValue().map(/*~*/)
return <TodosPresenter todos={todos} />
}
// Write系
const TodosFilter = () => {
const { reload } = useTodosMutation()
// React hook formみたいなのがコンテキストで提供されてる想定
const form = useTodosFilterForm()
const handleChangeOrder = () => {
reload(form)
}
const handleClear = () => {
form.reset()
}
return (
<div>
{/* コストが高いコンポーネント */}
<HeavyForm form={form} />
{/* 複雑なロジックを持つコンポーネント */}
<ComplexFox form={form} />
{/* イベントハンドラ系のコンポーネント */}
<Actions onChangeOrder={handleChangeOrder} onClear={handleClear} />
</div>
)
}
このように分離しておくことでRead/Writeのロジックを特定のコンポーネントに凝集させることができ、メンテナンスのしやすさにつながる。
最初はRead/Write一緒に扱って、規模が大きくなりそうなら分離していくといった戦略も取れる。
分割統治が可能なので、ロジックや状態の後方互換性を保ったまま段階的に設計をスケールさせられるのは嬉しい。
先程の肥大化したカスタムフックと例が違うので比較が難しいが、この方向性で肥大化したデータフェッチロジック・カスタムフックを小さくまとめることを目標の一つにする。
レスポンスから派生データも作れるのでモデリングしやすい。
クライアント状態の合成、ビジネスロジックもselectorに凝集できる。
// user.ts
type User = {
id: number,
firstName:string,
lastName:string
}
export const [[userState, useUser], [useUserMutation]] = atomWithFetcher(fetchUser({ id: 1 }))
// レスポンスから派生データを作れる
export const userFullNameState = selector({
key:"userFullNameState"
get({get}){
const user = get(userState)
return getFullName(user)
}
})
``
^ のフェッチャー部分はエンドポイントによって構造が異なるので何らかの形で抽象化される必要があるが、ここはOpenAPI Generator, Aspida, GraphQLなどのエコシステムに丸投げする
データフローグラフの責務は依存関係の構築なので、薄くつくるに越したことはない。
フェッチャーに関してだが、GraphQLは今回はパス。
その思想は好きだが、大衆に浸透するのにまだ時間がかかりそうなのとエコシステムの発展の問題でチームで運用するには課題が多い。
日本で流行ってきたら考える。
REST APIを考えると、Open API Generator と Aspidaを選択肢に挙げる ※(2023だとOrvalも選択肢に入る)
それぞれ、パラメータの指定の仕方が微妙に違う
- OpenAPI Generator: パスパラメータとクエリパラメータをフラットに指定する
- Aspida: パスパラメータを関数で指定する
// OpenAPI Generator
const user = await api.getUser({
id: 1,
name: 'hoge'
})
// Aspida
const user = aspida.users._user_id(1).$get({
query: {
name: 'hoge'
}
})
関数的に考えやすく、合成しやすいのはAspidaかなという感じ。
たとえば、http://hoge.com/users/:user_id
のユーザーの画面でRecoilとAspidaを使うとこのようにまとめることができる。
// http://hoge.com/users/3の場合は `3`
const currentUserId = atom<number>({
key: 'currentUserId',
// urlのuser_idを引いてくる
effects: [setUserIdFromUrlEffect]
})
// この例はnext/router
import SingletonRouter from 'next/router'
const setUserIdFromUrlEffect: AtomEffect<number> = ({
trigger,
setSelf,
}) => {
const init = () => {
const { query, isReady } = SingletonRouter
if(!isReady){
return
}
const userId = Number(query.userId)
if(Number.isNaN(userId) || userId === 0){
throw `userId ${userId} is not a number`
}
setSelf(userId)
}
// "atomへの初回の参照時だけ"というRecoilの方言
if (trigger === 'get') {
init()
}
}
// URLのユーザーのAspidaクライアント
const currentUserApi = selector({
key: 'currentUserApi',
get:({get}){
const userId = get(currentUserId)
return aspida.users_user_id(userId)
}
})
// 現在のユーザー
const currentUser = selector({
key: 'currentUser'
get:({get}){
const api = get(currentUserApi)
return api.$get()
}
})
// 現在のユーザーの記事
const currentUserArticles = selector({
key: 'currentUserArticles'
get:({get}){
const api = get(currentUserApi)
return api.articles.$get()
}
})
// 他にも現在のユーザーの"XXX"というのは`currentUser`から派生できる
// 主体(ドメイン)を明示することで脳内メモリに優しく、オブジェクト指向的に処理をまとめやすい
// 例: 現在のユーザーのWrite Model
const currentUserAction = selector({
key: 'currentUserAction',
get: ({getCallback}) => {
// currentUserApiに依存するノードのキャッシュ破棄
const invalidate = getCallback(({refresh}) => () => {
refresh(currentUserApi)
});
const update = getCallback(({snapshot}) => async (params) => {
const api = await snapshot.getPromise(currentUserApi)
api.$put(params).then(invalidate).catch(/*~*/)
});
const delete = getCallback(({snapshot}) => async () => {
const api = await snapshot.getPromise(currentUserApi)
api.$delete().then(invalidate).catch(/*~*/)
});
const hi = getCallback(({snapshot}) => async () => {
const user = await snapshot.getPromise(currentUser);
alert(user)
});
return {
update,
delete,
hi
};
},
});
// コンポーネントはViewに集中できる
const User = () => {
// Read Model
const user = useRecoilValue(currentUser)
const articles = useRecoilValue(currentUserArticles)
/*~*/
}
const UserActions = () => {
// WriteModel
const {update, delete, hi} = useRecoilValue(currentUserAction)
/*~*/
}
この例だと、コンポーネントが依存するのはReadModel/WriteModelのみとなっている。
コンポーネントは各Modelがどこから来て、何に依存しているかに関心を持たず、内部のデータ構造はデータフローグラフに隠蔽されている。
データとそれを描画するステップを明確に分けることで、
- コンポーネント: 宣言的UIの構築とイベントハンドラの割当に集中することができる(技術的な詳細は知らなくていい)
- データ: リファクタリングが容易で変化に強い
リソースに対するドメインモデルの構築という意味では、パスパラメータ(主体)を関数で明示的にできるAspidaはデータフローグラフと相性がいい。
(この構成はZustand, React Queryなどでも同様に出来るが、書き味、リアクティブ性、Suspense、エラーハンドリングなどの制約が異なる。本記事はRecoilを前提として話をすすめる)
ということでReact Queryを意識しつつも、RecoilとAspidaのヘルパーを作ることにする。
Aspidaはリソースごとにメソッドと型がまとめられている。
このリソースをget, post, deleteなどを集約した上位集合としてオブジェクト指向的に扱えるとうれしい。
- 入力: リソース
aspida.users
- 出力: 各エンドポイントのメソッド
- get:
aspida.users.$get
- post:
aspida.users.$post
- get:
この構造を上記の例で示したようにselectorで表現するヘルパーを作る。
コンポーネントはヘルパーを介してaspidaのapiを叩く。
似たことをReact Hooksで再現している記事
めっちゃ型ついててすごい
RecoilとAspidaのヘルパーはこんなイメージをしている
const [[, useUser],[useUserMutation]] = atomWithAspida({
// Selectorのgetをコールバックで受け取れるので依存性注入が出来る
endpoint({ get }) {
const userId = get(currentUserId)
return aspida.users.user_id(userId)
},
})
const User = () => {
const user = useUser().getValue()
const {
refetch,
} = useUserMutation()
return (
/*~*/
)
}
ユーザー一覧画面とかだとこんなイメージ
データフェッチのキャッシュ管理は中々鬼門なので(post後のリロードなど漏れがち)、post, putなどのタイミングでgetのキャッシュもinvalidateする仕組みがあるとよさそう。
RTK QueryではAutomated Re-fetchingというらしい
React QueryではQueryInvalidationというらしい
- https://tanstack.com/query/v4/docs/guides/invalidations-from-mutations
- https://zenn.dev/maro12/articles/ff825d35e8f776#queryinvalidation
// aspida.usersにはget, postのエンドポイントが定義されている想定
const [[userState, useUsers],[useUsersMutation]] = atomWithAspida({
endpoint({ get }) {
return aspida.users
},
// GETのクエリパラメータ
// 初期値にも利用
// 後述のreloadで状態変化を起こす
getOption({ get }) {
return {
query: {
page: 1,
limit: 10
},
}
},
})
const Users = () => {
const users = useUsers().getValue()
const {
// GETのクエリパラメータを更新する
reload,
// aspida.users.$postと同じ
post,
} = useUsersMutation()
// 検索条件を反映する
const handleSearch = (form: UserSearchForm) => {
// reloadの内部でoptionを更新するとともに、APIレスポンスのキャッシュのinvalidateを行う
// -> 指定したパラメータ(form)でAPIが呼び出されて画面に反映される
reload(form)
}
// ユーザーを登録する
const handleCreateUser = (user: User) => {
// post成功時、内部的にAPIレスポンスのキャッシュをinvalidateする
// -> 開発者はキャッシュを意識しなくとも、postしたら一覧画面に変更が伝搬する
post({
body: user,
refetchOnSuccess: true
})
}
return (
/*~*/
)
}
プリフェッチ(+並行リクエスト)
React QueryやGraphQLを始めとするRender As Fetchパターンの設計上の利点としてはデータフェッチと利用するコンポーネントを近く配置するわかりやすさ、管理のしやすさにあると思う。
propsの受け渡しなどを行わず、ほしい時にオンデマンドにとれという考え方。
APIの呼び出しと利用側が近くなることで自然と凝集度が高まる。
これはキャッシュ機構が備わっていることで実現できている。
ただし、RESTの場合のRender As Fetchは、APIが増えてくるとデータフェッチのウォータフォールが発生するという懸念がある。(一画面で呼び出すAPIが10個とかになると明確に問題になる)
コンポーネントがマウントされるまでAPI呼び出しが行われないため、並行リクエストするのにちょっと手間がかかる。
カルーセルとかでも先読みができないとユーザー体験が悪くなる。
APIの改修や増減が発生するフェーズ(立ち上げ期など)では、気付いたらウォーターフォールになっちゃってるというのはありがち。
そして後からパフォーマンス改善で苦しむ。
REST APIでクライアントを実装する場合は設計時に並行リクエストする層を考慮するのは重要な観点。
一方、GraphQLは一発でほしいもの全部取れるのでこういった悩みは減る。
けれどもREST, GraphQLともに複雑になってくるとデータフェッチロジックと呼び出しが分離されることで管理が大変になるという課題はある。
親でリクエストするけれども欲しいフィールドやデータ構造は子の関心となるので、親が子の構造をしっていないと効率的にデータフェッチロジックをかくのが難しく、双方向に依存してしまいメンテナンスコスト増加につながる。
たとえばRelayはフラグメントコロケーションで上記の課題を解決している。
そういう流れでGraphQLのクライアント実装では、データフェッチロジック自体は利用するコンポーネントと近く位置に配置しつつも、親が子のフラグメントを合成して一括でキャッシュの先行読み込みを行う設計が一般的。
いまとなってはデファクトスタンダードになりつつあり、設計の文脈ではこれをコロケーションという
参考:
歴史を知ることで逆説的にデータフェッチの設計はどう在るべきかが見えてくる。
- データフェッチは末端に寄せる
- データ構造と表示の構造の関数は描画ロジックと近いほど管理しやすい
- 子の関心は子で閉じるべき
- レスポンスのキャッシュ機能
- データの共用はキャッシュで透過的にやる
- キャッシュの先行読み込みをなんらかの形で提供する
- 任意のタイミングで再リクエスト可能
- データフェッチの発火は遅延束縛する
- パラメータの組み立てと、発火の関心を分離する
- 参照時のタイミングで遅延束縛されるようにする
これらに関してRecoilの公式ドキュメントで言及がある。
RecoilとGraphQLの開発元が一緒なだけあって、GraphQLとRESTのいい面を抽象化している[1]
さて、Prefetch, QueryRefreshを使えばRESTでもコロケーションの設計が可能であることが分かった。
だけどいちいちRequest IDのatom用意するの面倒だし、Recoil Refresherも依存ノードを再帰的に初期化するので扱いづらい。
なのでこれらを隠蔽したatomExternal
ユーテリティを作った。
atomExternal
は引数のget
に非同期処理を渡すとその結果をプロキシしているselectorが返ってくる。
戻り値は通常のselectorのように扱えて、拡張した点としてreset(selector)をするとget
で渡した非同期処理が再実行されるというもの。
atomExternal
// Referenced from
// https://scrapbox.io/study-react/atomRewind
// https://github.com/facebookexperimental/Recoil/issues/571#issuecomment-693364531
// https://recoiljs.org/docs/guides/asynchronous-data-queries#query-refresh
import {
atom,
DefaultValue,
selector,
GetCallback,
GetRecoilValue,
useRecoilValueLoadable,
useRecoilCallback,
} from 'recoil'
type RawSelectorOptions = {
get: GetRecoilValue
getCallback: GetCallback
}
type AtomExternalOptions<T> = {
key: string
get: (opts: RawSelectorOptions) => Promise<T>
dangerouslyAllowMutability?: boolean
}
/** Atom to rerun asynchronous query by reset() */
export function atomExternal<T>(options: AtomExternalOptions<T>) {
const { key, dangerouslyAllowMutability } = options
const delegatedQuery = selector({
key: `${key}/delegatedQuery`,
get: (opts) => {
// for cache invalidation
opts.get(invalidate)
return options.get(opts)
},
})
const invalidate = atom({
key: `${key}/invalidate`,
default: 0,
})
const baseAtom = atom({
key: `${key}/baseAtom`,
dangerouslyAllowMutability,
default: selector({
key: `${options.key}/baseAtom/default`,
dangerouslyAllowMutability,
get: ({ get }) => get(delegatedQuery),
}),
})
const state = selector<T>({
key,
dangerouslyAllowMutability,
get: ({ get }) => get(baseAtom),
set: ({ set }, newValue) => {
if (newValue instanceof DefaultValue) {
set(invalidate, (count) => count + 1)
return
}
set(baseAtom, newValue)
},
})
const useExternalQuery = () => useRecoilValueLoadable(state)
const useExternalQueryMutation = useRecoilCallback(
({ snapshot, reset }) =>
() => ({
prefetch() {
snapshot.getLoadable(state)
},
refetch() {
reset(state)
},
})
)
return [state, useExternalQuery, useExternalQueryMutation] as const
}
利用例
const userIdState = atom<number>({
key: 'userIdState',
})
const [userState, useUser, useUserMutation] = atomExternal({
key: 'userState',
get: ({ get }) => {
const userId = get(userIdState)
return fetchUser(userId)
},
})
const User = () => {
// Suspendしつつ読み込む
const user = useUser().getValue()
// 明示的にキャッシュ先読み、リフェッチできる
const { prefetch, refetch } = useUserMutation()
return (
<div>
<p>{user.name}</p>
<button onClick={() => refetch()}>refetch user</button>
</div>
)
}
このatomExternalをAspidaヘルパーの内部で利用する。
productionでも似たようなの使ってるが、チーム開発でもうまいこと要件を満たしてくれる。
主にデータフェッチ周りでRecoilの辛い部分が隠蔽されるのでオススメ。
もっといいやり方はあるかも。
あとからRecoilのDiscutionsに投稿してみて意見を募りたい。
需要ありそうだったらライブラリ化してもいいかもなぁ。
-
RecoilもRelayだけじゃ辛くなってきて作ったのかな。
Metaの内部方針的にもデータをRecoilに寄せるようになっているのだろうか。
Recoil-RelayでRelay<>Recoilを統合できるようにしたのも自分達が欲しいから説。
時間があればこのあたりの開発秘話を追ってみたい。 ↩︎
結構ハマったがRecoil x Aspidaで標題の要件を満たしたコードは出来た。
atomWithAspida
と命名する。
あとからコードサンドボックス作る。
1点だけrefetchOnMountで課題がある。
useEffectでリフェッチをしようとすると、Suspenseを利用した時に、初回マウント時に2回APIコールが走る。
// Recoil x Aspidaヘルパーのhooks部分だけ抜粋
const atomWithAspida = (/*~*/) => {
/*~*/
const useAspidaQuery: UseAspidaQuery<E> = (options) => {
const query = useRecoilValueLoadable(queryState)
const resetQuery = useResetRecoilState(queryState)
// コンポーネントのマウント時にリフェッチする
useEffectOnce(() => {
if (options?.refetchOnMount) {
resetQuery()
}
})
return query
}
}
- マウント
- APIコール
- suspend
- アンマウント
- Suspenseが解決される
- マウント
- refetchOnMountが評価される
- APIコール
Suspenseから戻ってきたときも再マウント扱いとなるのでまあそうなるか...
ちょっと後段で考える。
React QueryはstaleTime
で制御してるっぽい。
atomExternalのhooksでstaleTime
を受け取るようにして、selectorのデータフェッチをポーリングする機構を追加した。
ポーリング
export function atomExternal<T, P extends SerializableParam>(
options: AtomExternalOptions<T, P>
) {
/*~*/
+ let lastRequestedAt: Date | undefined
const delegatedQuery = selector({
key: `${key}/delegatedQuery`,
get: (opts) => {
// for cache invalidation
opts.get(invalidate)
+ lastRequestedAt = new Date()
return options.get({ ...opts, param: {} as any })
},
})
/*~*/
const useExternalQuery = (options?: { staleTime: number | undefined }) => {
+ const { staleTime = 0 } = options || {}
const query = useRecoilValueLoadable(state)
+ const tryInvalidateCache = useRecoilCallback(
+ ({ reset }) =>
+ () => {
+ if (lastRequestedAt === undefined || staleTime === 0) {
+ return
+ }
+
+ const now = new Date().getTime()
+ const lastRequestedAtMs = lastRequestedAt.getTime()
+
+ if (now - staleTime > lastRequestedAtMs) {
+ reset(state)
+ }
+ },
+ []
+ )
+
+ useInterval(tryInvalidateCache, staleTime, true)
+
return query
}
/*~*/
return [state, useExternalQuery, useExternalQueryMutation] as const
}
useIntervalはよくあるヤツ
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable react-hooks/exhaustive-deps */
// Borrowed from https://github.com/Hermanya/use-interval/blob/master/src/index.tsx
import { useEffect, useRef } from 'react'
const noop = () => {}
export function useInterval(
callback: () => void,
delay: number | null | false,
immediate?: boolean
) {
const savedCallback = useRef(noop)
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback
})
// Execute callback if immediate is set.
useEffect(() => {
if (!immediate) return
if (delay === null || delay === false) return
savedCallback.current()
}, [immediate])
// Set up the interval.
useEffect(() => {
if (delay === null || delay === false) return undefined
const tick = () => savedCallback.current()
const id = setInterval(tick, delay)
return () => clearInterval(id)
}, [delay])
}
export default useInterval
利用側
const User = () => {
// 1秒毎にポーリングする
const user = useUser({ staleTime: 1000 }).getValue()
return <p>{user.name}</p>
}
いい感じに宣言的。
ただまあ、実際そんなポーリングしたいかって言われるとそうでもない。
クロージャーを使って抽象化しすぎるとメモリーリークなどの危険性もある。
具体的にはlastRequestedAt
が残り続けるのでWeakMapなどでガベージコレクションを考慮しないといけない。
それだと内部実装が膨らんでわかりづらい。
トレードオフを鑑みて、ポーリングは使いたい時に普通に実装したほうがよさそう。
明示的だし、そんな難しいコードでもない。
const User = () => {
const user = useUser().getValue()
+ // 1秒毎にポーリングする
+ const resetUser = useResetRecoilState(userState)
+ useInterval(resetUser, 1000, false)
return <p>{user.name}</p>
}
SuspenseでAPIフェッチを済ませるとページングなどで一瞬画面がチラつく課題がある。
これはUXとしては気になると思う。
理想的には切り替えが終わるまでは以前の画面を表示しておきたい。
useStateで管理される状態の場合は以下のようにuseTransition
で解決できる。
RecoilもuseRecoilValue_TRANSITION_SUPPORT_UNSTABLEがあるが、まだUnstableなので利用は控えておく。
しかし、Suspenseのチラ付き問題や非同期関連の画面表示はUIと密接に関わるロジックなのでReact Hooksだけで完結できるものではない。
ユースケースも画面要件によって異なる。
さらにはデザインレベルで各パターン毎にどう在るべきかが考慮されている必要がある。
今回はThe five ui statesでいう、以下の5パターンを出し分け出来るデータ構造を考える。
- 何も登録されていない状態 (Blank state)
- ロードしている状態 (Loading state)
- 不完全な状態 (Partial state)
- エラーが起きている (Error state)
- 理想的な状態 (Ideal state)
その上で、ローディング中は前回の状態を表示、リクエスト完了時に新しい状態を表示するという機構を作れたらページングのチラつき課題を解決できる
基本的にfive statesはRecoilLoadableのパターンマッチング(loading
, hasValue
, hasError
)で表現できる。
LoadableはResult型のようになっていて非同期処理を抽象化している。
deeplから翻訳
この例では、複数のレイヤーを持つグラフをレンダリングします。
各レイヤーは、潜在的に高価なデータクエリを持っています。
まだ保留中の各レイヤーのスピナーを使用してすぐにチャートをレンダリングし、そのレイヤーのデータが届くと、チャートを更新して各新規レイヤーを追加します。
もしクエリにエラーがあるレイヤーがあれば、そのレイヤーだけがエラーメッセージを表示し、残りのレイヤーはレンダリングを継続します。
コードサンプル
function MyChart({layerQueries}: {layerQueries: Array<RecoilValue<Layer>>}) {
// layerQueriesがグラフデータフェッチを行う非同期処理
// waitForNoneは呼び出し側のコンテキストでSelectorの状態をLoadableで返す関数
// (メモ)useRecoilValueLoadable(layerQueries)との違いはなんだろう?
const layerLoadables = useRecoilValue(waitForNone(layerQueries));
return (
<Chart>
{layerLoadables.map((layerLoadable, i) => {
switch (layerLoadable.state) {
// 非同期処理が解決されている
case 'hasValue':
return <Layer key={i} data={layerLoadable.contents} />;
// 非同期処理でエラーが発生している
case 'hasError':
return <LayerErrorBadge key={i} error={layerLoadable.contents} />;
// リクエスト中
case 'loading':
return <LayerWithSpinner key={i} />;
}
})}
</Chart>
);
}
要はwaitFor*
とLoadable
を組み合わせればReactのConcurrent Modeと同じ文脈で並列レンダリングを行えるということ。
Loadable.getValue
でSuspenseモードで使ってもいいし、上述のLoadable.state
によってViewを切り替えてもいい。
ユーザー体験を最適化するならContainerコンポーネントがLoadable.state
のパターンマッチングでPresenterコンポーネントを切り替えるといったところだろう。
一般的にスピナーを表示するだけなら規模によってはContainer/Presenterを分けずにLoadable.getValue
で親にローディングを任せるとかでもいい。
このあたりはLoadable
に頼ればユースケースによって実装を切り替えられる。
最初はLoadable.getValue
でやっといて後からプロダクトのデザインを最適化したくなったらLoadable.state
に切り替えるとかだと進めやすそう。
データフェッチのロジック自体はatom, selectorに隠蔽されるので、ロジックの後方互換性を保ったままViewを最適化できる。
並列リクエストしたければもっと上のレイヤで(layouts, pages, templates)prefetchすればいい。参照時にデータフェッチが走るのでキャッシュを先読みできる。
さらにLoadable.map
を使えばatom, selectorの値の整形をコンポーネント内で出来る。
const usersQuery = selector(/*~*/)
const users = useRecoilValueLoadable(usersQuery).map(users => {
return {
data: users,
count: users.length
}
}).getValue()
Loadable
すごい。
リアルワールドのユースケースをほぼ網羅している気がする。
Recoilのドキュメントの中では度々backwards-compatible
(後方互換性) のワードが出てくるが、これはRecoilのコアな思想であり、分割統治しつつも段階的に設計をスケールさせることを目的としていることを表現しているのだろう。
描画の関心はLoadableの仕組みに乗っかって、データ/ロジックの関心や実装詳細はatom, selectorに寄せるというのが重要な気がする。
ページングにおいて、チラつき回避のために
ローディング中は前回の状態を表示、リクエスト完了時に新しい状態を表示する
を実現できるデータ構造を考えてみよう。
Loadableを拡張してLoadable.getPreviousValue()
みたいなのができるとうれしい
利用側はこんな感じで出し分けるみたいな
// ユーザー一覧的なの
const UsersContainer: React.FC = () => {
const users = useUsers()
switch (users.state) {
case 'loading':
// 前回の状態を表示する
// 要件によってはスケルトンやスピナーでもよい
return <Users users={users.getPreviousValue()} />
case "hasValue":
return <Users users={users.getValue()} />
case "hasError":
return <Error error={users.errorOrThrow} />
}
}
独自のLoadable型つくれるっぽい?
型定義
// loadable.d.ts
interface BaseLoadable<T> {
getValue: () => T;
toPromise: () => Promise<T>;
valueOrThrow: () => T;
errorOrThrow: () => any;
promiseOrThrow: () => Promise<T>;
is: (other: Loadable<any>) => boolean;
map: <S>(map: (from: T) => Loadable<S> | Promise<S> | S) => Loadable<S>;
}
interface ValueLoadable<T> extends BaseLoadable<T> {
state: 'hasValue';
contents: T;
valueMaybe: () => T;
errorMaybe: () => undefined;
promiseMaybe: () => undefined;
}
interface LoadingLoadable<T> extends BaseLoadable<T> {
state: 'loading';
contents: Promise<T>;
valueMaybe: () => undefined;
errorMaybe: () => undefined;
promiseMaybe: () => Promise<T>;
}
interface ErrorLoadable<T> extends BaseLoadable<T> {
state: 'hasError';
contents: any;
valueMaybe: () => undefined;
errorMaybe: () => any;
promiseMaybe: () => undefined;
}
export type Loadable<T> =
| ValueLoadable<T>
| LoadingLoadable<T>
| ErrorLoadable<T>;
React QueryはkeepPreviousData
でSuspenseのチラつき問題を解決している。
参考になりそう。
前回値の保持ロジックはatomExternalに寄せるとして、
利用側はこんな感じのインタフェースにすると仮定して実装してみる。
const [, useTodos] = atomExternal()
/*~*/
// staleTimeでミリ秒ごとのポーリング間隔を指定できる。
// keepPreviousでリフェッチ中はSuspendされず、前回の値が表示される
// 初回の読み込み時は前回の値が存在しないのでSuspendされる
const todos = useTodos({ staleTime: 1000, keepPrevious: true })
Suspend中に前回の値をフォールバックする実装はこんな感じで出来た
// atomExternal.ts
export type UseExternalQueryOptions = {
staleTime?: number
+ keepPrevious?: boolean
}
/*~*/
const useExternalQuery = (options?: UseExternalQueryOptions) => {
const { staleTime = 0, keepPrevious = false } = options || {}
const query = useRecoilValueLoadable(state)
+ const prevQuery = usePrevious(query)
+ const resultLoadable = useMemo(() => {
+ // return previous value while loading
+ if (
+ keepPrevious &&
+ prevQuery !== undefined &&
+ query.state === 'loading'
+ ) {
+ return prevQuery
+ }
+
+ return query
+ }, [keepPrevious, prevQuery, query])
/*~*/
- return query
+ return resultLoadable
}
usePreviousはよくあるヤツ
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
React QueryからReactに非依存な部分を切り出したTanstack Queryのプロジェクトが進行しているらしい。キャッシュ周りも切り出されてるんじゃないかな?(みてないけど)
atomExternalの抽象化を見直しても良いかも。
- Recoilでシンプルに非同期処理とReact Concurrentを扱うためのミニマムなユーティリティ (今回つくった
atomExternal
) - Tanstack Query のRecoilラッパー
今回は前者にフォーカスする。
Recoil x Aspidaの連携ではそれで十分なはず。
後者はまた別の路線で、ジェネリックなので実装量と求められる知識量は増えそう。
シンプルとは言い難いが、抽象化の深さはあるのでニッチな部分もカバーしてるはず。
興味が湧いたら別のスクラップで深堀りする。
JotaiはTanstack Queryのintegrationを提供している。
コミュニティの大きさがそのまま質につながるのでその点はRecoilよりもJotaiが優れている。
わざわざ車輪の再発明をする必要もない。
Recoilじゃないとできないことは限られているのでintegration系とかデータフローグラフを設計に取り込みたいだけなら最初からJotaiを採用する方がメリット大きそう。
そのうちちゃんと比較するか。
atomWithAspidaでRESTのエンドポイント感の依存管理を分離しつつorganisms単位で分割統治する草案
コンポーネントの単位はatomic designの概念で書いているが、イメージしやすいようにそうしているだけで、atomic designを強制しているわけではない。
- TODO
- 動作するサンドボックスをつくる
- テストコードのサンプルをつくる
- 単体テスト
- 結合テスト
// 機能単位(organisms | features)で関心を分割するディレクトリ構成にする
pages/users/users.page.tsx
organisms/users/users.root.tsx //これが分割統治の親
organisms/users/users.list.tsx // 一覧 (Read Stack)
organisms/users/users.form.tsx // フォーム (Write Stack)
思想的なところ
データフェッチをinfrastructure的なレイヤに分離するというのはあえてやらない。
というのも、基本的にスコープは小さくするべきという考えで、以下のような理由がある
- OpenApi, Aspida, GraphQLなど外部でAPIスキーマを管理してAPIクライアントを生成するというのが一般的
- フロントエンドのドメイン(クエリパラメータ,キャッシュ,レスポンスの整形)などは利用側のコンポーネントに強く依存する
- ゆえに同じAPIでもパフォーマンスや責務分割の観点で別のコンテキストで使いたい場合がある
- なのでロジックもできるだけコンポーネントと近い位置に配置したい
- キャッシュの生存期間などはコンポーネントの都合で制御する
- Recoil/Jotaiなどのライブラリの哲学に沿ってステートをコンポーネントと近い位置に配置したい
このような理由からorganismsとinfrastructureを分けたいと思ったことがない。
データフェッチロジックを分散させてしまうと不要な抽象化が必要となったりスコープが広がりすぎるのであまり好んでいない。
やるとしたらライブラリの設定を一元管理するためのラッパーを作る程度。
organismsのrootに集約する分割統治の作りは以下の利点を享受できる。
- コードが機能単位でまとまるので依存管理をしやすい
- 捨てやすい
- 不要なexportも減るのでデッドコードが減る
- 凝集度が高くなる
- スコープが狭いのでデータフローが追いやすい
- 機能単位で設計を最適化する余地を残している
- 並列で開発する際もスケールさせやすい
- 設計を委譲させやすく、段階的に設計を育てることができる
- 局所的にヒューリスティックな処理もスコープが小さければ許容できる
users.page
- organismsのRootコンポーネントを配置する
export const UsersPage: React.FC = () => {
// pagesの処理
return (
<UserRoot/>
)
}
users.root
- atomWithAspidaでエンドポイントの定義
- 各コンポーネントの配置(list, form)
- コンポーネントの位置は画面要件によって変わる
- 例ではformとlistを同階層に並べているが、実際はlistとformをラップするレイアウトコンポーネントを配置したりする
- organisms内の機能ごとにコンポーネント独立しているというのが重要
- これによって各コンポーネントを疎結合になるので柔軟な改修が可能となる
- コンポーネントのローディングはSuspenseで制御する
- コンポーネント間の通信(主にServer Stateのキャッシュ周り)はatomWithAspidaが最大限に面倒を見る (後述)
// atomWithAspidaのオプション内のコールバックに渡ってくる`get`はRecoilのgetと同じ
// ここで検索条件のatomやURLステートなどと依存グラフを構築できる
// queryのusersStateはatom<ReturnType<typeof aspida.api.v1.users.$get()>>
// このusersStateから派生データ(例えばユースケースごとのPresentation Model)を作ることが出来る
export const {
query: [usersState, useUsers],
mutation: [useUsersMutation],
} = atomWithAspida({
entry({ get }) {
return aspida.api.v1.users
},
option({ get }, current) {
return {
query: {
page:1,
limit: 5
},
}
},
})
export const UsersRoot: React.FC = () => {
return (
<>
<h2>Users</h2>
<AppSpinnerSuspense>
<UsersList />
<UsersForm
formProps={{
defaultValues: { name: '', description: '', disabled: false },
}}
/>
</AppSpinnerSuspense>
</>
)
}
users.list
- rootからRead Stackのhooksを呼び出す
- ここでいうuseUsersは
aspida.api.v1.users.$get()
- ここでいうuseUsersは
- エラー表示
- 正常系表示
- 検索処理は https://zenn.dev/link/comments/66f3b309df6d51 にちょっとだけサンプル書いた
import { useUsers } from '@/features/users/users.root'
export const UsersList: React.FC = () => {
const users = useUsers()
if (users.state === 'hasError') {
return <ErrorDump error={users.errorMaybe()} />
}
return <List users={users.getValue()} />
}
const List: React.FC<{
users: Users[]
}> = ({ users }) => {
return (
<>
/* ユーザー一覧 */
{users.map((user) => (
<Accordion key={user.id} allowToggle>
<AccordionItem>
{({ isExpanded }) => (
<>
<h2>
<AccordionButton>
<Box flex='1' textAlign='left'>
{user.name}
{user.disabled && (
<Badge colorScheme='red' ml={2}>
無効中
</Badge>
)}
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
/* ユーザー単体(省略) */
{isExpanded ? <UserItem subjectId={user.id} /> : null}
</>
)}
</AccordionItem>
</Accordion>
))}
</>
)
}
users.form
- この中でContainer/Presenterに棲み分ける
- Write Stackは複雑になりがちなのでこうしておくのが無難
- 実際にはバリデーションなども入るのでform単体でファイル切ったりする
- Container
- 親から渡されるpropsでformを初期化する
- 例ではreact hook formを利用
- 最近はreact hook form使っとけば間違いない気がする
- PreseterへonValid, onInvalidのコールバックを渡す
- この中にAPI通信などの副作用処理を入れる
- 副作用処理ではrootからWrite Stackのhooksを呼び出す
-
refetchOnSuccess
などのオプションでatomWithAspidaが内部でRead Stackへ更新を通知する
-
- useUsersMutationはpostApi, putApi, deleteApiなど該当エンドポイントのmutationを内包する
- ここでいうuseUsersMutationのpostApiは
aspida.api.v1.users.$post()
- callの引数にoptimisticDataを渡すと楽観的更新が可能 (SWRを参考にした)
- ロールバックなどもatomWithAspidaが内包する
- ここでいうuseUsersMutationのpostApiは
- 親から渡されるpropsでformを初期化する
- Presenter
- Containerのformをform要素、input要素に割り当てることが責務
- バリデーションも実行するがParse, don’t validateの考えに寄せる
- なので実際にはreact hook formのresolverでyup, zodのschemaを渡してあげたりする
- input中の値はstringだが、formに反映されるタイミングでschemaのtransformでnumberやdateに変換するようなイメージ
- こういった変換処理はPresenterには含めたくないのでschemaでやる
- Formが真にやりたいこと
- 理想はinput要素に(
register
|control
)を渡してform要素にhandleSubmit(onValid, onInvalid)
するだけ
- Container/Presenter間のformの受け渡しはpropsでもよい
- 例ではformはContextで渡している
- 複雑な画面では結局Contextにしたくなる
type Form = {
name: string
description: string
disabled: boolean
}
type UserFormProps = {
// react-hook-form想定
formProps: UseFormProps<Form, any>
}
// Context使わないならこのへんは不要
const useUserForm = () => useFormContext<Form>()
const Wrapper: React.FC<UserFormProps> = ({ formProps }) => {
const form = useForm<Form>(formProps)
return (
<FormProvider {...form}>
<UserForm />
</FormProvider>
)
}
const UserForm: React.FC = () => {
const form = useUserForm()
// `refetchOnSuccess`を指定するとatomWithApidaが内部でRead Stackへ更新を通知する
const { postApi } = useUsersMutation({ refetchOnSuccess: true })
return (
<UserFormInput
formStatus={
<FormStatus
formStatus={{
success: postApi.success,
pending: postApi.pending,
error: postApi.error,
}}
/>
}
onValid={(data) => {
postApi.call({ body: data }).then(() => {
form.reset()
})
}}
onInValid={(err, evt) => {
console.log({ err, evt })
}}
/* 楽観的更新したいならoptimisticDataを指定する ~
onValid={(data) => {
postApi.call({ body: data }, {optimisticData: (current) => [...current, data]}).then(() => {
form.reset()
})
}}
*/
/>
)
}
const UserFormInput: React.FC<{
formStatus: React.ReactNode
onValid: SubmitHandler<Form>
onInValid?: SubmitErrorHandler<Form> | undefined
}> = (props) => {
const { onValid, onInValid } = props
const form = useUserForm()
return (
<form onSubmit={form.handleSubmit(onValid, onInValid)}>
<>
<div>
<InputText
label='name'
placeholder='name'
{...form.register('name')}
/>
</div>
<div>
<InputText
label='description'
placeholder='description'
{...form.register('name')}
/>
</div>
<div>
<Checkbox {...form.register('disabled')}>disabled</Checkbox>
</div>
{FormStatus}
<br />
<div>
<Button type='submit'>CREATE</Button>
</div>
</>
</form>
)
}
export { Wrapper as UserForm }
動作するサンドボックスをつくる
リポジトリつくった。
ここで言及している機能は大筋カバーしているつもり。
作り上げてみて改めて見直すと暗黙知が多いことがわかるので徹底的に言語化したい。
特に、中大規模のフロントエンドでなぜコンポーネントツリーに依存しない状態管理が必要になってくるのかは自身の経験に強く依存しているのでもっと言語化しないと共感は得にくい。
同じ目的に対する実装手段は無数にあるので、比較検討して判断要素のポイントを詰めていく。
上記リポでは同じ機能を3パターンで実装したが、React-Queryつかったバージョンも欲しい。
後はもっと複雑なGUIとか。
- 中大規模のフロントエンドでContext, 標準のフックのみだとどこがボトルネックになるか
- どのタイミングでコンポーネントツリーに依存しない状態管理が必要になってくるか
- 外部ストレージとの連携
- 検索条件をURLに残す
- ダイアログやドロワーをURLに残す
- 本質的なpropsのみに絞られたテストしやすいコンポーネント設計
- 外部ストレージとの連携
- ReduxやZustandなどのFluxインスパイア系はどこに課題があったのか
- Atomic State Managementはそれまでの課題をどのように解決したのか
React-Queryつかったバージョンも欲しい。
書いた
React-QueryでもコンポーネントツリーやhooksのインタフェースはatomWithAspidaとほぼ同じだが、内部実装を見たときに差がある。
具体的にはReact-Query独特のキャッシュ管理の知識で一ファイル必要になる。
hooksをSingle Objectにまとめたことである程度凝集されてるが、冗長で直感的ではないのと、合成が難しい。
atomWithAspidaはここのキャッシュ管理をまるっと隠蔽したが、これが本質的な解決策になっているのかを考察していきたい。
atomWithAspidaはここのキャッシュ管理をまるっと隠蔽
あえて隠蔽せずにやってるのがこちらのatomWithQueryのバージョン (sandbox
というフォルダを切っている)
自分でmutationを定義する
利用側は、定義したmutationをこんな感じに使ってキャッシュ管理できる。
atomWithAspidaのmutationも内部的にはatomWithQueryを合成しているだけで、コア機能のほとんどはatomWithQueryが担っている。
atomWithAspidaの場合はaspidaのapi clientがREST APIのリソースを構造化しているのでgetと関連のあるput, postなどは全て推論している。
atomWithQueryのmutateはここ。
queryState: state(param)
がGET APIのレスポンスを示すatomとなっており、これを操作することで状態管理を実現している。
mutateの内部実装は関数へ移譲している(楽観的UIのロールバックやコールバックの実装など)
楽観的更新の処理は自前だが、2023年ではtanstack queryのcoreはvanilla化されているためそちらを活かせるはず
利用者視点の構造や説明はリポジトリのREADMEに記載したのでそちらへ。
一ヶ月かけてアンラーニングしながら頭をからっぽにした状態で改めてこのスクラップ全体を見つつ振り返る。
思想
思想自体は違和感ない。
今でもとりあえず機能要件を満たすことを考えるならtanstack queryで十分だと思っている。
最初はそれでミニマムに作ることを目指すのがいい。
ただREST APIが複数登場したり、クライアントでform state, ui state, global stateなどと絡んで来るとやっぱり辛くなる。
このスクラップはそういった複雑GUIのデータフェッチ設計をシンプルにすることを目指した。
複雑GUIへの課題
コンテキストは上記のコメントにも記載したが、プロダクトのグロースに応じてGUIが複雑化していく中で、REST API + tanstack queryのみだと依存関係の追跡やステートの分断, モデリングのレイヤが不足する課題がある。
他にもいくつか目指したところ
- React Hooksのdeps, ServerStateのキャッシュをデータフローグラフに任せる
- 楽観的UIの実現
- コンポーネント設計の方針を立てる
- 機能(関心)単位、リソース単位で分割統治してスケールする設計
- 改修時に追加/変更/削除しやすい、ステートとドメインを扱う箇所が局所化された設計
- 親と末端でリアクティブとパフォーマンスを両立
アプローチ
- RecoilとAspidaをベースにREST APIをリソース単位で抽象化をした
- ex.
aspida.api.users
に対するget, post, put, patch, deleteなどのエンドポイントをusers
というリソースとして扱う
- ex.
- リソースを境界線としてorganisms単位で分割統治する
- こちらはこのコメントに記載 https://zenn.dev/link/comments/c762aa47242900
- API呼び出し、キャッシュ管理、派生データなどをRecoilのデータフローグラフで表現した
- 各コンポーネントはgetCallback, hooksを利用することで
users.getApi
,users.postApi
のように扱える
- 複雑かつボイラープレートな状態管理/CRUDを隠蔽する
- 楽観的UI、キャッシュ破棄を直感的に行えるように
- CRUD的なリソース操作を定義しなくてよい
- コード量が減ってリソース操作/ユースケース組み立ての認知コストが下がる
- データの実体はRecoilが持つ
- コンポーネントツリーのネストが深くても同じ状態を共有できる
- 末端のViewコンポーネントは派生セレクターでレスポンスの整形/ユースケースの組み立てに集中でき、影響範囲も子に閉じることができる
// aspida.api.usersの抽象化
const {
query: [usersState, useUsers],
mutation: [useUsersMutation],
} = atomWithAspida({
entry({ get }) {
return aspida.api.users
},
option({ get }, currentOption) {
return {
query: currentOption.query,
}
},
})
const UsersList = () => {
// aspida.api.users.$getをRender As You FetchしながらSuspendできる
const users = useUsers().getValue()
return (/*~*/)
}
const UserForm = () => {
const post = useUsersMutation()
// 楽観的UI, postのレスポンスを待つことなくgetのstateが書き変えられる
post(body, {optimisticData: /*~*/})
// キャッシュ破棄, post成功時に自動的にリフェッチされる
post(body, {refetchOnSucess:true})
}
atomWithAspidaのinputのインタフェースはちょっと変えたいかな。
戻り値をquery, mutationに区切らず単一オブジェクトにするほうがexportも減るしネームスペースが簡素になるので管理しやすそう。
const usersQuery = atomWithAspida({
entry({ get }) {
return aspida.api.users
},
option({ get }, currentOption) {
return {
query: currentOption.query,
}
},
})
---
usersQuery.data // atom. aspida.api.users.$getの戻り値
usersQuery.mutation // prefetch, refetch, reload, post, put, patch, deleteのgetCallback
usersQuery.useQuery // atomへのuseRecoilValue
usersQuery.useQueryLoadable // atomへのuseRecoilValueLoadable
usersQuery.useMutation // mutationへのuseRecoilValue
---
const UsersList = () => {
const users = usersQuery.useQuery()
return (/*~*/)
}
インタフェースを更新した。
併せて、
- リポジトリのドキュメントも更新
- 実装レシピを充実させた
-
atomWithAspidaをhooksで宣言できるようにした
- atomFamily使わずともコンポーネントのpropsを元に(例えばIDなど)で初期化が出来る
- 再レンダリングされても参照がおなじになるようにハックしてる
上述の複雑GUIの課題はREST特有となっていて、GraphQLだと別のレイヤでこれらの複雑性が緩和されてクライアントのロジックが減る。(GraphQLのスキーマがリソース/ユースケースの両方を兼ねている)
fragmentが使えたりApolloだとキャッシュの正規化機構が搭載されてたりするのでatomWithAspida
のような設計はそもそもいらないと思っている。(一歩間違えるとオレオレフレームワークとなるので)
GraphQLのServerState管理で迷ったら下記記事のようにtanstack query使っとけば間違いないんじゃないかな。次点でApolloとかurql。
tanstack queryの動向を追ってみると、コア機能をvanilla化してReactに依存する部分とそうではない部分を分離して柔軟性を高めたり他ライブラリへも展開できる政治力や推進力は眼を見張るものがある。
近い内にデータフェッチ世界の覇権を取りそう。
- React自体がServerStateに関してopinionatedなコア機能を追加しようとしている
- Reactエコシステム全体のトレンドとしてvanilla化が進んでいる
その点でJotaiはtanstack queryとのアダプタを持っており、いま出ているJotai v2 のRFCでvanilla化が予定されている。
use
RFCやReactのコア機能への追従を考えると、中長期的にはデータフェッチのコア機能はtanstack queryに任せるほうがポータビリティや学習コスト的な観点で有利だと思う。
ServerState以外のステートとの統合にRecoil/Jotaiという立ち位置。
で、ここに関してはJotaiのほうが優勢。
RecoilはReactに特化しているが、それが優位になるケースはかなり限られるんじゃないかと思っている。
Recoilでやるとしたら、atomEffectsでtanstack queryのQuery Observerをsubscribeするみたいなeffectを作るか、上述のatomWithQueryの内部をtanstack queryに置き換える感じになると思う。
// atomEffects
const query = atom({
key:"users",
effects:[
queryEffect("users", () => fetchUser, {retry: 3})
]
})
// もしくは
const query = atomWithTanstackQuery("users", () => fetchUser, {retry: 3})
// query.data()
// query.useQuery()
// query.useQueryLoadable()
ここまでやるならjotai-tanstack-query使いましょうという話になる。
Atomic State Managementの哲学や導入するモチベーションを改めて整理する
- 状態グラフとReactのレンダリングの整合性を保ったまま一貫性のあるレンダリングができる
- その上で再描画をコントロールできる
- Form, URL, Globalなどのブラウザ上の各種ステートやライブラリ間を疎結合に連携できる
- ステートを集約させるプレゼンテーションロジックをコンポーネント上ではなく状態グラフに分離できる
自分が魅力だと思ったのはこのあたりか。
特にプレゼンテーションロジックの依存関係の解決を状態グラフに分離できるのは嬉しい。
非同期を含む場合もSuspenseによって同期的に処理できるためメンタルモデルとの乖離が減る。
Recoil/Jotaiは、状態グラフの構築(宣言、同期、破棄等のライフサイクル)や派生データ構築)だけに責務をもって薄くつかいたい。
画面要件次第では、そもそもAtomic State Managementを「敢えて使わない」というのも十分アリだと思う。「Atomic State Managementを使わねばならない理由」は少なく、必要となったとしてもそれはアプリ固有の機能となる重要な数画面程度なのが実情であり一般化はできない。
プレゼンテーションロジックとレンダリングロジックを分離する用途であればカスタムフックだけでも十分。最初はuseStateでミニマムに作りつつ、ステートが増えてくる中でメモ化, 再レンダリングの局所化, 状態の依存関係とレンダリングの一貫性を維持するのが難しいときにはじめて検討すると良さそう。
そして、本質的にステートフルなGUIを作るのは難しい。問題解決の手段としては、要件の取捨選択の一つとしてあえてGUIのステートフルさを抑えるようにして「技術の損益分岐点」を見極めるという考え方もある。
なんというか、問題解決が目的であれば、Atomic State Managementを導入する前にやるべきことがあるという話。
既存ライブラリ(ReduxやRx)の勘所や正規化, SSoT, Lifting State upなどの基礎概念を知らずになんでもかんでもAtomやAsync Selectorにするとただの無秩序グローバルステートの集合体となってしまい、複雑で難解なコードベースになってしまう。
「ロジックやデータをRecoil/Jotaiに寄せる」というのはある程度状態管理に関するメンタルモデルが出来上がったタイミングで検討するといいだろう。
チームで開発する場合、AtomやSelectorの粒度は人によってブレがちなためそこを牽引できるような人がいると良い。
React docsを読み込んでからある程度「宣言的プログラミング」について語れる状態だと丁度いいのかもしれない。
テストをどう書くかは意見が様々だが、これはAtomic State Managementに何を求めているかによって変わると思われる。
自分がAtomic State Managementに求めているのはレンダリングに一貫性のある状態伝搬だ。
そのためAtom, Selector自体をテストすることはあまり考えてない。
それはライブラリをテストしているのと同義だと思っていて意味のあるテストだとは思えないため。
だからSelectorはロジックを書く場所というよりはただのsubscriberとして捉えている。
入力をgetだけして、エンティティの知識を扱うようなビジネスロジックは純粋関数に切り出す。
その純粋関数をテストする。
イメージは下記である。
最近はStorybookやmswのエコシステムも整ってきているきているので結合テストだけで十分カバーできるケースもあると思っている。
個人的にはStorybookに集めるほうが好み。VRTも兼ねるので。
CSF 3.0でStoryとmsw集めてjestでAAAにStoryを再利用してブラウザ上でinteraction testするイメージ。
基本的はアプリの想定するUI/UXやチームのモチベーションにより意思決定がなされるべきで、別トピックとする。
色々と発散したので収束させる。
現時点(2022年)のReactにおいては、以下のような考え方がベストプラクティスに沿った犠牲的アーキテクチャの構築に役立つのではなかろうか。
- Reactはレンダリング(=UIの構築)だけ任せる
- プレゼンテーション層の分離はカスタムフックかAtomic State Managementに任せる
- 状態を扱う技術詳細やインタフェースはその時代や要件に合わせ特化したライブラリに任せる
- データフェッチならtanstack query
- フォームならreact-hook-form
- ライブラリ間の統合や整合性が重要となった場合にAtomic State Managementに任せる
- 状態グラフを構築する
- 状態のインタフェースは極力action, set, getのみに絞る
- 特にactionでステートマシンを作るのはカプセル化のために重要で、ここはReduxの思想を知るとイメージしやすい
- 副作用や集約の関心はコンポーネントから逃がす
- APIは「叩く」のではなく「叩かれる」
細かい思想はScrapboxに詳細を書いた。
WEBフロントエンドは3年もたてばパラダイムごと変わるので、価値を見つめ直して作り直すほうがコスパが良いと思われる。
特に2023はRSCだったりNext 13のappDirなどで新たなパラダイムが生まれようとしている。
ここは引き続き観測しつづけたい。