はじめに
Reactのドキュメントは入門書としても、一通り触った人がReactに対する理解を深めるためにも秀でている良いサイトです。そのため、社内の活動として有志で読み合わせ会を毎週開催しています。
この記事は「stateの保持とリセット」のページを読んだ会で発生した疑問が興味深かったのでそれについて追求した記事になります。
疑問
発生した疑問は「出力するDOMが同じであるにも関わらず、Counter
を三項演算子によって分岐させたときはstate
を保持し、論理演算子で表現したときはstate
を保持しないのはなぜですか?」というものでした。
三項演算子の例
// isFancyを切り替えてもCounterの値が保持される
<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
</div>
論理演算子の例
// isFancyを切り替えるとCounterの値がリセットされる
<div>
{isFancy && (
<Counter isFancy={true} />
)}
{!isFancy && (
<Counter isFancy={false} />
)}
</div>
ドキュメントを振り返る
ここでドキュメントの内容をおさらいしてみます。
まずは、ReactのコンポーネントをブラウザのDOMに伝える方法についてです。
React もまた、ユーザが作成した UI を管理しモデリングするためにツリー構造を使用します。React は JSX から UI ツリーを作成します。次に React DOM が、その UI ツリーに合わせてブラウザの DOM 要素を更新します。(React Native の場合は UI ツリーをモバイルプラットフォーム固有の要素に変換します。)
次にstate
が更新される方法についてです。
React は、UI ツリーの中でコンポーネントが当該位置にレンダーされ続けている間は、そのコンポーネントの state を維持します。
ここで得た知識だけではUIツリーがどのようなものでどのように構築されるか分からないので、JSXとそれを元に構築されるDOMツリーを元にstate
の維持について考察するのが自然だと思います。
それを前提に考えるとこの疑問は真っ当で、その場では「DOMツリーではなくUIツリーを元にstate
の保持が決まります。UIツリーはfalse
やnull
のようなDOMツリーに現れない部分も情報として持つので前者はstate
が保持されて、後者は保持されません。」のように説明しました。しかし、UIツリーがどのようなものについては説明していないので煮え切らない回答になってしまいました。
そこでこの記事ではUIツリーとは何か、そしてどのように構築されるかを確認してこの曖昧さを払拭します。
UIツリーについて
UIツリーはReactがReact DOMで扱いやすいような形にJSXを変形して渡す時の構造です(React DOMの部分はReact Nativeにも置き換えられます)。
UIツリーが何を指すかを確認するためにReact DOMがReactコンポーネントを扱うメソッドの1つとして持つcreateRoot
に注目します。よく利用される書き方は以下のようになっています。
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
render
の箇所がJSXを渡す部分です。直接JSXを渡しているのでReactがJSXをUIツリーに変換する処理は見当たりません。
そこでJSXについて考えてみます。JSXはJavaScriptの拡張構文であって、実行時はSWCやESBuildなどを用いてブラウザで扱えるようなJavaScriptの形式にトランスパイルするのでした。
ESBuildのドキュメントを元にコードを変換すると
import React from 'react';
let button = <button>Click me</button>
render(button)
上記のコードはReact.createElement
を利用した下記のようなコードになります。
import React from 'react';
let button = React.createElement('button', null, "Click me");
render(button);
つまり、JSXはトランスパイルによって隠されているが、ReactのcreateElement
メソッドを利用してReact DOMで扱いやすいようなものへと変換しているということです。
ESBuild
の説明がわかりやすかったので参考のために利用しましたが、React17以降ではcreateElement
を利用しない方法が主流になっています。
当時のドキュメントによればこのケースは以下のようにトランスパイルされます。
import {jsx as _jsx} from 'react/jsx-runtime';
import React from 'react';
let button = _jsx('button', { children: 'Click me' });
render(button);
react
のcreateElement
メソッドを利用せずに、react/jsx-runtime
の_jsx
を利用するようになりました。これによるメリットは先ほどのドキュメントに書かれています。
せっかくなので新しい変換形式で追っていきます。
_jsx
はどのように変換しているのでしょうか。react/jsx-runtime
はこのコードを起点に始まっており、ReactJSXElement.jsにほとんどの処理が書かれています。
大雑把にみてみるとJSXは以下のようなオブジェクトに変換されるようです。
{
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
_owner: owner,
};
_jsx
の第一引数がtype
に、第二引数がref
とprops
に、第三引数がkey
になります。
props
のchildren
がJSXの場合はさらに_jsx
が続くので以下のようにネストされたツリーが形成されます。
{
$$typeof: REACT_ELEMENT_TYPE,
type: type1,
key: key1,
ref: ref1,
props: {
children: [
{
$$typeof: REACT_ELEMENT_TYPE,
type: type2,
key: key2,
ref: ref2,
props: props2,
_owner: owner,
},
],
},
_owner: owner,
};
これがドキュメントにあるUIツリーと考えられます(ドキュメントで明言されていませんのでおそらくです)。
先ほどの例は以下のように変換されます。
{
$$typeof: Symbol(react.element),
type: 'button',
key: null,
ref: null,
props: {
children: 'Click me'
},
_owner: null,
};
実はReactのコードを追わなくてもブラウザに表示する時点ではトランスパイルが完了しているのでお使いの環境でconsole.log(<Button>Click me</Button>)
のようにしてconsole
に出力するとUIツリーを確認できます。
該当のJSXをUIツリーに変換する
UIツリーが何かわかったところで議題にあったJSXを変換して確認します。
まずは三項演算子によって分岐させるJSXです。
<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
</div>
トランスパイルすると以下のようになります。
const Counter = ({ isFancy }) => (_jsx("button", { className: isFancy ? 'fancy' : undefined, children: "0" }));
_jsx("div", { children: isFancy ? (_jsx(Counter, { isFancy: true })) : (_jsx(Counter, { isFancy: false })) });
isFancy
がtrue
の時、UIツリーは以下のようになります(いくつかの情報は削っています)。
{
$$typeof: Symbol.for("react.element"),
type: 'div',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: ({ isFancy })=> {…},
props: { isFancy: true }
}
]
}
}
({ isFancy })=> {…}
はカウンターコンポーネントの部分です。
それに対して論理演算子を活用したJSXについてみていきます。
<div>
{isFancy && (
<Counter isFancy={true} />
)}
{!isFancy && (
<Counter isFancy={false} />
)}
</div>
トランスパイルすると以下のようになります。
const Counter = ({ isFancy }) => (_jsx("button", { className: isFancy ? 'fancy' : undefined, children: "0" }));
_jsxs("div", { children: [isFancy && (_jsx(Counter, { isFancy: true })), !isFancy && (_jsx(Counter, { isFancy: false }))] })
_jsxs
というものが出てきていますがjsx
同じようなものと考えてください(参考、実際に本番では同じように動きます)。
isFancy
がtrue
の場合は以下のように
{
$$typeof: Symbol.for("react.element"),
type: 'div',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: ({ isFancy })=> {…},
props: { isFancy: true }
},
false
]
}
}
false
の場合は下記のようになります。
{
$$typeof: Symbol.for("react.element"),
type: 'div',
props: {
children: [
false,
{
$$typeof: Symbol.for("react.element"),
type: ({ isFancy })=> {…},
props: { isFancy: false }
}
]
}
}
こうして見ると確かに後者の書き方ではCounter
に値するUIツリーの位置がisFancy
の切り替えによってfalse
と入れ替わっています。
ここまで追うとコンポーネントがUIツリー上で異なる箇所に構築されたと解釈されstate
が維持されないことにも納得です。
おわりに
Reactのドキュメントを読んだ中で出てきた疑問を元にUIツリーについて実装を元に解決しました。
Reactでは背景や思想を元に考えると自然な挙動でもそれを知らないと不自然な挙動に見えることがあるのでより背景と思想の理解を深めてReactの眼鏡をかけて開発を進められるようになっていければと思います。