前書き
まず、Live reloading と Hot reloading の違いを紹介します。
-
Live reloading: ファイルを変更すると、Webpack が再コンパイルし、ブラウザを強制的にリロードします。これはアプリ全体(グローバル)のリフレッシュに相当し、
window.location.reload()
と同じ動作をします。 - Hot reloading: ファイルを変更すると、Webpack が該当モジュールのみを再コンパイルし、アプリの状態を維持しながら局所的なリフレッシュを行います。
概要
Fast Refresh は、React 公式が React Native(v0.6.1) に導入したモジュールホットリプレースメント(HMR)の仕組みです。
この仕組みの核となる実装はプラットフォーム非依存であるため、Fast Refresh は Web 環境 でも利用可能です。
リフレッシュ戦略
- React コンポーネント のみをエクスポートするモジュールファイル を編集した場合、Fast Refresh はそのモジュールのコードだけを更新し、コンポーネントを再レンダリングします。スタイル、レンダリングロジック、イベントハンドラ、
useEffect
など、あらゆる変更が適用されます。 -
React コンポーネントをエクスポートしないモジュール を編集した場合、Fast Refresh はそのモジュールを再実行し、それをインポートしている他のモジュールも更新します。
例えば、Button.js
とModal.js
の両方がTheme.js
をインポートしている場合、Theme.js
を編集するとButton.js
とModal.js
も更新されます。 -
React のレンダーツリーの外部でインポートされているモジュールを編集した場合、Fast Refresh は完全なページリフレッシュにフォールバックします。
例えば、あるファイルが React コンポーネントをレンダリングする一方で、他の非 React モジュールにも値をエクスポートしている場合、このファイルを編集すると完全なリロードが発生します。このようなケースでは、クエリやロジックを別ファイルに分離し、それを両方のモジュールにインポートすることで、Fast Refresh を適用できるようになります。
エラーハンドリング
- 構文エラー が発生した場合、エラーを修正してファイルを保存すれば、エラー警告(Redbox)は消えます。構文エラーのあるモジュールは実行がブロックされるため、アプリをリロードする必要はありません。
-
モジュールの初期化時にランタイムエラーが発生した場合(例:
StyleSheet.create
をStyle.create
と誤記)、エラーを修正すると Fast Refresh のセッションが継続し、エラー警告が消え、モジュールが更新されます。 - コンポーネント内部でランタイムエラーが発生した場合 も、エラーを修正すれば Fast Refresh のセッションは継続します。この場合、React は更新後のコードを使用してアプリを再マウントします。
- エラー境界(Error Boundary)内でランタイムエラーが発生した場合、エラーを修正すると Fast Refresh はエラー境界内のノードを再レンダリングします。
制限事項
Fast Refresh はコンポーネントの state を安全な範囲で保持 します。ただし、以下の場合は state がリセットされます。
- クラスコンポーネントのローカル state は保持されません(state を保持できるのは関数コンポーネントと Hooks のみ)。
- 編集中のモジュールに React コンポーネント以外のエクスポート がある場合。
- 高階コンポーネント(HOC)が使用されている場合(例:
createNavigationContainer(MyScreen)
)。返されるコンポーネントがクラスコンポーネントである場合、state はリセットされます。
関数コンポーネントと Hooks の利用が増えるにつれ、Fast Refresh の編集体験はよりスムーズになります。
ヒント
- Fast Refresh はデフォルトで 関数コンポーネント(および Hooks) の state を保持します。
-
マウント時にのみ発生するアニメーションをデバッグしている場合、state をリセットしてコンポーネントを強制的に再マウントしたいことがあります。このような場合、ファイル内のどこかに
// @refresh reset
を追加すると、Fast Refresh は編集のたびにそのファイル内のコンポーネントを再マウントします。
Hooks
Fast Refresh は、可能な限り コンポーネントの state を保持しながらリフレッシュ します。
特に useState
と useRef
は、引数や Hooks の呼び出し順が変わらなければ、以前の値を保持します。
ただし、依存関係を持つ Hooks(例:useEffect
, useMemo
, useCallback
)は Fast Refresh 時に常にリフレッシュされます。
Fast Refresh がトリガーされた際、これらの Hooks の 依存リストは無視されます。
例えば、次のような場合を考えてみましょう。
useMemo(() => x * 2, [x]);
これを以下のように変更すると、
useMemo(() => x * 10, [x]);
たとえ x
が変更されていなくても、関数(factory
)は再実行されます。
もし React がこの処理を行わなかった場合、変更は画面に反映されません。
この仕組みは、時には予期しない動作を引き起こすことがあります。
例えば、useEffect
の依存リストが 空配列([]
) であっても、Fast Refresh 中に再実行されます。
しかし、これは実際には問題ではありません。
Fast Refresh がなくても、useEffect
がたまに再実行される状況を想定してコーディングするのが望ましいです。
このようにしておけば、後から新しい依存関係を追加するときにも柔軟に対応できます。
実装
HMR(モジュール単位)、React Hot Loader(制約付きのコンポーネント単位)よりも さらに細かい粒度 でのホットリロードを実現するためには、コンポーネントレベル、さらには Hooks レベルでの信頼性のある更新が求められます。
これは外部の仕組み(追加のランタイムやコンパイル変換)だけでは実現が難しく、React の深い協力 が必要になります。
Fast Refresh is a reimplementation of “hot reloading” with full support from React.
(Fast Refresh は、React による完全なサポートを受けた「ホットリロード」の再実装です。)
つまり、以前の HMR では回避できなかった問題(例えば Hooks の扱い)も、React の対応によって解決できるようになったのです。
Fast Refresh の構成
Fast Refresh の実装は、HMR の仕組みを基盤にしています。
以下の 4 つの階層 から構成されています。
-
HMR の仕組み(例:
webpack HMR
) -
コンパイル時の変換(
react-refresh/babel
) -
補助ランタイム(
react-refresh/runtime
) -
React の対応(
React DOM 16.9+
またはreact-reconciler 0.21.0+
)
React Hot Loader との違い
従来の React Hot Loader では、コンポーネントの上に Proxy を設置することで状態を保持していましたが、Fast Refresh では React 自体が直接対応 するようになりました。
以前は、コンポーネントの状態を保持するために Proxy Component を利用して render
部分を置き換える必要がありましたが、新しい React では関数コンポーネントや Hooks のホットリロードが ネイティブでサポート されています。
Fast Refresh は、Babel プラグイン と ランタイム の 2 つの部分に分かれており、どちらも react-refresh
パッケージ内で管理されています。
それぞれ、以下のエントリーポイントから利用されます。
-
Babel プラグイン →
react-refresh/babel
-
ランタイム →
react-refresh/runtime
Babel プラグインはコンパイル時に何をするのか?
簡単に言うと、Fast Refresh の Babel プラグイン は、すべての コンポーネントとカスタム Hooks を特定し、それらを登録するためのコードを挿入します。
具体的には、コンポーネントの登録 と カスタム Hooks の署名の収集 を行う関数呼び出しをコード内に埋め込みます。
変換前のコード(オリジナル)
function useFancyState() {
const [foo, setFoo] = React.useState(0);
useFancyEffect();
return foo;
}
const useFancyEffect = () => {
React.useEffect(() => {});
};
export default function App() {
const bar = useFancyState();
return <h1>{bar}</h1>;
}
変換後のコード(Babel による処理後)
var _s = $RefreshSig$(),
_s2 = $RefreshSig$(),
_s3 = $RefreshSig$();
function useFancyState() {
_s();
const [foo, setFoo] = React.useState(0);
useFancyEffect();
return foo;
}
_s(useFancyState, 'useState{ct{}', false, function () {
return [useFancyEffect];
});
const useFancyEffect = () => {
_s2();
React.useEffect(() => {});
};
_s2(useFancyEffect, 'useEffect{}');
export default function App() {
_s3();
const bar = useFancyState();
return <h1>{bar}</h1>;
}
_s3(App, 'useFancyState{bar}', false, function () {
return [useFancyState];
});
_c = App;
var _c;
$RefreshReg$(_c, 'App');
ランタイム(Runtime)は実行時にどのように動作するのか?
Babel プラグインがコードに挿入する $RefreshSig$
や $RefreshReg$
という関数は、
実際にはランタイム(react-refresh/runtime
)によって提供される関数 です。
例えば、以下のように react-refresh/runtime
をインポートして、グローバル関数を設定します。
var RefreshRuntime = require('react-refresh/runtime');
window.$RefreshReg$ = (type, id) => {
// `module.id` は Webpack 固有の識別子(他のバンドラーでは異なる可能性あり)
const fullId = module.id + ' ' + id;
RefreshRuntime.register(type, fullId);
};
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
それぞれ以下のような役割を持ちます。
-
$RefreshSig$
→ カスタム Hooks の署名を収集 -
$RefreshReg$
→ コンポーネントを登録
この $RefreshSig$
は react-refresh/runtime
の createSignatureFunctionForTransform
から提供され、Hooks の情報を記録します。
export function createSignatureFunctionForTransform() {
let savedType;
let hasCustomHooks;
let didCollectHooks = false;
return function <T>(
type: T,
key: string,
forceReset?: boolean,
getCustomHooks?: () => Array<Function>
): T | void {
if (typeof key === 'string') {
if (!savedType) {
savedType = type;
hasCustomHooks = typeof getCustomHooks === 'function';
}
if (type != null && (typeof type === 'function' || typeof type === 'object')) {
setSignature(type, key, forceReset, getCustomHooks);
}
return type;
} else {
if (!didCollectHooks && hasCustomHooks) {
didCollectHooks = true;
collectCustomHooksForSignature(savedType);
}
}
};
}
一方、$RefreshReg$
は register
関数を通じてコンポーネントの情報を管理します。
export function register(type: any, id: string): void {
let family = allFamiliesByID.get(id);
if (family === undefined) {
family = { current: type };
allFamiliesByID.set(id, family);
} else {
pendingUpdates.push([family, type]);
}
allFamiliesByType.set(type, family);
}
この pendingUpdates
に追加された変更は、performReactRefresh()
が呼ばれた際に適用され、React に伝えられます。
function resolveFamily(type) {
return updatedFamiliesByType.get(type);
}
React はどのように対応しているのか?
Fast Refresh のランタイムは、実際には React の内部 API に依存しています。
import type {
Family,
RefreshUpdate,
ScheduleRefresh,
ScheduleRoot,
FindHostInstancesForRefresh,
SetRefreshHandler,
} from 'react-reconciler/src/ReactFiberHotReloading';
特に重要なのが setRefreshHandler
であり、これを通じて React に更新を通知します。
export const setRefreshHandler = (handler: RefreshHandler | null): void => {
if (__DEV__) {
resolveFamily = handler;
}
};
performReactRefresh()
内で、このハンドラを React に渡します。
export function performReactRefresh(): RefreshUpdate | null {
const update: RefreshUpdate = {
updatedFamilies, // 更新されたコンポーネント群(state を保持)
staleFamilies, // 再マウントが必要なコンポーネント群
};
helpersByRendererID.forEach((helpers) => {
helpers.setRefreshHandler(resolveFamily);
});
failedRootsSnapshot.forEach((root) => {
const helpers = helpersByRootSnapshot.get(root);
const element = rootElements.get(root);
helpers.scheduleRoot(root, element);
});
mountedRootsSnapshot.forEach((root) => {
const helpers = helpersByRootSnapshot.get(root);
helpers.scheduleRefresh(root, update);
});
}
React は、渡された resolveFamily
を使って、更新された関数コンポーネントと Hooks を取得し、更新を適用します。
export function resolveFunctionForHotReloading(type: any): any {
const family = resolveFamily(type);
if (family === undefined) {
return type;
}
return family.current;
}
そして、実際のコンポーネント更新時に resolveFunctionForHotReloading
を利用して最新の関数を取得します。
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
switch (workInProgress.tag) {
case IndeterminateComponent:
case FunctionComponent:
case SimpleMemoComponent:
workInProgress.type = resolveFunctionForHotReloading(current.type);
break;
case ClassComponent:
workInProgress.type = resolveClassForHotReloading(current.type);
break;
case ForwardRef:
workInProgress.type = resolveForwardRefForHotReloading(current.type);
break;
default:
break;
}
}
HMR(ホットモジュールリプレースメント)との統合
これまでに説明した仕組みだけでは、Fast Refresh は単体では動作しません。
実際には HMR(ホットモジュールリプレースメント) の仕組みと連携することで、完全なホットリロードが実現されます。
Fast Refresh を機能させるには、バンドラー(例:Webpack) の HMR と適切に接続する必要があります。
具体的には、以下の 3 つのステップを実施します。
1. react-refresh/runtime
をアプリのエントリーポイントに注入
通常、React アプリのエントリーポイント(index.js
や main.js
など)で react-refresh/runtime
をインポートします。
const runtime = require('react-refresh/runtime');
// React からフックを取得できるようにグローバルフックを注入
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
2. 各モジュールの前後にコードを挿入
各 React モジュールに対して、Fast Refresh のための登録コードを追加します。
window.$RefreshReg$ = (type, id) => {
const fullId = module.id + ' ' + id;
RefreshRuntime.register(type, fullId);
};
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
try {
// !!!
// ...ここに実際のモジュールのコードが入る...
// !!!
} finally {
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
}
このコードにより、すべてのモジュールが Fast Refresh の仕組みに適応されます。
3. HMR API に接続
すべてのモジュールの処理が完了した後、HMR の API を利用して更新を適用します。
const myExports = module.exports;
if (isReactRefreshBoundary(myExports)) {
module.hot.accept(); // バンドラー(Webpack など)によって異なる
const runtime = require('react-refresh/runtime');
// 更新頻度を抑えるためのデバウンス処理
let enqueueUpdate = debounce(runtime.performReactRefresh, 30);
enqueueUpdate();
}
ここで isReactRefreshBoundary
という関数を使って、更新対象のモジュールが React コンポーネントを含むかどうか をチェックします。
対象外のモジュール(CSS など)は、通常の HMR(または完全リロード)にフォールバックします。
Fast Refresh の活用
Fast Refresh はもともと React Native 向け に開発されたものですが、その実装は プラットフォーム非依存 であるため、
Web 環境でも簡単に利用できます。
"It’s originally shipping for React Native but most of the implementation is platform-independent."
(もともとは React Native 向けにリリースされたが、その実装の大部分はプラットフォーム非依存である。)
つまり、React Native の Metro バンドラー を Webpack などのバンドラーに置き換えれば、
Web でも Fast Refresh を利用できます。
私たちはLeapcell、Node.jsプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