はじめに
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の眼鏡をかけて開発を進められるようになっていければと思います。