React の新ドキュメント読破を目指す
新ドキュメントを一通り読んで、個人的に重要な箇所や知らなかったことをまとめるスクラップ
なぜ複数のJSXタグをラップする必要があるのですか?
JSXはHTMLのように見えるが、その内部ではプレーンなJavaScriptオブジェクトに変換されている。関数から2つのオブジェクトを返すには、それらを配列にラップする必要があります。このため、2つのJSXタグを別のタグやフラグメントにラップせずに返すこともできません。
これって React が内部でやってくれないのかな?ラップの必要がなくなると不要なインデントも減って嬉しい。
del タグ初めて知った... (React 関係ない)
内部的には、Reactは各コンポーネントの状態ペアの配列を保持している。また、現在のペアのインデックスも保持し、レンダリング前に0 。useState を呼び出すたびに、Reactは次の状態ペアを渡し、インデックスをインクリメントする。
useState の実装イメージ
let state = [];
let setters = [];
let firstRun = true;
let cursor = 0;
function createSetter(cursor) {
return function setterWithCursor(newVal) {
state[cursor] = newVal;
};
}
// This is the pseudocode for the useState helper
export function useState(initVal) {
if (firstRun) {
state.push(initVal);
setters.push(createSetter(cursor));
firstRun = false;
}
const setter = setters[cursor];
const value = state[cursor];
cursor++;
return [value, setter];
}
// Our component code that uses hooks
function RenderFunctionComponent() {
const [firstName, setFirstName] = useState("Rudi"); // cursor: 0
const [lastName, setLastName] = useState("Yardley"); // cursor: 1
return (
<div>
<Button onClick={() => setFirstName("Richard")}>Richard</Button>
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
</div>
);
}
// This is sort of simulating Reacts rendering cycle
function MyComponent() {
cursor = 0; // resetting the cursor
return <RenderFunctionComponent />; // render
}
console.log(state); // Pre-render: []
MyComponent();
console.log(state); // First-render: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // Subsequent-render: ['Rudi', 'Yardley']
// click the 'Fred' button
console.log(state); // After-click: ['Fred', 'Yardley']
React におけるレンダリングという単語の意味を理解することは重要。
レンダリングは DOM の更新でも、再描画のことでもない。
関数 (コンポーネント) 呼び出しである。
「レンダリング」とは、Reactがコンポーネントを呼び出すことだ。
コンポーネントをレンダリング(呼び出す)した後、ReactはDOMを変更する。
Reactは、レンダリングの間に違いがある場合にのみ、DOMノードを変更する。
React では描画のことをペイントと呼んでいる。
レンダリングが完了し、ReactがDOMを更新した後、ブラウザは画面を再描画します。このプロセスは「ブラウザ・レンダリング」として知られているが、ドキュメント全体の混乱を避けるために「ペイント」と呼ぶことにする。
なぜReactではステートの変異が推奨されないのか?
Reactは突然変異に依存しないため、オブジェクトに対して特別なことをする必要はない。多くの「リアクティブ」ソリューションが行うように、オブジェクトのプロパティを乗っ取ったり、常にプロキシに包んだり、初期化時に他の作業を行ったりする必要はない。Reactが、どんなに大きなオブジェクトであっても、パフォーマンスや正しさの落とし穴を増やすことなく、オブジェクトをステートに置くことができる理由もここにある。
同じ位置にある同じコンポーネントだから、Reactから見れば同じカウンターだ。
Reactにとって重要なのは、JSXマークアップ内ではなく、UIツリー内の位置である。
Reactは、あなたが関数内のどこに条件を配置したかを知りません。Reactが「見ている」のは、あなたが返すツリーだけです。
覚えておくべきルールとして、再レンダー間で state を維持したい場合、ツリーの構造はレンダー間で「合致」する必要があります。構造が異なる場合、React がツリーからコンポーネントを削除するときに state も破棄されてしまいます。
リストをレンダーする際に key を使ったのを覚えているでしょうか。key はリストのためだけのものではありません! どんなコンポーネントでも React がそれを識別するために使用できるのです。デフォルトでは、React は親コンポーネント内での順序(「1 番目のカウンタ」「2 番目のカウンタ」)を使ってコンポーネントを区別します。しかし、key を使うことで、カウンタが単なる 1 番目のカウンタや 2 番目のカウンタではなく特定のカウンタ、例えば Taylor のカウンタである、と React に伝えることができます。このようにして、React は Taylor のカウンタがツリーのどこにあっても識別できるようになるのです。
key を指定することで、親要素内の順序ではなく、key 自体を位置に関する情報として React に使用させることができます。これにより、JSX で同じ位置にレンダーしても、React はそれらを異なるカウンタとして認識するため、state が共有されてしまうことはありません。カウンタが画面に表示されるたびに、新しい state が作成されます。カウンタが削除されるたびに、その state は破棄されます。切り替えるたびに、何度でも state がリセットされます。
React は、同じコンポーネントが同じ位置でレンダーされている限り、state を保持する。
state は JSX タグに保持されるのではない。JSX を置くツリー内の位置に関連付けられている。
異なる key を与えることで、サブツリーの state をリセットするよう強制することができる。
useRefは内部でどのように機能しているのか?
useState とuseRef の両方がReactによって提供されているが、原理的にはuseState の上に useRef を実装することができる。Reactの内部では、useRef がこのように実装されていると想像できる
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
React コンポーネントに ref を渡したい時
<input /> のようなブラウザ要素を出力する組み込みコンポーネントに ref を置いた場合、React はその ref の current プロパティを、対応する DOM ノード(ブラウザの実際の <input /> など)にセットします。
ただし、独自のコンポーネント、例えば <MyInput /> に ref を置こうとすると、デフォルトでは null が返されます。以下はそれを示す例です。ボタンをクリックしても入力フィールドにフォーカスが当たらないことに注意してください。
import { useRef } from 'react';
function MyInput(props) {
return <input {...props} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
これは、デフォルトでは React は、コンポーネントが他のコンポーネントの DOM ノードにアクセスできないようにしているためです。自分自身の子でさえもです! これは意図的なものです。ただでさえ ref は控えめに使うべき避難ハッチ (escape hatch) です。別のコンポーネントの DOM ノードまで手動で操作できてしまうと、コードがさらに壊れやすくなってしまいます。
代わりに、内部の DOM ノードを意図的に公開したいコンポーネントは、そのことを明示的に許可する必要があります。コンポーネントは、自身が受け取った ref を子のいずれかに「転送 (forward)」するよう指定できます。MyInput が forwardRef API を使ってこれをどのように行うのか見てみましょう。
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
上記の例では、MyInput は元の DOM の input 要素を公開しています。これにより親コンポーネント側からその要素の focus() を呼び出すことができます。しかしこれにより、親コンポーネントが他のこと、例えば、CSS スタイルを変更することもできてしまいます。一般的なことではありませんが、公開される機能を制限したいということがあります。それには useImperativeHandle を使います。
import {
forwardRef,
useRef,
useImperativeHandle
} from 'react';
const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// Only expose focus and nothing else
focus() {
realInputRef.current.focus();
},
}));
return <input {...props} ref={realInputRef} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
flushSync
これだと最後の要素にスクロールされない。なぜなら setTodos で状態の更新がキューに入り listRef.current.lastChild
は newTodo
ではないから。
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
react-dom
からflushSync
をインポートして、こうするとうまくいく。flushSync
でラップされたコードが実行された直後に、Reactに同期してDOMを更新する。
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
useRef の値を useEffect の第二引数に入れるべきかについて
このエフェクトでは、ref と isPlaying の両方が使用されていますが、依存値として宣言されているのは isPlaying のみです。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
これは、ref オブジェクトが毎回同一のものだからです。React は、同じ useRef コールから常に同じオブジェクトが返されることを保証しています。これが変更されることはないため、それ自体がエフェクトの再実行を引き起こすことも決してありません。したがって、それを含めるかどうかは問題となりません。ただし含めても問題ありません:
useState によって返される set 関数も毎回全く同一のものであるため、依存配列から省略されることがよくあります。ある依存値を省略してもリンタのエラーが出ない場合は、それを行っても安全です。
毎回同一である値を依存配列から省略できるのは、リンタがそのオブジェクトが毎回同一であると「判断できる」場合のみです。例えば、ref が親コンポーネントから渡される場合は、依存配列にそれを指定する必要があります。親コンポーネントが常に同じ ref を渡すのか、それとも条件付きで違う ref から 1 つ選んで渡すのか、知ることはできないのですから、これは良いことです。あなたのエフェクトは、どの ref が渡されるかに確かに依存していることになります。
リデューサの語源
リデューサによりコンポーネント内のコード量を「削減 (reduce)」することもできますが、実際にはリデューサは配列で行うことができる reduce() という操作にちなんで名付けられています。
reduce() 操作とは、配列を受け取り、多くの値を 1 つの値に「まとめる」ことができるものです。
const arr = [1, 2, 3, 4, 5]; const sum = arr.reduce( (result, number) => result + number ); // 1 + 2 + 3 + 4 + 5
ここで reduce に渡している関数が “リデューサ” と呼ばれるものです。これは「ここまでの結果」と「現在の要素」を受け取り、「次の結果」を返す関数です。React のリデューサも同じアイディアを用いています。「ここまでの state」と「アクション」を受け取り、「次の state」を返します。このようにして、経時的に発生する複数のアクションを 1 つの state に「まとめて」いるわけです。
計算コストが高いかどうかを見分ける方法
一般的に、何千ものオブジェクトを作成したりループしたりしていない限り、おそらく高価ではありません。より確信を持ちたい場合は、コンソールログを追加して、コードの実行にかかった時間を計測することができます。
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
測定したいユーザ操作(例えば、入力フィールドへのタイプ)を実行します。その後、コンソールに filter array: 0.15ms のようなログが表示されます。全体のログ時間がかなりの量(例えば 1ms 以上)になる場合、その計算をメモ化する意味があるかもしれません。実験として useMemo で計算をラップしてみて、その操作に対する合計時間が減少したかどうかをログで確認できます。
console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');
また、ほとんどの場合に、あなたが使っているマシンは、ユーザのマシンより高速に動作するであろうことを忘れてはいけません。そのため、意図的に処理速度を低下させてパフォーマンスをテストするのが良いでしょう。例えば、Chrome では CPU スロットリングオプションが提供されています。
props が変更されたときに一部の state を調整する
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
このような前回のレンダーからの情報を保存する手法は理解しにくいかもしれませんが、エフェクトで同一の state を更新するよりはましです。上記の例では、setSelection はレンダー中に直接呼び出されます。React は return 文で終了した直後に List を再レンダーします。React はまだ List の子のレンダーや DOM の更新を行っていないので、これによって List の子が古くなった selection の値でレンダーされてしまうことを回避できます。
レンダー中にコンポーネントを更新すると、React は返り値の JSX を破棄して、すぐにレンダーを再試行します。非常に遅くなる連鎖的な再レンダーを避けるために、React はレンダー中に同じコンポーネントの state を更新することしか許可していません。レンダー中に別のコンポーネントの state を更新すると、エラーが表示されます。無限ループを避けるために、items !== prevItems のような条件が必要です。このタイプの state 調整は大丈夫ですが、他のあらゆる副作用(DOM の変更やタイムアウトの設定など)は、イベントハンドラやエフェクトに書き、コンポーネントを純粋に保つ必要があります。
このパターンはエフェクトよりも効率的ですが、ほとんどのコンポーネントではこれすらも必要ありません。どのように行っても、props や他の state に基づいて state を調整すると、データフローが理解しにくくなり、デバッグが難しくなります。代わりに常に、key ですべての state をリセットできないか、レンダー中にすべてを計算できないか、検討してください。例えば、選択されたアイテムを保存(およびリセット)する代わりに、選択されたアイテム ID を保存できます。
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
コンポーネントの視点から見ると、エフェクトは、“レンダー直後” や “アンマウント直前” のように特定のタイミングで発生する “コールバック関数” や “ライフサイクル中のイベント” であると考えたくなります。しかし、このような考え方はすぐにややこしくなるため、避けた方が無難です。
その代わりに、エフェクトの開始/終了という 1 サイクルのみにフォーカスしてください。コンポーネントがマウント中なのか、更新中なのか、はたまたアンマウント中なのかは問題ではありません。どのように同期を開始し、どのように同期を終了するのか、これを記述すれば良いのです。このことを意識するだけで、開始・終了が何度も繰り返されても、柔軟に対応できるエフェクトとなります。
useEffectEvent
useEffectEvent
はまだ実験段階だが便利。下の例では isMuted
を useEffect の依存関係には入れたくない。しかし、入れないと isMute
はずっと初期値の false になる。そんな時に useEffectEvent
が使える。
import { useState, useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
const onMessage = useEffectEvent(receivedMessage => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...
カスタムフック版
import { useEffect, useEffectEvent } from 'react';
// ...
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ All dependencies declared
}
エフェクトイベントは、イベントハンドラと非常に似たものだと考えることができます。主な違いは、イベントハンドラがユーザの操作に反応して実行されるのに対し、エフェクトイベントはエフェクトからトリガされることです。エフェクトイベントを使うことで、リアクティブであるエフェクトと、リアクティブであってはならないコードとの間の「繋がりを断ち切る」ことができます。
オフスクリーン
現在のところ、コンポーネントを非表示にしたり表示したりしたい場合、選択肢はふたつあります。ひとつは、ツリーから完全に削除するというものです。これによる問題は、アンマウントするたびに UI の state や、DOM 内に保持されているスクロール位置のような状態が失われてしまうことです。
もうひとつの選択肢は、コンポーネントをマウントしたままで CSS を使って表示・非表示を切り替える、というものです。これにより UI の state は保持されますが、React は非表示のコンポーネントやその子コンポーネントに更新があったときにそれをレンダーし続ける必要があるため、パフォーマンス面ではコストがかかります。
オフスクリーン (offscreen) 機能は第 3 の選択肢を提供します。UI を見た目に非表示とした上で、内容の更新の優先度を下げるのです。これは考え方の点では content-visibility CSS プロパティと似ています。コンテンツが非表示の場合、UI 内の他の要素と同期をとる必要はありません。React はレンダー作業を、アプリがアイドル状態になるかコンテンツが再び表示されるようになるまで遅延させることができます。
オフスクリーン機能は、高レベルの機能を実現するための低レベル機能です。startTransition のような React の他の並行レンダー機能と同様ですが、大抵の場合、あなたが直接オフスクリーン API を利用することはありません。代わりに、フレームワークが実装する以下のようなパターンを通じて利用することになるでしょう。
- 即時の画面遷移。現在でもルーティングフレームワークの中にはナビゲーションを高速化するため、リンクをホバーした際などにデータをプリフェッチするものが存在します。オフスクリーン機能を使えば、さらに後続の画面をバックグラウンドでプリレンダーしておくことが可能になります。
- ステートの再利用。同様に、オフスクリーン機能を使うことで、ページやタブを切り替えたときに前の画面の state を保持しておき、切り替えて戻ってきたときに前の状況を復元できるようになります。
- リストのレンダーの仮想化。大きなリストを表示している際に、リスト仮想化を提供するフレームワークでは現在見えているもの以外にも多くの項目をプリレンダーします。オフスクリーン機能を使えば、見えていない項目をリスト内の見えている項目よりも低優先度でプリレンダーすることができるようになります。
- 背景コンテンツ。また、モーダルをオーバーレイで表示している場合の背景要素など、非表示でないコンテンツのレンダー優先度を下げるような関連機能についても検討しています。
React 最適化コンパイラ
React の中核概念は、開発者が UI を現在の状態に対する関数として定義する、ということです。コンポーネントロジックは、プレーンな JavaScript の値(数値、文字列、配列、オブジェクト)、そして標準的な JavaScript のイディオム(if/else、for など)を使用して記述します。メンタルモデルは、アプリケーションの state が変更されるたびに React が再レンダーを行う、というものです。このシンプルなメンタルモデルや JavaScript のセマンティクスから離れないことが、React プログラミングモデルにおける重要な原則です。
問題は、React が時々過度にリアクティブになる、すなわち再レンダーが過剰になることがあるということです。たとえば、JavaScript では 2 つのオブジェクトや配列が同一である(同じキーと値を持っている)かどうかを比較する安価な方法がないため、レンダーのたびに新しいオブジェクトや配列を作成すると、React が本来必要である以上の作業を行うことがあります。これは、開発者がコンポーネントを明示的にメモ化して変更に対して過剰に反応しないようにする必要があることを意味します。
React Forget の目標は、React アプリがデフォルトでちょうどよい程度のリアクティビティを有することを保証することです。つまり、state の値に対して意味のある変更が行われたときにのみアプリが再レンダーされるようにします。実装の観点から言えば、自動的にメモ化するということですが、リアクティブ性という枠組みで React と Forget を捉えることが理解の上でより良い方法だと考えています。ひとつの考え方としてはこうです:React は現在、オブジェクトの同一性が変更されたときに再レンダーを行います。Forget を使うと、オブジェクトに意味のある値の変更があったときにのみ React が再レンダーを行うようになります。しかし深い比較によるランタイムコストをかけずに、です。
オフスクリーンレンダリング
重要なのは、コンポーネントの書き方を変えることなしに、あらゆる React ツリーをオフスクリーンでもレンダーできなければならない、ということです。コンポーネントをオフスクリーンでレンダーすると、表示されるまで実際にはマウントが起きず、副作用も実行されなくなります。例えば、初めて表示されたときに useEffect を使って分析データをログ出力するコンポーネントがある場合、プリレンダーのせいで分析データの正確性が乱されることはありません。同様に、コンポーネントがオフスクリーンになると、副作用もアンマウントされます。オフスクリーンレンダリングの重要な機能は、コンポーネントの表示/非表示を切り替えても state が失われないことです。
オフスクリーン(Activity に改名)
前回のアップデート以降に、“オフスクリーン (Offscreen)” という研究中機能の名称を”Activity” に変更しました。「オフスクリーン」という名前は、アプリの見えない部分にのみこれが適用されるという誤った印象を与えるものでしたが、この機能を研究する中で、例えばモーダルの背後のコンテンツなど、アプリの一部は見えていても非アクティブになる可能性があることに気づきました。新しい名前は、アプリの特定の部分を「アクティブ」または「非アクティブ」とマークするという動作を、より正確に反映しています。