javascripter です。ハローでは、初期メンバーとしてプロダクトのローンチ前からAutoReserve の開発に関わっています。 前回の記事に引き続き、筆者が社内で書いている技術ガイドラインについて紹介します。
はじめに
ハローでは、高品質なコードを維持し、開発チームの技術レベル向上を図るため、チーム横断的に、有用な技術Tips、ベストプラクティス・コーディングガイドラインなど情報をNotion上に集約し、自由にエンジニアが閲覧・編集できるようになっています。
この取り組みの目的は以下の通りです:
- コード品質の向上と統一
- 開発チームメンバーの技術スキル向上
- 「どう」直すかでではなく「なぜ」そう修正すべきかまで理解してる人を増やす
- 効率的な開発プロセスの確立
前回の記事については、こちらを参照下さい。
Reactベストプラクティス: react-hooks/exhaustive-depsのエラーを0にする - Hello Tech
今回紹介するドキュメント
今回は、社内技術Tipsの中から、AutoReserveでのSWRライブラリの活用事例と、実装におけるベストプラクティスを紹介します。 AutoReserveは、Web版はReact製のアプリでiOS/Android版はReact Native製のアプリがあり、どちらでもSWRを使用しています。
SWRとは
SWRは、Vercel社が開発したReact用の非同期データ管理ライブラリです。主な特徴は以下の通りです:
- データのキャッシュ管理
- 自動的な再検証(revalidation)
- フォーカス時の再検証
- リアルタイムデータ更新
SWRを使用することで、データフェッチのロジックを簡素化し、キャッシュによってアプリケーションの応答性を向上させることができます。
SWRのキャッシュキー管理問題
SWRの強力な機能の一つが、データの自動再検証(revalidation)です。 SWRはkeyベースでキャッシュが管理されており、キーの管理は使用者側に委ねられています。
例えば、記事一覧と記事があり、記事に「いいね」することができたとします。
// data.tsx export function usePost(postId: number) { return useSWR(`/posts/${postId}`) } export function usePosts() { return useSWR('/posts') } export async function likePost(postId: number) { return await apiClient.post(`/posts/${postId}/likes`) } // `post-show.tsx` const handleLikeButtonClick = async () => { const { data, error } = await likePost(postId) if (error) return // 記事一覧、記事の両方で「いいね」が返ってきてる場合 // mutateを呼び出し関連するデータをrevalidateする必要がある mutate(`posts/${postId}`, data, { revalidate: false }) mutate('/posts') }
上記の「いいね」ボタンのmutate部分の実装からわかる通り、SWRでは表示しているページ・各エンドポイント・操作による変更のデータ依存関係をエンジニアが把握し、適切にmutateを呼び出しデータの再検証を行う必要があります。
AutoReserveにおけるSWRのrevalidation戦略
AutoReserveでは、mutate時にキーを指定して細かい粒度でrevalidationを行うのではなく、富豪的プログラミング的に必要以上にデータが再度fetchされることを許容しています。
具体的には、以下の方針でrevalidation(再検証)を実装しています:
画面フォーカス・画面遷移時の自動revalidation:ユーザが新しい画面に移動した際に、表示されている全てのデータを自動的に更新します。フォーカス時も同様です。
同一画面内での手動mutate:画面遷移を伴わない操作(例:「いいね」ボタンの押下)の場合、必要なエンドポイントを手動でmutateします。
言い換えると、ブラウザリフレッシュ時に走るデータfetchと同等のものを、画面遷移・フォーカス時に必ず行う方針というと理解しやすいかもしれません。
アプリ・Web共通のSWRの設定コードのイメージ:
revalidateOnMount: undefined, // デフォルト値。freshなキャッシュがなければmount時にfetch revalidateOnFocus: true, // フォーカス時にrevalidate focusThrottleInterval: 0, // throttleせずfocus時に必ずrevalidateさせる
React Native側:
- swr-react-nativeというライブラリを使用
- React Navigationでの画面遷移のたび、フォーカスの当たった画面で使用している全てのhooksをrevalidate
細かいmutate操作を行わない理由
以下のメリットが得られるためです。
- エンジニアが実装時に考慮すべきデータ依存関係が画面内に限定され、コードがシンプルになる
- 今作っている機能、画面以外の場所でデータがどう使われているかについて一切考える必要がなくなります
- 大きなプログラムを書く上で大事な特性で、英語でよくLocal Reasoningと呼ばれています
- ※ Local Reasoning = アプリケーション全体のロジックを追わずに、該当部分のコードを読むだけで影響範囲・挙動が予測できること
- 大きなプログラムを書く上で大事な特性で、英語でよくLocal Reasoningと呼ばれています
- 今作っている機能、画面以外の場所でデータがどう使われているかについて一切考える必要がなくなります
- UIの不整合が発生するリスクが大幅に低減される
富豪的revalidationで最初の「いいね」機能の実装はこのように単純化されます:
const router = useRouter() const { data, mutate } = usePost(postId) const handleButtonClick = async () => { const { data, error } = await likePost(postId); if (error) return // 「いいね」操作後、投稿データを再取得(画面内でのみ整合性を保つ) mutate(data, { revalidate: false }) // 画面外のデータのrevalidationは不要: // - /postsへの画面遷移、全データが必ず裏でrevalidateされるため };
典型的な「フォーム編集画面で保存 → 一覧画面への遷移」というパターンでは手動のmutate操作が一切不要になることがわかります。
負荷に関して
バックエンドのリクエスト数は上記の富豪的方針で多少増加しますが、十分許容可能なトレードオフだと考えています:
- この方針で本番で3-4年程度運用していますが、サーバー負荷は現時点で特に大きな課題とはなっていません
- 新規ユーザのほとんどは、キャッシュなしの状態でアクセスしてくるため、元々キャッシュが効く場面が限られていました
- 例外的に重いエンドポイントに関しては、ケースバイケースで特別対応を行なっています(フォーカスによるrefetchを切るなど)
- フロントエンド側に関しては、stale-while-revalidateのため、基本的にフェッチしすぎによる画面のチラつきは発生しません
SWRを使用する際のベストプラクティス
上記ではAutoReserveでの運用方針を書きましたが、このセクションではより一般的なSWRを使用する上でのベストプラクティスについて記述します。
SWRの根本的な仕組みとして、キャッシュが存在するため、古いデータが先に即時に返ってきた後に、新しいデータが返ってくる可能性があります。 またフォーカスのタイミングなど、任意のタイミングで何度もデータが自動で更新され、変更される可能性があります。 このため、Reactのコードを書くうえで、コードをリアクティブに書く必要がある点に注意する必要があります。
SWRの特性を理解し、適切に使用するために、以下の点に注意してください:
1. SWRから返ってきたデータをuseState()の初期値に入れない
フォームの初期値を動的な値に設定したいケース(プロフィール編集画面など)が該当します。
サーバーのデータに依存するフォームの初期値設定には、単純なuseState
の使用は避けましょう。
useState()の初期値は、初回レンダリング時に値がセットされるため、フォームの初期値がSWRのキャッシュの値で固定されてしまうからです。
React Hook Formを使用している場合は、values
という動的な値に対応したプロパティを使用してください。
useState()
を使用しているUIでは、useFormState()
という、初期値が動的になっても対応可能なhooksを使用します。
// ✅ GOOD import { useFormState } from 'react-hooks-use-form-state' function ProductEditForm({ productId }: { productId: string }) { const { data: product } = useSWR(`/products/${productId}`) const [title, setTitle] = useFormState(product?.name ?? '') return ( <View> <Text>Title</Text> <TextInput value={title} onChangeText={setTitle} /> <Button>Save</Button> </View> ) }
useFormState
の実装
react-hooks-use-form-stateに載せていますが、記事中にも貼っておきます。
※ 社内での使用経緯からuseFormState()
という名前になっていますが、フォームに限らず、動的な初期値を使用するstate全般で使用しているので、useDynamicState()
という名前にする方が無難かもしれません。
useFormState.ts
import React from 'react' type FormState<S> = { isChanged: false } | { isChanged: true, value: S } type FormAction<S> = { type: 'SET_STATE', payload: S | ((prevState: S) => S) } | { type: 'RESET' } const unwrap = <T, A extends unknown[]>(valueOrFunction: T | ((...args: A) => T), ...args: A) => typeof valueOrFunction === 'function' ? (valueOrFunction as (...args: A) => T)(...args) : valueOrFunction export function useFormState<S>(initialState: S | (() => S)): [S, React.Dispatch<React.SetStateAction<S>>, () => void] { const initialStateValue = unwrap(initialState) const [state, dispatch] = React.useReducer<React.Reducer<FormState<S>, FormAction<S>>>( (state, action) => { if (action.type === 'SET_STATE') return { isChanged: true, value: unwrap(action.payload, state.isChanged ? state.value : initialStateValue) } if (action.type === 'RESET') return { isChanged: false } }, { isChanged: false } ) const setState = React.useCallback( (value: React.SetStateAction<S>) => dispatch({ type: 'SET_STATE', payload: value }), [dispatch] ) const reset = React.useCallback(() => dispatch({ type: 'RESET' }), [dispatch]) return [state.isChanged ? state.value : initialStateValue, setState, reset] }
2. ロード状態の表示にisValidatingを使用しない
isValidatingは、データの有無に関係なく、データのfetchが走るたびにtrueになります。 SWRを使用したコードでは、revalidationによってfetchが任意のタイミングで何度も走りうるということに注意してください。
例:
- 画面にフォーカスが当たったことによる再検証
- 別の箇所からのmutateの呼び出しによってデータの再フェッチが走る再検証
- 別の箇所でのコンポーネントのmountによる再検証
必要以上にロード画面が表示されるとUI上ちらつきが発生するだけでなく、ブラウザバック時のスクロール時の挙動も壊れます。 ブラウザがスクロール位置を復元するためには、ブラウザバック時にキャッシュから値が同期的に読み込まれUI要素が即座に復元される必要があるからです。
isValidating
を使うと、画面のチラつきが生じるため好ましくない:
// ❌ BAD function ProductDetailsScreen({ productId }: { productId: string }) { const { data: product, isValidating } = useSWR(`/products/${productId}`) // ❌ BAD: // フォーカスが当たったタイミングで、一度表示されていた画面が消えて // 再度ロード状態に移行し画面が不必要にちらつく if (isValidating) { return <Loading /> } return <ProductDetails product={product} /> }
ローディング状態を出すにはdata !== undefined
を使用する:
// ✅ GOOD function ProductDetailsScreen({ productId }: { productId: string }) { const { data: product } = useSWR(`/products/${productId}`) // ✅ GOOD: データがすでにある場合にはロード中にならない if (product != null) { // エラー発生時にもdataはundefinedになるため、 // エラー時のUIを用意していない場合は代わりにLoadingのUIを出す return <Loading /> } // ここでは必ずproductが存在する return <ProductDetails product={product} /> }
もしくは、SWRのisLoading
プロパティを使用することもできます:
function ProductDetailsScreen({ productId }: { productId: string }) { const { data: product, error, isLoading } = useSWR(`/products/${productId}`) // ✅ GOOD: データがすでにある場合にはロード中にならない if (isLoading) { return <Loading /> } // ✅ GOOD: データフェッチ時にエンドポイントがエラーを返した時はisLoadingがfalseになる // このため、エラーを明示的にチェックしないとProductDetailsにundefinedが渡るので注意 if (error != null) { return <Error /> } // ✅ GOOD: エラー時の画面を用意していない場合は、下記のようにLoadingを代用すると良いでしょう // この場合でも、SWRのグローバルなonErrorコールバックによってAPIエラー時にはToastが出るので // エラーが生じたことはUIからわかるようになっています // if (isLoading || error != null) { // return <Loading /> // } // ここではproductが必ず存在する return <ProductDetails product={product} />
data !== undefined
とisLoading
の条件は、fetcherがエラーを返した時の扱いに差があることに注意してください。
3. onSuccessコールバックの使用を避ける
onSuccessというプロパティでリクエスト完了時にcallbackを呼ぶことができますが、上記と同様に、設計上リクエストが何度走っても正しくコードが動くようにする必要があるため、onSuccessを使ったコードは正しくないケースが多いです(参考記事)。
onSuccessを使った結果、意図せず複数回データが送られてしまっている例:
// ❌ BAD function Screen() { const [keyword, setKeyword] = useState('') const { data } = useSWR(`/search/?q=${keyword)`, fetcher, { onSuccess: (data) => { // リクエストが実行されるたびに発生するので、フォーカスが当たるたびに何度も送られる // また、キャッシュによりフェッチが発生しなかった場合にも呼ばれない if (keyword === '') return sendAnalytics(keyword, data) } }) return <.../> }
下記のように、onSuccess
を使わず、データフェッチのトリガーとなるevent handler側で先に処理する方が良いでしょう:
// ✅ GOOD function Screen() { const [keyword, setKeyword] = useState('') const { data } = useSWR(`/search/?q=${keyword)`, fetcher) const onSearch = (value) => { setKeyword(value) if (value !== '') { sendAnalytics(value) } } return <.../> }
また、処理にdataを使用する必要がある場合など、上記のように書けない場合については useEffectを使用し、発火条件となるデータの変更を宣言的に記述する方がより堅牢になるでしょう:
// ✅ GOOD function Screen() { const [keyword, setKeyword] = useState('') const { data } = useSWR(`/search/?q=${keyword}`, fetcher) const [shouldSendAnalytics, setShouldSendAnalytics] = useState(false) const onSearch = (value) => { setKeyword(value) if (value !== '') { setShouldSendAnalytics(true) } } const onSendAnalytics = useEffectEvent(() => { sendAnalytics(keyword, data) }) useEffect(() => { if (shouldSendAnalytics && data != null) { // キーワードを変更し、最初にデータが揃ったタイミングでanalyticsを送信する // 1. 同期的にキャッシュにデータがある場合は即時に送る // 2. キャッシュにデータがなければ、ロードが完了するまで待機 // の2種類のケースに対応 // NOTE: この例では、ロード完了前にキーワードが変化した場合は // 最終的にデータのロードが完了したページしか送信しない仕様 onSendAnalytics() setShouldSendAnalytics(false) } }, [shouldSendAnalytics, data]) return <SearchComponent onSearch={onSearch} results={data} /> }
まとめ
AutoReserveでは、画面遷移のタイミングでエンドポイントを全て再検証するという富豪的アプローチによってアプリケーションのロジックを単純に保っています。 このアプローチにより、UIの不整合が生じる可能性を大幅に減らし、開発効率とユーザー体験を同時に向上することができました。 また、SWRを使用する上で意識しておくべき注意点を紹介しました。
ハローでは、質の高いコードを高速に書くことに熱意のある、本物のエンジニアを募集しています。私たちと一緒に、世界規模のサービスを作りませんか?
少しでも興味を持たれた方は、ぜひjavascripterまでDMでご連絡ください。