🎓

<div/> のレンダリングから始める React 学び直し

2022/12/01に公開約6,600字

React Advent Calendar 2022 2日目の記事です。

本記事はごく簡単なコンポーネントから始めて、react のレンダリングについて学び直す記事です。学び直し(!=入門記事)なので JSX、TS の説明などはしません。

本記事の対象読者

  • ある程度 react を触っていて、もっとレンダリングについて理解したい人
  • より良いコンポーネントを書きたい人

本記事のコードについて

特に断らないかぎり、本記事に出てくるコードは以下のコードを省略したものです。実際に動かせる codesandbox も用意したので、そちらも参照ください。

import { createRoot } from 'react-dom/client';

const App = /* 実装 */;
createRoot(document.querySelector('#main')).render(<App />);

In short: React レンダリングの原則

記事が結構長くなったので、最初に「まとめ」に載せているレンダリングの原則をここに載せておきます。

  • React はコンポーネントのレンダリング結果をブラウザに描画する
  • React がブラウザに描画した結果は不変である
  • ブラウザの表示を変更するには再レンダリングが必要である
    • setState を呼び出すと再レンダリングされる
  • 副作用フェーズとレンダリングフェーズを分けることでレンダリングを冪等にできる

最もシンプルなコンポーネントのレンダリング (<div/>)

まずは div 要素だけのコンポーネントをレンダリングしてみます。実行するとブラウザに hello world と書かれた div が描画されます。

const App = () => <div>hello world</div>;

codesandbox で見る

ここで覚えてほしい原則: React はレンダリング結果をブラウザに描画する [1]

createRoot(root).render(<App />) がまさに App をレンダリングしてその結果(<div/>)をブラウザへ描画しています。この原則は当たり前のようですが大事です。念頭に置いてください。

内部ステートを持つコンポーネント (<input/>)

次は input 要素をレンダリングします。

const App = () => <input value="foo" />;

codesandbox で見る

このコンポーネントをブラウザに描画すると、 foo という値が入った input が現れます。しかし、描画された input の入力欄は編集できません。これは react が input 要素の値を foo で固定しているためです[2]

このように react がブラウザに描画したものをユーザーは変更できません。 ブラウザの表示を変更するには、再レンダリングするしかありません。

ちょっと寄り道: 無理やり再レンダリングしてみる

「ブラウザの表示を変更するには再レンダリングするしかない」という話が出ました。実際に再レンダリングして変更してみましょう。

import { createRoot } from 'react-dom/client';
const root = createRoot(document.querySelector('#main'));

let count = 0;
setInterval(() => {
  count++;
  root.render(<div>{count}</div>);
}, 1000);

codesandbox で見る

上記の例では 1 秒ごとに count を増やし、root.render を呼び出してブラウザへ count の変化を反映させています。他の再レンダリングの方法も、内部で root.render を呼び出しているような感じです。

setState は root.render 以外の代表的な再レンダリング方法の一つです。setState を行うと、state が更新された後に root.render が呼び出されるイメージです(実際には、setState を呼び出したコンポーネントを起点に、そのサブツリーが再レンダリングされます)。

余談: root.render を何度も手動で呼び出すのは稀です。僕は実際のプロダクトでやったことないです。

state ありのコンポーネント

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>{count} 回クリックした</button>
  );
};

codesandbox で見る

ようやく useState が登場しました。少し難しいですが、「React はコンポーネントのレンダリング結果をブラウザに描画する」という大原則を抑えておけば難しくありません。

まず、 このコンポーネントもボタンをクリックするまでブラウザの表示は変化しません。 つまりずっと <button>0 回クリックした</button> のままです。今までのコンポーネントたちと同じです。

違うのはボタンをクリックした時です。ボタンをクリックすると setCount が呼び出され、setCount が count の更新と再レンダリングを行います。

  1. ボタンをクリックする
  2. setCount(1) が呼ばれる
  3. ReactDOM が管理する count の値が更新される(0 → 1)
  4. App が再レンダリングされる
  5. useState(0) から返る count が 1 になっている
  6. 再レンダリング結果が <button>1 回クリックした</button> になる
  7. React が再レンダリング結果をブラウザに反映する

このように react のレンダリングは基本的に、「state が変化し、再レンダリングされ、結果がブラウザへ反映される」のループです。

state ありのコンポーネント(controlled input)

もう一つ state ありのコンポーネントの例をみてみます。

const App = () => {
  const [value, setValue] = useState("");
  return (
    <input
      value={value}
      onChange={({ target: { value } }) => setValue(value)}
    />
  );
};

codesandbox で見る

この例では input の値を変更できます。先ほどの「内部ステートを持つコンポーネント」の章と異なります。

