30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React の関数コンポーネントが再呼び出しされる条件

マウントされた関数コンポーネントが再び実行されるのは、どのようなケースか。
stateが更新されたら再実行されるんでしょ、くらいの曖昧な理解だったので、検証して整理した。

reactreact-domのバージョンは16.10.2
動作確認にReact Developer Toolsも使用したが、そのバージョンは4.2.0

確認方法

コードの全体像は改めて載せるが、関数コンポーネント内にconsole.log('called');と記述する。
これで、関数コンポーネントが呼ばれる度にログにcalledと流れる。

また、React Developer ToolsHighlight updates when components render.を有効にすることで、コンポーネントが再レンダリングされる度にハイライトされるようにしておく。

f:id:numb_86:20191019105056p:plain

state が更新されると呼び出される

useStateuseReducerstateが更新されると、コンポーネント関数が再呼び出しされる。
当該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;

f:id:numb_86:20191019110341g:plain

onClickを書き換えて、stateに常に0がセットされるようにする。
つまりstateが初期値から変わらず、更新が発生しない。こうすると、関数の再呼び出しは発生しない。

  const onClick = () => {
    setState(0);
  };

f:id:numb_86:20191019110757g:plain

SameValue アルゴリズムによる更新判定

stateが更新されたら関数コンポーネントを再呼び出しする」ということが分かったが、その「更新」が行われたかどうかは、どのように判定しているのか。

SameValueというアルゴリズムを使って判定している。ちなみに、PureComponentuseEffectdepsなどでも、同じロジックを使っているらしい。

github.com

ES2015 で定義されたObject.isは、このアルゴリズムに基づいて動く。
React でもこのメソッド、及びそのポリフィルを実装して、利用している。

react/objectIs.js at master · facebook/react

このアルゴリズムの挙動は===とほぼ同じ。違うのはNaN-0の扱い。
これについてはコードを見たほうが早い。このなかで出てくる+00と同じものと思ってよい。

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を使っていることを確認するため、ボタンを押す度にstate0-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;
    });
  };

f:id:numb_86:20191019115831g:plain

オブジェクトの扱いは===と同じなので、値ではなく参照が同じかどうかを見る。

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;

f:id:numb_86:20191019130401g:plain

reducerを以下のように書き換えると先程とは逆になり、中身は変わっているがオブジェクトは同一なので、stateの更新はないと判定される。
そのため関数コンポーネントの再呼び出しは行われず、当然、表示にも反映されない。

const reducer = (state, action) => {
  switch (action.type) {
    case 'update':
      state.status = 'Offline';
      return state;
    // 以下が正しい
    // return {...state, status: 'Offline'};
    default:
      throw new Error();
  }
};

f:id:numb_86:20191019130657g:plain

コメントアウトした部分のように新しいオブジェクトを作って返すと、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;

f:id:numb_86:20191019164414g:plain

以下のようにonClickを書き換えて「一度setStateに現在値と違う値を渡し、その後に現在値をsetStateに渡す」と、再呼び出しはするがレンダリングはしない、という挙動になる。

  // App が呼び出されるが、レンダリングはされない
  const onClick = () => {
    setState(1);
    setState(0);
  };

f:id:numb_86:20191019165055g:plain

関数は呼び出されているのだが、ハイライトしていない。

プロファイラで確認しても、Appは灰色なので、やはりレンダリングされていない。

f:id:numb_86:20191019175017g:plain

どういうロジックでこうなっているのかは不明。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;

f:id:numb_86:20191019170639g:plain

非同期処理のなかで行われる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);
  };

f:id:numb_86:20191019171313g:plain

再呼び出しを行うロジックは、よく分からなかった。

  // ボタンを押しても 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;

f:id:numb_86:20191019173152g:plain

親のコンポーネントが再呼び出しされないなら、子もされない。

  const onClick = () => {
    setState(0);
  };

f:id:numb_86:20191019173334g:plain

参考資料