HN 日本語サマリー

← 一覧へ戻る
プログラミング

参照の安定性を型にする

Making Referential Stability a Type (jovidecroock.com)

15 pointsby acmnrs1 コメント

要約

大規模な(P)Reactコードベースでは、useMemoやuseCallbackの使用にもかかわらず、コンポーネントに渡されるプロップの参照の安定性について暗黙的な保証しかなく、予期せぬ再レンダリングの原因となることがあります。この記事では、参照の安定性を型システムに組み込むための「Stable<T>」という型を提案しています。これにより、配列やコールバック関数などが安定した参照であることを型レベルで保証し、開発者が意図しない参照の不安定性によるバグを防ぐことを目指します。

全文翻訳

2026年7月24日 記述 参照の安定性を型にする ある程度の規模の(P)Reactコードベースで作業したことがあるなら、参照の安定性についての会話を何度もしたことがあるはずです。誰かがuseMemoを追加し、別の誰かがuseCallbackを追加し、exhaustive-deps警告を無視し、そして私たちは次のコンポーネントに渡されるプロップが安定していることを願います。コードは通常正しいですが、この暗黙的な安定性に対する保証はすべてありません。私は、新しい配列リテラルがメモ化された子に渡されたことが原因で、再レンダリングやエフェクト実行を追いかけるのに、言いたくないほど多くの時間を費やしました。 宣言された型 `Item[]`、`Item[]` として型付けされたプロップは、型の形状とその形状の中に何があるかについては教えてくれますが、次のレンダリングで安定した配列が得られるかどうかについては何も教えてくれません。 そこで、私は小さなアイデアを試していました。もし参照の安定性という意図が型の一部だったらどうなるだろうか、と。 型 このすべては、プライベートなファントムブランドにかかっています: declare const stableBrand: unique symbol type Stable<T> = T extends object ? T & { readonly [stableBrand]: true } : T オブジェクト、配列、関数はブランド付けされます。プリミティブはそのまま通過します。なぜなら、ReactとPreactはそれらを値で比較するため、文字列は常に「十分に安定している」からです。 ユニークシンボルがここで कामをしています。もし私が`_stable: true`のような文字列キーを使用していたら、その形状を偶然持つ任意のオブジェクトがブランドを満たしてしまい、誰かが最終的に`{ _stable: true }`と書くでしょう。パッケージから決して出ないユニークシンボルは、アプリケーションコードからの構造的な代入では再現できません。あなたは`Stable<T>`と名前を付けることはできますが、それを偽造することはできません。 これにより、プロップはそのデータ以上のものを記述できるようになります: type ItemListProps = { items: Stable<Item[]> onSelect: Stable<(id: string) => void> title: string } 配列とコールバックは安定した意図で構築された参照であるため、ブランドを運びます。そして今、インラインで構築された配列は間違った型になります: <ItemList items={items.filter(isVisible)} onSelect={(id) => select(id)} title="Visible items" /> コンポーネントの境界は、要求を呼び出し元に押し戻します。これはまさにそうあるべき場所です。 これはまた、私たちが忘れがちな`memo()`と`PureComponent`の半分でもあります。コンポーネントを`memo`でラップし、値の変更なしに再レンダリングされないと仮定しますが、複雑な型の安定性は保証ではなく、この最適化が無効になる原因となる可能性があります。 フックはそのままに、インポートを置き換える これらの証明を生成するための私の最初の試みは、モジュール拡張を使用しました。あなたはパッケージをその副作用のためにインポートし、それはReactのフック型を拡張し、`useMemo`はすべての依存関係が安定しているときにその結果をブランド付けし始めます。それは半分機能し、機能しない半分は理解する価値があります。なぜなら、それは私が掘り下げるまで私にも奇妙に聞こえた部分だからです。 モジュール拡張により、宣言にオーバーロードを追加できます。それは、`@types/react`が出荷するものを取り除いたり置き換えたりすることは決してできません。拡張した後、`useMemo`は2つのオーバーロードを並べて持っています:厳密な(ファクトリ、deps: StableDeps)=> Stable<T>、およびReactの元の(ファクトリ、deps: DependencyList)=> T。 TypeScriptはまず厳密な方を試します。すべての依存関係が証明を持っている場合、素晴らしい、あなたは`Stable<T>`を得ます。しかし、1つの依存関係がそうでない瞬間、厳密なオーバーロードは一致しなくなり、解決は静かにReactの元のものにフォールバックします。これは、任意の`DependencyList`を喜んで受け入れます: const unstable = {} const value = useMemo(() => ({ answer: 42 }), [unstable]) // エラーなし。valueは単なる{ answer: number }、ブランドなし 依存関係リストにはエラーがありません。そこが私がエラーを期待していた場所です。証明は単に生成されません。 そこで、私は戦うのをやめました。厳密なパスは別のエントリポイントです: import { useCallback, useEffect, useMemo } from 'stableref/react' これらは実行時の実際のReactフックであり、より厳密な型宣言の下で公開されています。依存関係タプルは推論され、各証明されていない要素は、期待される型のアクション可能な文字列リテラルに置き換えられます。このエントリにはReactの寛容なオーバーロードが含まれていないため、フォールバックするものはありません。生の参照は、記述した場所で失敗します: const stableFilter = useCallback(filter, [query]) useMemo(() => source.filter(stableFilter), [source, stableFilter]) // Stable<Item[]> useMemo(() => source.filter(rawFilter), [source, rawFilter]) // ^ 型エラー effect(syncSelection, [selection, page]) // ^ pageが証明されていないオブジェクトの場合、型エラー パッケージは拡張モードを出荷しません。静かに証明を失うことは、実際の強制と間違えやすいです。`stableref/react`からインポートすると、悪い依存関係リストは、記述した場所で失敗し、それ以降のどこかではありません。 ソースに祝福を Reactはすでに保証しています すべての安定した参照が`useMemo`から出てくるわけではありません。Reactはいくつかのものを無料で提供しており、型はそれを伝えることができます: const [state, setState] = useState(initialState) // state: Stable<State> // setState: Stable<Dispatch<SetStateAction<State>>> const element = useRef<HTMLElement | null>(null) // element: Stable<RefObject<HTMLElement | null>> `Stable<State>`は小さな説明に値します。なぜなら、最初は嘘のように読めるからです。それは状態が決して変わらないという意味ではありません。それは、変更されなかった状態に対してReactが新しいIDを製造しないという意味です。実際の状態遷移は、メモを無効にし、エフェクトを再実行する必要があります。それが状態のポイントです。ブランドはアイデンティティに関するものであり、不変性に関するものではありません。 初期化クロージャも証明を必要としません。それらは依存関係リストではありません: const [state] = useState(() => buildInitialState(props)) const [reduced] = useReducer(reducer, props, createInitialState) Reactはそれらを初期値を生成するためだけに呼び出します。契約は結果の状態とディスパッチャーにブランドを付けますが、初期化クロージャ自体が安定していたかどうかは気にしません。そのため、私はそれを要求しません。 モジュールスコープの値で、構築によって安定しているものには、小さなアイデンティティヘルパーがあります: export const EMPTY_ITEMS = stable([] as Item[]) 実行時には`stable()`は値をそのまま返します。`x => x`です。有用な部分は関数ではなく証明であり、主張が実際に真実であるモジュールスコープに留めてください。 コンテキストは、これがすぐに役立つ場所です インラインコンテキスト値は、サブツリー全体に不要な作業を静かにさせる最も簡単な方法の1つです: <ThemeContext.Provider value={{ theme, setTheme }}> {children} </ThemeContext.Provider> `createStableContext`は、その要件をプロバイダーに移動します。それはフレームワーク固有であるため、パッケージのルートではなくReactのエントリから来ます: import { createStableContext } from 'stableref/react' const ThemeContext = createStableContext<ThemeValue | null>(null) これで、プロバイダーは`Stable<ThemeValue>`のみを受け入れます。値の所有者は、それをメモ化するジョブも所有します。ツリーの奥深くにあるコンシューマーは、その値がどのように組み立てられたかを知る必要はまったくありません。単に契約を得るだけです。それが私が型から欲しい境界であり、知識はその責任がある場所に留まります。 Preact アイデアは同じで、別のエントリポイントです。なぜなら、Preactを無視するわけにはいかなかったからです: import { useEffect, useMemo, type Stable } from 'stableref/preact' これらは元の`preact/hooks`参照を保持し、同じ厳密な依存関係シグネチャを適用します。証明は値の契約に属しており、実際にどのレンダリングライブラリを選択したかとは関係ありませんでした。 これが解決しないこと 決意した開発者はいつでも書くことができます: value as Stable<typeof value> キャストではブランドは生き残りません。これは、TypeScriptでモデル化するほぼすべての保証に当てはまります。私はそれのためのESLintプラグインを出荷していません。アサーションは、レビューと規約が監視する必要のある明示的なエスケープハッチのままです。`stable()`も同様の約束です。コンパイラを黙らせるために、それをあちこちに散りばめないでください。 React Compilerは、人々が持ち出すもう一つのものです。手動メモ化は不要になりますか?多くのケースでは、そうです。しかし、私はこのアイデアを無意味だとは思いません。なぜなら、それらは異なる質問に答えるからです。コンパイル