🔥 「なぜかコンポーネントが意図通りに動かない...」
こんな経験、ありませんか?実は、Reactには暗黙のルールがあり、それを知らずにコードを書いていると、予測不能なバグに悩まされることになります。
今回は、facebook/reactリポジトリのソースコードを深掘りしながら、React Compilerが内部でどのようにこれらのルールを検証しているのかを含めて解説します!
1. Rules of Reactとは?
Rules of Reactとは、Reactアプリケーションを正しく動作させるために守るべき基本的なルール群です。これらのルールを守ることで:
- コードの理解とデバッグが容易になる
- Reactがコンポーネントとフックを正しく最適化できる
- React Compilerによる自動最適化が可能になる
React Compilerについて
facebook/reactリポジトリのcompiler/README.mdには以下のように記載されています:
React Compiler is a compiler that optimizes React applications, ensuring that only the minimal parts of components and hooks will re-render when state changes. The compiler also validates that components and hooks follow the Rules of React.
つまり、React CompilerはRules of Reactを遵守しているかどうかを検証し、遵守しているコードを最適化します。
主要なルール一覧
| ルール | 説明 |
|---|---|
| 純粋性(Purity) | コンポーネントとHooksは純粋でなければならない |
| 冪等性(Idempotency) | 同じ入力に対して常に同じ結果を返す |
| 副作用の分離 | 副作用はレンダリング外で実行する |
| 不変性(Immutability) | PropsとStateは不変として扱う |
| Hooksの不変性 | Hooksの引数と戻り値は不変として扱う |
| JSXの不変性 | JSXに渡された値は変更してはいけない |
2. なぜ純粋性が重要なのか?
純粋なコンポーネントやフックとは、以下の特性を持つものです:
- 冪等(べきとう)である - 同じ入力(props, state, context)に対して常に同じ結果を返す
- レンダリング中に副作用を持たない - 副作用はイベントハンドラやEffectで実行する
- ローカル以外の値を変更しない - レンダリング中に作成されていない値を変更しない
なぜこれが重要なのか?
Reactは純粋性を前提として、以下のような最適化を行います:
レンダリングが純粋
→ Reactは更新の優先順位を理解できる
→ ユーザーにとって重要な更新を先に表示できる
→ 重要でない更新は後回しにできる
React Compilerの設計目標
compiler/docs/DESIGN_GOALS.mdには、以下の目標が記載されています:
"Just work" on idiomatic React code that follows React's rules (pure render functions, the rules of hooks, etc).
Reactのルールに従った慣用的なコードは、特別な設定なしで「ただ動く」ことを目指しています。
純粋でないコードの問題
// 🔴 Bad: グローバル変数を変更している
let count = 0;
function Counter() {
count = count + 1; // レンダリングごとにcountが増える!
return <p>カウント: {count}</p>;
}
Reactがこのコンポーネントを複数回レンダリングすると、countは予測不能な値になります。
3. コンポーネントとHooksは冪等でなければならない
冪等性(Idempotency)(べきとうせい) とは、同じ入力に対して常に同じ出力を返す性質です。
冪等でないコードの例
// 🔴 Bad: 毎回異なる結果を返す
function Clock() {
const time = new Date(); // 呼び出すたびに異なる値!
return <span>{time.toLocaleString()}</span>;
}
new Date()は呼び出すたびに異なる値を返すため、このコンポーネントは冪等ではありません。
React Compilerによる検出
facebook/reactリポジトリでは、React Compilerがこのような不純な関数呼び出しを検出します:
// compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-impure-functions-in-render.expect.md
function Component() {
const date = Date.now(); // 🔴 Error!
const now = performance.now(); // 🔴 Error!
const rand = Math.random(); // 🔴 Error!
return <Foo date={date} now={now} rand={rand} />;
}
このコードをReact Compilerでコンパイルすると、以下のようなエラーが発生します:
Error: Cannot call impure function during render
`Date.now` is an impure function. Calling an impure function can produce
unstable results that update unpredictably when the component happens to
re-render.
検出される不純な関数
React Compilerは以下の関数をレンダリング中に呼び出すとエラーを報告します:
Date.now()performance.now()Math.random()-
new Date()(引数なし)
::
正しい実装方法
冪等でない処理は、Effectを使ってレンダリング外で実行します:
// ✅ Good: Effectを使って非冪等な処理を分離
import { useState, useEffect } from 'react';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date()); // ✅ レンダリング外で実行
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
function Clock() {
const time = useTime();
return <span>{time.toLocaleString()}</span>;
}
4. 副作用はレンダリング外で実行する
副作用(Side Effect) とは、関数の主要な結果(戻り値)以外の、観測可能な効果を持つコードです。
:::note info
副作用(Side Effect)とEffect(useEffect)の違い
- 副作用(Side Effect): 関数の主要な結果以外の観測可能な効果を持つコード全般を指す広い用語
-
Effect:
useEffectでラップされたコードを特に指す
副作用は通常、イベントハンドラやEffectの中に書かれます。レンダリング中には決して書いてはいけません。
Reactがコードをどのように実行するか
Reactは宣言的です。あなたがReactに「何をレンダリングするか」を伝えると、Reactがそれをユーザーに最適な方法で表示する方法を決定します。
Reactのコード実行には複数のフェーズがあります:
- レンダリング: UIの次のバージョンがどのように見えるべきかを計算する
- Effectのフラッシュ: レンダリング後にEffectが実行され、レイアウトに影響がある場合は計算を更新
- コミット: 新しい計算と前回のUIの計算を比較し、DOMに最小限の変更を適用
レンダリング中に実行されるコードの見分け方
一般的なヒューリスティック:コードがファイルのトップレベルにある場合、それはレンダリング中に実行される可能性が高いです。
副作用の例
- DOM操作
- ネットワークリクエスト
- タイマーの設定
- ログ出力
- グローバル変数の変更
レンダリング中に副作用を書いてはいけない理由
Reactはコンポーネントを複数回レンダリングすることがあります:
- Concurrent Mode: ユーザー体験を向上させるため、Reactは複数のバージョンのUIを並行して準備することがあります
- Strict Mode: 開発中に問題を検出するため、コンポーネントを2回レンダリングします
- Suspense: データ取得中に一時停止し、後で再開することがあります
// 🔴 Bad: レンダリング中にDOMを変更
function ProductDetailPage({ product }) {
document.title = product.title; // レンダリングごとに実行される!
return <div>{product.title}</div>;
}
正しい実装方法
副作用はイベントハンドラまたはEffectで実行します:
// ✅ Good: Effectを使ってDOMを同期
function ProductDetailPage({ product }) {
useEffect(() => {
document.title = product.title;
}, [product.title]);
return <div>{product.title}</div>;
}
イベントハンドラ vs Effect
- イベントハンドラ: ユーザーの操作に応じて副作用を実行する場合
- Effect: レンダリング後に外部システムと同期する必要がある場合
可能な限りイベントハンドラを使用し、Effectは最後の手段として使用してください。
5. ミューテーションが許可される場合
「ミューテーション(変更)」とは、JavaScriptでプリミティブでない値を変更することを指します。
ローカルミューテーションは許可される
レンダリング中にローカルで作成した値を変更することは許可されています:
// ✅ Good: ローカルで作成した配列への変更
function FriendList({ friends }) {
const items = []; // ローカルで作成
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // ✅ ローカルミューテーションはOK
}
return <section>{items}</section>;
}
itemsはレンダリング中に作成され、このレンダリング内でのみ使用されるため、変更しても問題ありません。
許可されないミューテーション
コンポーネント外で作成された値を変更することは許可されていません:
// 🔴 Bad: コンポーネント外で作成された配列への変更
const items = []; // コンポーネント外で作成
function FriendList({ friends }) {
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push( // 🔴 レンダリングごとにitemsが増え続ける!
<Friend key={friend.id} friend={friend} />
);
}
return <section>{items}</section>;
}
React Compilerのミューテーション追跡
facebook/reactリポジトリのcompiler/packages/babel-plugin-react-compiler/src/Inference/MUTABILITY_ALIASING_MODEL.mdでは、ミューテーションとエイリアシングの追跡方法が詳細に説明されています:
## Mutation and Aliasing Effects
The inference model is based on a set of "effects" that describe subtle
aspects of mutation, aliasing, and other changes to the state of values
over time.
主要なエフェクトには以下があります:
| エフェクト | 説明 |
|---|---|
Create |
新しい値の作成 |
Mutate |
値の変更(ネストされた値は含まない) |
MutateTransitive |
値とそのネストされた値すべての変更 |
Capture |
値への参照を別の値に格納 |
Freeze |
値の凍結(Reactに渡された後は変更不可) |
Freezeエフェクトについて
値がReactに渡されると(JSXのprop、フックの引数など)、その値は「凍結」されたとみなされます:
Once a reference to a value has been passed to React, that value is generally not safe to mutate further.
これは、Reactがその値をメモ化やEffectの依存配列に使用する可能性があるためです。
遅延初期化は許可される
コンポーネント内での遅延初期化も許可されています:
// ✅ Good: 遅延初期化
function ExpenseForm() {
SuperCalculator.initializeIfNotReady(); // ✅ 他のコンポーネントに影響しなければOK
// レンダリング続行...
}
他のコンポーネントに影響を与えない限り、初期化処理を行うことは許可されます。
厳密な純粋性 vs Reactにとっての純粋性
コンポーネントを複数回呼び出しても安全で、他のコンポーネントのレンダリングに影響しない限り、Reactは厳密な関数型プログラミングの意味で100%純粋かどうかを気にしません。コンポーネントが冪等であることがより重要です。
6. PropsとStateは不変である
コンポーネントのpropsとstateは不変のスナップショットです。直接変更してはいけません。代わりに、新しいpropsを渡すか、useStateのセッター関数を使用してください。
propsとstateの値は、レンダリング後に更新されるスナップショットと考えることができます。そのため、propsやstate変数を直接変更するのではなく、新しいpropsを渡すか、セッター関数を使用して、次のレンダリング時にstateを更新するようReactに伝えます。
Propsを変更してはいけない
Propsは不変です。Propsを変更すると、アプリケーションは一貫性のない出力を生成し、状況によって動作したりしなかったりするため、デバッグが困難になります。
// 🔴 Bad: propsを直接変更している
function Post({ item }) {
item.url = new Url(item.url, base); // propsを変更!
return <Link url={item.url}>{item.title}</Link>;
}
// ✅ Good: コピーを作成して使用
function Post({ item }) {
const url = new Url(item.url, base); // コピーを作成
return <Link url={url}>{item.title}</Link>;
}
Stateを変更してはいけない
useStateはstate変数とそれを更新するセッター関数を返します。
const [stateVariable, setter] = useState(0);
state変数を直接更新するのではなく、useStateが返すセッター関数を使用して更新する必要があります。state変数の値を変更してもコンポーネントは更新されず、ユーザーには古いUIが表示されたままになります。セッター関数を使用することで、Reactにstateが変更されたことを通知し、UIを更新するために再レンダリングをキューに入れる必要があることを伝えます。
// 🔴 Bad: stateを直接変更している
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
count = count + 1; // stateを直接変更!
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
// ✅ Good: セッター関数を使用
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // セッター関数で更新
}
return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
7. Hooksの引数と戻り値は不変である
値がフックに渡されると、その値を変更してはいけません。JSXのpropsと同様に、値はフックに渡されると不変になります。
// 🔴 Bad: フックの引数を直接変更している
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
if (icon.enabled) {
icon.className = computeStyle(icon, theme); // 引数を変更!
}
return icon;
}
// ✅ Good: コピーを作成して使用
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
const newIcon = { ...icon }; // コピーを作成
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}
ローカル推論の原則
Reactで重要な原則の一つは**ローカル推論(Local Reasoning)**です。これは、コンポーネントやフックのコードを単独で見て、その動作を理解できる能力のことです。
フックは呼び出されるとき「ブラックボックス」として扱われるべきです。例えば、カスタムフックは内部で値をメモ化するために引数を依存配列として使用している可能性があります:
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
return useMemo(() => {
const newIcon = { ...icon };
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}, [icon, theme]);
}
フックの引数を変更すると、カスタムフックのメモ化が正しく機能しなくなります:
style = useIconStyle(icon); // `style`は`icon`に基づいてメモ化
icon.enabled = false; // 🔴 Bad: フックの引数を直接変更
style = useIconStyle(icon); // 以前のメモ化された結果が返される(バグ!)
style = useIconStyle(icon); // `style`は`icon`に基づいてメモ化
icon = { ...icon, enabled: false }; // ✅ Good: コピーを作成
style = useIconStyle(icon); // 新しい`style`の値が計算される
同様に、フックの戻り値もメモ化されている可能性があるため、変更してはいけません。
8. JSXに渡された値は不変である
JSXで使用した後は、値を変更してはいけません。変更はJSXが作成される前に行ってください。
JSXを式で使用すると、ReactはコンポーネントのレンダリングがFinishする前にJSXを先行評価することがあります。これは、JSXに渡された後に値を変更すると、Reactがコンポーネントの出力を更新することを知らないため、古いUIになる可能性があることを意味します。
// 🔴 Bad: JSXに渡した後に値を変更
function Page({ colour }) {
const styles = { colour, size: "large" };
const header = <Header styles={styles} />;
styles.size = "small"; // 🔴 stylesは既に上のJSXで使用された!
const footer = <Footer styles={styles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}
// ✅ Good: 新しい値を作成
function Page({ colour }) {
const headerStyles = { colour, size: "large" };
const header = <Header styles={headerStyles} />;
const footerStyles = { colour, size: "small" }; // 新しい値を作成
const footer = <Footer styles={footerStyles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}
まとめ
- 純粋性が重要な理由: Reactの最適化機能を正しく動作させるため
- 冪等性: 同じ入力に対して常に同じ出力を返す
- 副作用の分離: レンダリング外で副作用を実行する
- ローカルミューテーション: レンダリング中に作成した値の変更は許可される
- PropsとStateの不変性: 直接変更せず、セッター関数や新しい値を使用する
- Hooksの不変性: 引数と戻り値は不変として扱い、ローカル推論を可能にする
- JSXの不変性: JSXに渡した後は値を変更しない