マウントされた関数コンポーネントが再び実行されるのは、どのようなケースか。
state
が更新されたら再実行されるんでしょ、くらいの曖昧な理解だったので、検証して整理した。
react
とreact-dom
のバージョンは16.10.2
。
動作確認にReact Developer Tools
も使用したが、そのバージョンは4.2.0
。
確認方法
コードの全体像は改めて載せるが、関数コンポーネント内にconsole.log('called');
と記述する。
これで、関数コンポーネントが呼ばれる度にログにcalled
と流れる。
また、React Developer Tools
のHighlight updates when components render.
を有効にすることで、コンポーネントが再レンダリングされる度にハイライトされるようにしておく。
state が更新されると呼び出される
useState
やuseReducer
のstate
が更新されると、コンポーネント関数が再呼び出しされる。
当該state
を表示に使っているか否かは、関係ない。
下記の例では、ボタンが押す度にstate
がインクリメントされる。
state
は定義しただけでどこでも使っておらず、DOM構造にも影響はない。それでも、関数は呼び出される。
import React, {useState} from 'react'; // ボタンを押す度にこの関数が実行される const App = () => { const [state, setState] = useState(0); console.log('called'); const onClick = () => { setState(s => s + 1); }; return ( <div style={{backgroundColor: 'lightcyan'}}> App <br /> <button type="button" onClick={onClick}> click </button> </div> ); }; export default App;
onClick
を書き換えて、state
に常に0
がセットされるようにする。
つまりstate
が初期値から変わらず、更新が発生しない。こうすると、関数の再呼び出しは発生しない。
const onClick = () => { setState(0); };
SameValue アルゴリズムによる更新判定
「state
が更新されたら関数コンポーネントを再呼び出しする」ということが分かったが、その「更新」が行われたかどうかは、どのように判定しているのか。
SameValue
というアルゴリズムを使って判定している。ちなみに、PureComponent
やuseEffect
のdeps
などでも、同じロジックを使っているらしい。
ES2015 で定義されたObject.is
は、このアルゴリズムに基づいて動く。
React でもこのメソッド、及びそのポリフィルを実装して、利用している。
react/objectIs.js at master · facebook/react
このアルゴリズムの挙動は===
とほぼ同じ。違うのはNaN
と-0
の扱い。
これについてはコードを見たほうが早い。このなかで出てくる+0
は0
と同じものと思ってよい。
console.log(Object.is(NaN, NaN)); // true console.log(NaN === NaN); // false console.log(Object.is(+0, -0)); // false console.log(+0 === -0); // true console.log(Object.is(+0, 0)); // true console.log(+0 === 0); // true
state
の更新判定にSameValue
を使っていることを確認するため、ボタンを押す度にstate
に0
と-0
を交互に渡してみる。
state
が変わる度に関数コンポーネントが再呼び出しされていることを確認できる。
const onClick = () => { setState(s => { const currentState = s; const nextState = 1 / currentState === Infinity ? -0 : 0; console.log(currentState); console.log(nextState); console.log(currentState === nextState); // === では常に true になる return nextState; }); };
オブジェクトの扱いは===
と同じなので、値ではなく参照が同じかどうかを見る。
const a = {foo: 1}; const b = {foo: 1}; const c = a; console.log(Object.is(a, b)); // false console.log(Object.is(a, c)); // true
以下の例では、ボタンを押下する度に新しいオブジェクトをstate
にセットしている。
値は同じだがオブジェクトとしては別物なので、state
が更新されたと見做される。
import React, {useReducer} from 'react'; const reducer = (state, action) => { switch (action.type) { case 'update': return { id: 1, status: 'Online', }; default: throw new Error(); } }; // ボタンを押す度にこの関数が実行される const App = () => { const [state, dispatch] = useReducer(reducer, {id: 1, status: 'Online'}); console.log('called'); const onClick = () => { dispatch({type: 'update'}); }; return ( <div style={{backgroundColor: 'lightcyan'}}> id: {state.id} <br /> status: {state.status} <br /> <button type="button" onClick={onClick}> status update </button> </div> ); }; export default App;
reducer
を以下のように書き換えると先程とは逆になり、中身は変わっているがオブジェクトは同一なので、state
の更新はないと判定される。
そのため関数コンポーネントの再呼び出しは行われず、当然、表示にも反映されない。
const reducer = (state, action) => { switch (action.type) { case 'update': state.status = 'Offline'; return state; // 以下が正しい // return {...state, status: 'Offline'}; default: throw new Error(); } };
コメントアウトした部分のように新しいオブジェクトを作って返すと、state
の更新を検知し、関数の再呼び出しが行われるようになる。
一度のイベントで state が複数回セットされる場合
state
の更新による関数の再呼び出しは即時で行われるのではなく、同期処理(逐次処理)が終わったあとに一度だけ行われる。
以下の例ではボタンを押下するとsetState
が1000回呼ばれる。
だがその都度state
が更新されるのではなく、まずは処理が全て終わるのを待ち、その後に一度だけstate
を更新する。
そのため、まずdone
がログに流れ、その後にstate
をセットし、それによりApp
コンポーネントが再呼び出しされてcalled
がログに流れる、という挙動になる。
import React, {useState} from 'react'; // ボタンを押す度に一度だけ App が実行される const App = () => { const [state, setState] = useState(0); console.log('called'); const onClick = () => { [...Array(1000)].forEach(() => { setState(s => s + 1); }); console.log('done'); }; return ( <div style={{backgroundColor: 'lightcyan'}}> {state} <br /> <button type="button" onClick={onClick}> click </button> </div> ); }; export default App;
以下のようにonClick
を書き換えて「一度setState
に現在値と違う値を渡し、その後に現在値をsetState
に渡す」と、再呼び出しはするがレンダリングはしない、という挙動になる。
// App が呼び出されるが、レンダリングはされない const onClick = () => { setState(1); setState(0); };
関数は呼び出されているのだが、ハイライトしていない。
プロファイラで確認しても、App
は灰色なので、やはりレンダリングされていない。
どういうロジックでこうなっているのかは不明。React の実装を読むしかないと思う。
非同期処理のなかで setState を実行した場合
同期処理のなかでsetState
を実行している場合、まずそれによるstate
の更新を行う。
その後、非同期処理のなかで実行したsetState
に基づき、state
を更新する。
そのため以下の例では、一度state
を更新して関数を呼び出した後、その1秒後に再びstate
の更新とそれによる関数呼び出しが行われる。
import React, {useState} from 'react'; const timeout = ms => new Promise(resolve => setTimeout(resolve, ms)); // ボタンを押す度に App が2回実行される const App = () => { const [state, setState] = useState(0); console.log('called'); const onClick = () => { setState(s => s + 1); timeout(1000).then(() => setState(s => s + 1)); }; return ( <div style={{backgroundColor: 'lightcyan'}}> {state} <br /> <button type="button" onClick={onClick}> click </button> </div> ); }; export default App;
非同期処理のなかで行われるsetState
は即時で実行される。そのため以下のような実装にすると、関数呼び出しが5回発生する。
const onClick = async () => { setState(s => s + 1); await timeout(1000); setState(s => s + 1); setState(s => s + 1); await timeout(1000); setState(s => s + 1); setState(s => s + 1); };
再呼び出しを行うロジックは、よく分からなかった。
// ボタンを押しても App は呼び出されない const onClick = () => { setState(0); timeout(1000).then(() => setState(0)); };
これは分かる。同期処理、非同期処理共に、state
が現在値である0
と同じだから、コンポーネント関数は実行されない。
// App を1回呼び出す const onClick = () => { setState(0); timeout(1000).then(() => setState(1)); };
これも分かる。同期処理では更新は行われていないが、非同期処理で更新されているから。
// App を2回呼び出す const onClick = () => { setState(1); timeout(1000).then(() => setState(0)); };
これも分かる。まず同期処理で0
から1
に更新される。そして1秒後に今度は1
から0
に更新される。
// App を2回呼び出す const onClick = () => { setState(1); timeout(1000).then(() => setState(1)); };
問題はこれ。
同期処理は分かる。0 → 1
だから、このときにApp
が実行されるのは分かる。
だがなぜ、非同期処理のsetState
でもApp
が再呼び出しされるのだろうか。1 → 1
だからstate
は更新されていないはずだが。
これも React の実装を読むしかなさそう。
親が再呼び出しされれば、子も無条件で再呼び出しされる
props
の受け渡しの有無等は関係なく、親である関数コンポーネントが再呼び出しされれば、子にあたる関数コンポーネントも再呼び出しされる。
import React, {useState} from 'react'; const Child = () => { console.log('Child called'); return ( <div style={{backgroundColor: 'lightblue', margin: '10px'}}>Child</div> ); }; // ボタンを押す度に App と Child が再呼び出しされる const App = () => { const [state, setState] = useState(0); console.log('App called'); const onClick = () => { setState(s => s + 1); }; return ( <div style={{backgroundColor: 'lightcyan'}}> App <br /> <button type="button" onClick={onClick}> click </button> <Child /> </div> ); }; export default App;
親のコンポーネントが再呼び出しされないなら、子もされない。
const onClick = () => { setState(0); };