なぜこの例では input の値を変更できるのでしょうか?実は、ユーザーがブラウザ上の input の値をいじっている訳ではありません。ユーザーが input を変更したことを検知した App が value の値を更新して再レンダリングしているのです。

つまり ブラウザ上の input の値と react のステートが同期されているわけではありません。 React のステートをブラウザに表示しているだけなのです。ココは Vue との大きな違いです

子コンポーネントを持つコンポーネント

次は子コンポーネントがある場合についてみていきます。

const Grandchild = () => {
  console.log('render Grandchild');
  return <div />;
};
const Child = () => {
  const [count, setCount] = useState(0);
  console.log('render Child');
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        {count} 回クリックした
      </button>
      <Grandchild />
    </div>
  );
};
const App = () => {
  console.log('render App');
  return <Child />;
};

codesandbox で見る

(簡潔のため useEffect を使わずに console.log を呼んでいます)

ボタンをクリックすると、Child と Grandchild が再レンダリングされます。ログでこれを確認できます。反対に、App は再レンダリングされていません。

このように、 あるコンポーネントが更新されると、子孫コンポーネントも再レンダリングされます。 親コンポーネントは再レンダリングされません。こうなっているのは react には Child の更新が Grandchild に影響しているか分からないためです。Child が更新されたら Grandchild も更新されるべきかもしれない、だからとりあえずレンダリングしているという感じです。Grandchild が再レンダリングされないことが明らかな場合、React.memo を使うと再レンダリングをスキップできます。

余談: パフォーマンスチューニングについて

パフォーマンスを上げるために再レンダリングを抑制する方法がいくつかあります。

  • React.memo を使う
  • shouldComponentUpdate を設定する
  • PureComponent にする

これらを設定すると、当該コンポーネントに渡す prop が変化しない限り再レンダリングされなくなります。

注意してほしいのは「あくまでパフォーマンスを上げるために使う」ということです。prop が変わってなければ再レンダリングしても結果が同じなときにパフォーマンスを上げる目的で使ってください。再レンダリングを無理やり抑制するために使ってはいけません。

副作用のあるコンポーネント

さて、ラストは副作用のあるコンポーネントです。ここではまず 悪い例 からみていきます。

以下のコンポーネントは 0〜9 の数字をランダムに表示します。

const App = () => <div>{Math.floor(Math.random() * 10)}</div>;

しかし、レンダリング中に Math.random() を使っているため、root.render(<App/>) を呼び出すたびに結果が異なります。つまり、App を迂闊に再レンダリングできません。

これを解消したのが以下のコードです[3]。こちらは App を何度再レンダリングしても問題ありません。

const App = () => {
  const [num, setNum] = useState(0);
  useEffect(() => {
    setNum(Math.floor(Math.random() * 10));
  }, []);
  return <div>{num}</div>;
};

codesandbox で見る

このコードのポイントは、useEffect を消すと prop と state からレンダリング結果が決まるコンポーネントだ という点です。

const App = () => {
  const [num] = useState(0);
  // useEffect(() => {
  //   setNum(Math.floor(Math.random() * 10));
  // }, []);
  return <div>{num}</div>;
};

レンダリングフェーズの react からは実際に上のコメントアウトしたコンポーネントのように見えています。 というのも、react はレンダリング結果をブラウザへ反映した後に useEffect を呼び出すからです。

useEffect を使って副作用フェーズとレンダリングフェーズを分けてやることで、prop と state が同じであれば再レンダリングを何度行っても同じ結果になる状態にできます (レンダリングが冪等)。副作用を持っていても他のコンポーネントたちと同じ扱いができるのは react がシンプルさを保っている秘訣の一つです。

まとめ

最後に今回紹介した react レンダリングの原則についておさらいしましょう。

  • React はコンポーネントのレンダリング結果をブラウザに描画する
  • React がブラウザに描画した結果は不変である
  • ブラウザの表示を変更するには再レンダリングが必要である
    • setState を呼び出すと再レンダリングされる
  • 副作用フェーズとレンダリングフェーズを分けることでレンダリングを冪等にできる

ここまで理解できていれば、Context API も Recoil もすぐに理解でき、パフォーマンスチューニングもできるようになるはずです。

脚注
  1. 厳密に言えば react ではなく react-dom がブラウザへの描画を行っている ↩︎

  2. React は input イベントを監視しており、書き換えられたらすぐに foo に戻しています ↩︎

  3. この例は実際には const [num] = useState(Math.floor(Math.random()*10)) で十分です。サンプルコードだと思って目をつむってください。また、useEffect より useLayoutEffect の方が適切です。こちらも話がややこしいので割愛しました ↩︎

Discussion

ログインするとコメントできます