React hooksのdependencyについて
前提
hooksのdependencyには「いかなる理由があっても嘘をつくべきではない」
// eslint-disable exhaustive-depsは認められないものとする
この主張の妥当性をどこまで信じるか?の議論はちょっと思うところあるが、ここでは全面的に信じるとする
用語定義(厳密じゃない可能性があるが、一旦このドキュメントでは下記にする)
同一性
参照が同じであること。アイデンティティ
等価性
値が同じであること。値が同じであれば同じ
scalaの記事だけど↓これとかを参考にしている
JSのことを語るので、primitiveな要素(number,string等)は同じ値であれば同一として判定されるものとして用語を扱う(厳密にはこれは同一というべきでない可能性がある)
JSの同一性と等価性について
===について、numberやstringなどのprimitiveの比較は「等価性」で見られる。
Objectの比較は概ね「同一性」で見られて「等価性」は見られない
等価性を見る行為はdeepEqualやisEqualなどがある(後述)
Object.isも同様「Objectの同一性のみを調べている」であって「等価性を調べている」では無い
「それはそうだろ」なんだけど割とやってると勘違いが発生する
Object.is({}, {})はfalse
code:js
const a = {}
b = a
b.foo = "baz"
Object.is(a,b) // は true
ArrayもObjectの一つなので性質は一緒([] === []はfalse)
ReactのuseEffects等のdepsについて
depsはObject.isを利用している
depsは「同一性」を見ていて、「等価性」は見ていない。
=useEffectは「要素が等価でないから発火する」ではなく「要素が同一でないから発生する」である
useRefsで生成した値は前回と内容が変わろうが変わらなかろう(等価でなかろうが)が「同一」なので発火しない
-> インスタンス変数のようなものというはそういう話(=等価でなくても同一であることを保証される)
useEffectのcustom comperatorはcloseされてる(=実装予定は無い)
useEffectに「等価な値でも同一でないオブジェクトを渡すと意図しない更新が起こる」というハマりかたをする
なんとなくReduxの頃に似たような話があった気がする(性質も一緒)だが、今回は逆の考え方が必要
Reduxのreducer -> 同一なオブジェクトが帰ると「更新されない」=等価なObjectをcloneする必要がある
useEffect -> 等価でも同一のオブジェクトでないと「更新されてしまう」=どうにか同一性をもたせる必要がある
もう少しいうと、同一でも等価で無い場合本来更新されるべきである可能性があるので、「同一かつ等価であるオブジェクトを渡し続けなければならない」になる
なにが困るのか
例えばこういうことをすると、無限ループが発生する
code:js
const GlobalSetting = createContext({
some: {
nested: {
setting: "foo"
}
}
})
const useFlattenGlobalSetting = () => {
const setting = useContext(GlobalSetting)
return { // このObjectは毎度生成されることになり、Object.isの判定では別Objectになる(=同一ではない)
setting: setting.some.nested
}
}
追記 -> 後で気づいたがそもそもContextにObjectを突っ込むのは同一性が取れないので良くないらしい(後述)
案1: deepEqualすればいいのでは?
こういうのはある。が、しかし
Deep equality checks are generally a bad idea
There’s a reason React’s useEffect does *not* work this way.
つまり「deep equalは使ってない理由があるからやってない」とのこと
(これがなんでなのかは読み取れないのはちょっとモヤッとしてる。なんとなく議論見てるとパフォーマンス関連にみえるけど・・・?)
案1-2じゃあJSON.stringifyを使うのか?(これは一応OK)
と思ってこうやたところでeslintには怒られる
code:js
const Children = ({ obj }) => {
useEffect(() => {
console.log("some obj", obj)
}, JSON.stringify(obj)) // React Hook useEffect has a complex expression in the dependency array. Extract it to separate variable so it can be statically checked // react-hooks/exhaustive-deps
return <div>hello</div>
}
としたらこうするのか
code:js
const Children = ({ obj }) => {
const serialize = JSON.stringify(obj)
useEffect(() => {
const deserialize = JSON.parse(serialize)
console.log("some obj", deserialize)
return <div>hello</div>
}
いやーさすがにこれはあんまりじゃないすかー?
案1-2-2: useMemoを使う
案1-2をuseMemoで応用する
code:js
const useObjectMemo = (obj) => {
const key = JSON.stringify(obj)
return useMemo(() => {
return JSON.parse(key)
}
これならかなりきれいにみえる。が、しかし
useMemo はパフォーマンス最適化のために使うものであり、意味上の保証があるものだと考えないでください。将来的に React は、例えば画面外のコンポーネント用のメモリを解放する、などの理由で、メモ化された値を「忘れる」ようにする可能性があります。
とのことなので上記は「最悪消えてもOK」ならばやるべきと思われる。
案2: Refsを使う
refsを使っても一応この問題を回避はできる
code:js
const useUnsafeObjectRef = () => {
const setting = useContext(SettingContext)
const ref = useRef({})
ref.current = {
setting: setting.some.nested
}
return ref.current
}
Contextの値が変わらないのであればこれでも十分。が、逆に「変わったときには検知できない(同一ではあるが等価ではない)」も発生しやすいので結構危うい
(この辺の思想がまだ飲み込めてない)
今の所の答え
JSON.stringifyしたcacheをrefsにする
多分こんな自前キャッシュをすれば「等価なオブジェクトである」ことだけを保証出来る(はず)
当然Data型とか入ってるとダメ
code:js
const useEqualObject = (obj) => {
// const obj = useContext(ObjectContex) // If want use context
const cache = useRef({})
const key = JSON.stringify(obj)
cache.currentkey = JSON.parse(key) // cache.currentkey = {...obj} でも可。 // cache.currentkey = obj だと等価でなくても参照が残り、同一としてに取られる場合アリ(ちゃんと検証してない) }
}
useReducer / useStateで値を格納する
多分これも行ける気がするが、ちゃんと調べてない。
もとの課題が「加工して使った場合」だったが、reducer側で加工しきった状態にすればうまくいきそうな予感がある。
一方で、「加工した状態」での保持それほど正しくない可能性もある
Contextに直でObjectを突っ込むのがアンチパターンな可能性がありそう
↑やっぱりそうっぽい!!
長期的な答え
Record & Tupleのプロポーサルを応援する
Record / Tupleは「等価」を見てくれる!
言語レベルなのでpolyfillは難しそうなのが悩ましい。
みんなで応援しよう