javascripter です。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。 今回は、筆者が社内で書いている技術ガイドラインについて紹介します。
はじめに
ハローでは、高品質なコードを維持し、開発チームの技術レベル向上を図るため、社内で継続的に技術Tipsやガイドラインの整備・蓄積を行っています。
チーム横断的に、有用な技術Tips、ベストプラクティス・コーディングガイドラインなど情報をNotion上に集約し、自由にエンジニアが閲覧・編集できるようになっています。
この取り組みの目的は以下の通りです:
- コード品質の向上と統一
- 開発チームメンバーの技術スキル向上
- 「どう」直すかでではなく「なぜ」そう修正すべきかまで理解してる人を増やす
- 効率的な開発プロセスの確立
- 新メンバーのオンボーディング支援
今回紹介するドキュメント
今回は、その中から「react-hooks/exhaustive-deps」の使用に関するガイドラインについて紹介します。
このルールは、Reactによって使用が推奨されているESLintのルールで useEffectなどのHooksの依存配列に、その中で使用されているすべての変数を含めることを要求するルールです。
開発者には馴染みは深いと思いますが、不要なdepsをうまく消せず、// es-lint-ignore-next-line
を使用し妥協している
プロダクトもあるのではないでしょうか?
ハロー内でもこのようなコードが一部存在していたので、どのように修正をすべきか・行なったか下記で説明します。
コードベースでreact-hooks/exhaustive-depsをオフにしてはいけない
既存コードで
// ❌ BAD useEffect(() => { ... a ... b ... c // cを依存関係に入れない // eslint-disable-next-line react-hooks/exhaustive-deps }, [a, b])
など、react-hooks/exhaustive-depsのルールをoffにしているコードが散見されます。
このルールはReactの最も基本的なルールの一つであり、オフにしないとコードを書けないケースはプロダクトコードを書くうえで、基本的に存在しません。
ルールをoffにすると、将来のコードのリファクタリング時に問題が生じ、変更がしづらくなりバグが発生しやすくるので、オフにしないようにしましょう。
発生する問題:
- stale closure問題
- 変更時に含めるべき依存関係を入れ忘れ、値の変更が反映されない
- 非同期で更新されるprops更新の順序が変わると壊れるなどのrace conditionの発生
- ルールをオンにしている限り発生しない問題で、バグが発生した場合、特定のレンダリングの順序のみで不具合が発生することになり、原因の特定が非常に難しくなります。
- Reactのバージョンアップ時などに困る問題
- useEffectのdepsを空にしたからといって、effectが一度しか走らないという保証はありません。Strict Modeでは(開発環境で)二回走ります。Reactの基本的な前提として、何度余分にrenderしても壊れないコードを書く必要があります。
- depsを空にして[]を指定して一度しか走らせないようにしているコードは堅牢でなく、render回数に依存したコードとなっておりReactのsemanticsに違反しています。
- 関連して、一度しかeffectを走らせないためsubscribe後のcleanupの関数が不要に感じられたとしても、基本的に必ずcleanupは書く必要があります。
- 一度しか走らないことを意図したコードでも、開発中にReactのFast Refresh 時にComponentが再レンダリングされるケースが存在します。cleanupのコード を書かないとsubscribeなどの関数が多重に登録されて開発がしづらくなりま す。
- 正しいdepsを使用しないと、パフォーマンスを大幅にあげる見込みのReact Compilerの有効化が難しくなります
どう書くべきか?
useEffectEventを使うパターン
https://react.dev/learn/separating-events-from-effects
にある通り、公式でuseEffectEventというAPIが提案されています。React 18の時点では使えないので、
[INTERNAL_PATH]/src/modules/useEffectEvent.ts
内にあるuserlandでの実装を使用してください。
実装コード:
import React from 'react' export function useEffectEvent<T extends (...args: any[]) => any>(fn: T) { const ref = React.useRef<T | null>(null) React.useLayoutEffect(() => { ref.current = fn }, [fn]) return React.useCallback((...args: Parameters<T>): ReturnType<T> => { const f = ref.current return f!(...args) }, []) }
- 一部の値の変更のみに応じてeffectを走らせたい場合:
// ❌ BAD function Component(props) { const pathname = usePathname() const searchParams = useSearchParams() useEffect(() => { sendAnalytics(pathname, searchParams) // pathnameの変更時のみ走らせたい // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]) }
lintのルールをoffにするのではなく、下記のように、useEffectEventを使いdepsを分離することができます。
// ✅ GOOD function Component(props) { const pathname = usePathname() const searchParams = useSearchParams() const onVisit = useEffectEvent((pathname) => { // depsに含めたい値は引数として受け取る // depsに含めたくないsearchParamsはuseEffectEvent内で自由に使える sendAnalytics(pathname, searchParams) }) useEffect(() => { onVisit(pathname) // pathnameの変更に応じて発火させたいことを明確にするため、引数として渡す }, [pathname, onVisit]) // onVisit自体はuseCallbackと違い安定しているのでdepsに含めても問題ない }
もう一つ頻出のパターンとして、propsから渡ってくる値が安定していないがuseEffectの依存関係に含めないといけないケースがあります。このケースもuseEffectEventを使って書くことができます。
// ❌ BAD function Component(props) { const { onUpdated } = props const pathname = usePathname() // ... useEffect(() => { onUpdated(pathname) }, [pathname, onUpdated]) }
上記の場合、onUpdatedがmemoizeされていない場合effectがrenderのたびに呼び出されて しまいます。
この場合もlintルールをoffにするのではなく、下記のように書きましょう。
// ✅ GOOD function Component(props) { const { onUpdated } = props const pathname = usePathname() // ... const handleUpdated = useEffectEvent((pathname) => { onUpdated(pathname) }) useEffect(() => { handleUpdated(pathname) }, [pathname, handleUpdated]) }
より細かく依存関係の変更を管理したい場合
上記のuseEffectEventで大半のケースはカバーできますが、より細かく制御する必要があるケースもあるでしょう。この場合も、usePrevious()というhooksを使うとlintルールをオフにせず制御できます。下記に例を挙げます。
- effect内でstate/propsを色々使っているが、実際にはその一部の依存関係の変更時のみeffectを走らせたい
// ❌ BAD function Component(props) { const { a, params } = props useEffect(() => { fn(a, params) // aの変更時のみ走らせたい // eslint-disable-next-line react-hooks/exhaustive-deps }, [a]) }
この場合、usePrevious()を使い、一つ前のrenderの値と比較することで明示的に依存関係をコード内で示すことができます。別の依存する値が増えた時にも壊れず、リファクタリング時にも不具合の発生を減らせます。
// ✅ GOOD function Component(props) { const { a, params } = props const prevA = usePrevious(a) useEffect(() => { // effect自体は全てのdepsの変更で走るが、ifで監視したい条件を明示的にfilterできる if (a !== prevA) { fn(a, params) } }, [a, params]) } // usePreviousの実装 import { useRef, useEffect } from 'react' export function usePrevious<T>(value: T, initialValue?: T): T | undefined { const ref = useRef(initialValue) useEffect(() => { ref.current = value }, [value]) return ref.current }
複数の値の変更を監視したい場合は、変更時に発火させたい変数ごとにusePrevious()を用意し、if内でorを書きましょう。
今後の展望
この記事では、「react-hooks/exhaustive-deps」ルールに関するドキュメントを公開しましたが、社内では他にも以下のようなトピックについてのガイドラインを作成しています:
- 「フロントエンドSWRライブラリの設計思想」
- 「変数名・関数名の命名規則」
これらに関しても社外に役立ちそうなドキュメントは精査した上で追加で記事を出せると良いかなと考えています。
まとめ
ハローでは、技術ドキュメントの整備と共有を通じて、高品質なコード開発と効率的な開発プロセスの実現を目指しています。 今回紹介した「react-hooks/exhaustive-deps」ルールは、その一例に過ぎません。
ハローでは最新の技術知見やベストプラクティスを取り入れ、質の高いプロダクトを高速に開発したい本物のエンジニアを募集しています。 少しでも興味を持たれた方は、ぜひjavascripterまでDMでご連絡ください。