HMR を超えて: React の Fast Refresh を理解する

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967621/14fa77d5-7679-4b2b-b9d0-c74900b1fc07.png)](https://leapcell.io/?lc_t=q_reactrefresh)

## 前書き

まず、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 の **依存リストは無視されます**。

例えば、次のような場合を考えてみましょう。

```js
useMemo(() => x * 2, [x]);
```

これを以下のように変更すると、

```js
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 つの階層** から構成されています。

1. **HMR の仕組み**（例：`webpack HMR`）
2. **コンパイル時の変換**（`react-refresh/babel`）
3. **補助ランタイム**（`react-refresh/runtime`）
4. **React の対応**（`React DOM 16.9+` または `react-reconciler 0.21.0+`）

**React Hot Loader との違い**  
従来の React Hot Loader では、コンポーネントの上に Proxy を設置することで状態を保持していましたが、Fast Refresh では **React 自体が直接対応** するようになりました。

![Process](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967621/f6cd0301-9488-43f9-8b9d-15624c9c3cbe.png)

以前は、コンポーネントの状態を保持するために **Proxy Component** を利用して `render` 部分を置き換える必要がありましたが、新しい React では関数コンポーネントや Hooks のホットリロードが **ネイティブでサポート** されています。

Fast Refresh は、**Babel プラグイン** と **ランタイム** の 2 つの部分に分かれており、どちらも `react-refresh` パッケージ内で管理されています。  
それぞれ、以下のエントリーポイントから利用されます。

- **Babel プラグイン** → `react-refresh/babel`
- **ランタイム** → `react-refresh/runtime`

### Babel プラグインはコンパイル時に何をするのか？

簡単に言うと、Fast Refresh の **Babel プラグイン** は、すべての **コンポーネントとカスタム Hooks** を特定し、それらを登録するためのコードを挿入します。  
具体的には、**コンポーネントの登録** と **カスタム Hooks の署名の収集** を行う関数呼び出しをコード内に埋め込みます。

#### 変換前のコード（オリジナル）

```js
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 による処理後）

```js
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` をインポートして、グローバル関数を設定します。

```js
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 の情報を記録します。

```js
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` 関数を通じてコンポーネントの情報を管理します。

```js
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 に伝えられます。

```js
function resolveFamily(type) {
  return updatedFamiliesByType.get(type);
}
```

### React はどのように対応しているのか？

Fast Refresh のランタイムは、実際には **React の内部 API** に依存しています。

```js
import type {
  Family,
  RefreshUpdate,
  ScheduleRefresh,
  ScheduleRoot,
  FindHostInstancesForRefresh,
  SetRefreshHandler,
} from 'react-reconciler/src/ReactFiberHotReloading';
```

特に重要なのが `setRefreshHandler` であり、これを通じて React に更新を通知します。

```js
export const setRefreshHandler = (handler: RefreshHandler | null): void => {
  if (__DEV__) {
    resolveFamily = handler;
  }
};
```

`performReactRefresh()` 内で、このハンドラを React に渡します。

```js
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 を取得し、更新を適用します。

```js
export function resolveFunctionForHotReloading(type: any): any {
  const family = resolveFamily(type);
  if (family === undefined) {
    return type;
  }
  return family.current;
}
```

そして、実際のコンポーネント更新時に `resolveFunctionForHotReloading` を利用して最新の関数を取得します。

```js
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` をインポートします。

```js
const runtime = require('react-refresh/runtime');

// React からフックを取得できるようにグローバルフックを注入
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
```

#### 2. 各モジュールの前後にコードを挿入

各 React モジュールに対して、Fast Refresh のための登録コードを追加します。

```js
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 を利用して更新を適用します。

```js
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プロジェクトのホスティングの最適解です。](https://leapcell.io/?lc_t=q_reactrefresh)

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967621/f137cc82-6c43-425b-9d5c-5c69678be9f9.png)](https://leapcell.io/?lc_t=q_reactrefresh)

[Leapcell](https://leapcell.io/?lc_t=q_reactrefresh)は、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです：

**複数言語サポート**

- Node.js、Python、Go、Rustで開発できます。

**無制限のプロジェクトデプロイ**

- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

**比類のないコスト効率**

- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。

**洗練された開発者体験**

- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

**簡単なスケーラビリティと高パフォーマンス**

- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。

[ドキュメントで詳細を確認！](https://docs.leapcell.io/)

[![Try Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967621/b853a627-5a80-4086-b69f-6d84f984ab3f.png)](https://leapcell.io/?lc_t=q_reactrefresh)

Xでフォローする：[@LeapcellHQ](https://x.com/LeapcellHQ)

---

[ブログでこの記事を読む](https://leapcell.io/blog/react-fast-refresh)