Reactをやめて3KBのPreactにした理由 - 37KB削減で得たものと失ったもの

に公開

結論から:3KBで十分だった

Chrome拡張機能「Mark It Down」では、ReactではなくPreactを採用した。

ライブラリ サイズ(minified + gzip)
React + React DOM ~40KB
Preact ~3KB
差分 37KB(92%削減)

この37KBの差が、毎回タブを開くたびに効いてくる。

本記事では、Preactを選んだ理由、移行で遭遇した問題、そして「いつReactを使うべきか」の判断基準を共有する。

この記事で得られること

  • Chrome拡張機能でReactではなくPreactを選ぶべき理由と判断基準
  • Preactへの移行で遭遇する型の違いと具体的な解決策
  • Hooks互換性の実態と注意点(useTransitionなど非対応Hooksの一覧)
  • 移行コストの実測値(所要時間、変更箇所数)
  • 「いつPreactを使うべきか」「いつReactを使うべきか」のマトリクス

なぜバンドルサイズにこだわるのか

Chrome拡張機能の特殊性

通常のWebアプリと違い、Chrome拡張機能には独自の制約がある。

1. 毎回起動する

新しいタブを開くたび、Side Panelを開くたびにJavaScriptが実行される。SPAのように「一度開いて長時間使う」パターンではない。

[通常のSPA]
ロード(1回) → 長時間使用 → 閉じる

[Chrome拡張 New Tab Override]
タブ開く → ロード → 閉じる → タブ開く → ロード → ...

ロードの回数が桁違いに多い。

2. キャッシュが効きにくい

拡張機能のアップデートでキャッシュが無効化される。Chrome Web Storeは週1〜2回のアップデートを推奨しており、キャッシュに頼る設計は危険だ。

3. ユーザーの期待値が高い

新しいタブは「瞬時に開く」ことが期待されている。Chromeのデフォルト新規タブは即座に表示される。それに近い体験を提供する必要がある。

37KBの実際の影響

37KBがユーザー体験にどう影響するか、計測してみた。

パース + コンパイル時間: ~10-20ms(低スペックPCでは~50ms)
ネットワーク転送: ~15ms(HTTP/2、圧縮済み)
合計: ~25-65ms(デバイスによる)

単体では「誤差」に見えるかもしれない。しかし、Mark It Downの初期バンドルは他のライブラリも含めて約320KB(gzip: 84KB)ある。Reactを使っていたら+37KBで121KBになっていた。

これは44%増だ。

Mark It DownのINP(Interaction to Next Paint)は109msまで改善できた。もしReactを使っていたら、この数字はさらに悪化していただろう。

Preact移行で遭遇した問題と解決策

Preactは「Reactのドロップイン代替」を謳っているが、完全な互換性はない。実際に遭遇した問題を記録する。

問題1: 型定義の違い(MutableRefObject)

Reactの型定義をそのまま使うと、TypeScriptがエラーを吐く。

// React
import React from 'react'
type Ref = React.MutableRefObject<HTMLDivElement | null>

// Preact - エラー!
import { Ref } from 'preact'
type MyRef = Ref<HTMLDivElement | null>
// MutableRefObjectは存在しない

解決策: ReactでもPreactでも動く汎用的な型を使う。

// Before: React固有の型
interface Props {
  ref: React.MutableRefObject<HTMLDivElement | null>
}

// After: 汎用的な型
interface Props {
  ref: { current: HTMLDivElement | null }
}

{ current: T } という形式にすれば、どちらのライブラリでも動作する。

Mark It Downでは、この変更を約20箇所で行った。

問題2: createPortalのインポートパス

モーダルをbodyに直接レンダリングする際、createPortalのインポートパスが微妙に異なった。

// React
import { createPortal } from 'react-dom'

// Preact
import { createPortal } from 'preact/compat'

解決策: preact/compatからインポートする。

// preact/compatを使えば同じAPIで動作
import { createPortal } from 'preact/compat'

function Modal({ children }) {
  return createPortal(children, document.body)
}

ただし、パフォーマンス上の理由でpreact本体のAPIを直接使いたい場合は、別のアプローチが必要になる。

問題3: SyntheticEventの不在

ReactのSyntheticEventシステムはPreactには存在しない。Preactはネイティブイベントをそのまま使う。

// React
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.preventDefault()
}

// Preact - 型エラー!
const handleClick = (e: MouseEvent) => {
  e.preventDefault()
}

解決策: PreactのJSX固有の型を使う。

// Preact独自の型
import { JSX } from 'preact'

const handleClick = (e: JSX.TargetedMouseEvent<HTMLButtonElement>) => {
  e.preventDefault()
}

ただし、実際にはMouseEventのままでも動作する。TypeScriptの型チェックが通らないだけだ。

Mark It Downでは、このイベントハンドラの型修正を約30箇所で行った。

問題4: forwardRefの挙動

forwardRefの使い方が微妙に異なる。

// React
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
  return <input ref={ref} {...props} />
})

// Preact - preact/compatから
import { forwardRef } from 'preact/compat'

const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
  return <input ref={ref} {...props} />
})

これはpreact/compatを使えば解決するが、純粋なPreact APIではforwardRefが不要な場合もある(Preactはrefを通常のpropsとして扱える)。

互換レイヤー(preact/compat)の使い方

完全な互換性が必要な場合、Vite/Webpackでエイリアスを設定する。

// vite.config.js
export default defineConfig({
  resolve: {
    alias: {
      'react': 'preact/compat',
      'react-dom': 'preact/compat',
      'react-dom/test-utils': 'preact/test-utils',
      'react/jsx-runtime': 'preact/jsx-runtime',
    }
  }
})

これで、Reactコンポーネントライブラリがそのまま動作する。

ただし、互換レイヤーは追加のバンドルサイズを招く。

Mark It Downでは互換レイヤーを最小限に抑え、Preact本体のAPIだけで実装した。その結果、3KBという軽量さを維持できている。

Hooks互換性の実態

PreactはほとんどのHooksをサポートしているが、React 18で追加されたConcurrent Features関連は非対応だ。

Hook Preact対応状況 備考
useState 完全対応
useEffect 完全対応
useRef 完全対応
useCallback 完全対応
useMemo 完全対応
useContext 完全対応
useReducer 完全対応
useLayoutEffect 完全対応
useImperativeHandle 完全対応
useId Preact 10.x以降
useSyncExternalStore preact/compat必要 Zustandで使用
useTransition 非対応 Concurrent Features
useDeferredValue 非対応 Concurrent Features
startTransition 非対応 Concurrent Features

Zustandとの連携

Mark It DownではZustandを状態管理に使っている。Zustandは内部でuseSyncExternalStoreを使用するため、preact/compatエイリアスが必要だ。

// stores/noteStore.ts
import { create } from 'zustand'

interface NoteState {
  notes: Record<string, Note>
  currentNote: Note | null
  setCurrentNote: (note: Note | null) => void
}

export const useNoteStore = create<NoteState>((set) => ({
  notes: {},
  currentNote: null,
  setCurrentNote: (note) => set({ currentNote: note }),
}))

// コンポーネントで使用(Preactでも動作)
function NoteList() {
  const { notes, setCurrentNote } = useNoteStore()
  // ...
}

preact/compatエイリアスを設定していれば、Zustandはそのまま動作する。

移行しなかった機能

Reactから移行する際、以下の機能は諦めた(または代替実装した)。

Server Components

PreactにServer Componentsはない。

しかし、Chrome拡張機能にサーバーサイドレンダリングは不要なので、問題なし。

Suspense + Error Boundaries

PreactにもSuspenseはあるが、動作が微妙に異なる。

Mark It Downでは独自のローディング状態管理を実装した。

// シンプルなローディング状態管理
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
  loadInitialData().finally(() => setIsLoading(false))
}, [])

if (isLoading) {
  return <LoadingSkeleton />
}

return <Editor />

Suspenseより冗長だが、これで十分だった。ローディング状態が複雑になるならSuspenseを検討すべきだが、Mark It Downではシンプルなパターンで済んでいる。

React DevTools

React DevToolsは使えない。代わりに「Preact Devtools」という拡張機能がある。

React DevToolsほど高機能ではないが、コンポーネントツリーの確認には十分。Profilerがないのは痛いが、Chrome DevToolsのPerformanceタブで代用できる。

移行の実際のコスト

Mark It Downの場合、移行コストは低かった。

変更箇所

変更種別 件数
インポート文の修正 ~50ファイル
型定義の修正 ~20箇所
イベントハンドラの型修正 ~30箇所

所要時間

約4時間

内訳:

  • インポート文の一括置換: 30分
  • 型エラーの修正: 2時間
  • 動作確認: 1時間
  • 細かな調整: 30分

既存のReactプロジェクトでも、Concurrent Featuresを使っていなければ移行は現実的だ。

移行後のバグ

移行後に発生したバグはゼロだった。

Preactの互換性は十分に高い。型エラーを直せば、ほぼそのまま動作する。

いつPreactを選ぶべきか

以下の条件に当てはまるなら、Preactを検討する価値がある。

条件 説明
バンドルサイズが重要 Chrome拡張、モバイル、低帯域環境
起動速度が重要 毎回ロードされるアプリ
React 18機能が不要 useTransition等を使わない
サードパーティ依存が少ない 互換性問題を回避しやすい
シンプルなアプリ 複雑な状態管理がない

いつReactを選ぶべきか

逆に、以下の場合はReactを使うべき。

条件 説明
Concurrent Featuresが必要 大規模データ表示、リアルタイム更新
React Nativeと共通化 コードベース共有が必要
エコシステム依存が高い React専用ライブラリを多用
Server Componentsが必要 Next.js App Routerなど
チームがReactに慣れている 学習コストを避けたい

Mark It Downは「シンプルなMarkdownエディタ」であり、Concurrent Featuresは不要だった。だからPreactを選んだ。

複雑なダッシュボードや、リアルタイムコラボレーション機能があるアプリなら、Reactの方が適している。

バンドルサイズ以外のメリット

Preactには軽量さ以外のメリットもある。

1. シンプルな内部実装

ReactのReconciler(Fiber)は複雑だ。Preactは単純な差分検出アルゴリズムを使っている。

デバッグ時にスタックトレースが読みやすい。「Reactの内部で何が起きているか」を理解する必要がない。

2. 高速な更新

ベンチマークではPreactがReactを上回ることが多い。特に小規模な更新(1要素の追加・削除など)で顕著。

これは、ReactのFiberアーキテクチャが「大規模な更新を効率化する」設計になっているため。小規模な更新ではオーバーヘッドが目立つ。

3. 学習コストの低さ

Reactを知っていればPreactは即座に使える。新しい概念を学ぶ必要がない。

チームに「Preactを学ばせる」必要はない。Reactの知識がそのまま使える。

4. バンドルの可読性

Preactはバンドル後のコードも読みやすい。問題が発生したときに、minified codeを読んでデバッグできる(できればやりたくないが)。

失ったもの

正直に、失ったものも書いておく。

1. React DevToolsのProfiler

パフォーマンスのボトルネックを見つけるのに便利だったが、Preact Devtoolsにはない。Chrome DevToolsのPerformanceタブで代用している。

2. 一部のReactライブラリとの互換性

react-dndreact-springなど、React専用のライブラリは使えない(または動作が不安定)。

Mark It Downではこれらを使っていなかったので問題にならなかった。

3. React 18の新機能

useTransition、useDeferredValue、startTransitionは使えない。

「重い処理をバックグラウンドで行う」ようなパターンが必要なら、Reactを使うべき。

まとめ

Chrome拡張機能「Mark It Down」では、ReactではなくPreactを採用した。

選定理由:

  • 3KB vs 40KBのバンドルサイズ差(92%削減)
  • 毎回起動するChrome拡張機能では軽量さが最重要
  • React 18のConcurrent Featuresは不要
  • Hooks互換性で実用上の問題なし

移行コスト:

  • 型定義の修正が主
  • 約4時間で完了
  • 互換レイヤー最小使用でさらに軽量化

得たもの:

  • 37KBのバンドルサイズ削減
  • 高速な起動時間
  • シンプルな内部実装

失ったもの:

  • React DevToolsのProfiler
  • 一部のReact専用ライブラリ
  • Concurrent Features

「3KBで十分」というのは、すべてのプロジェクトに当てはまるわけではない。しかし、Chrome拡張機能という特殊な環境では、37KBの差がユーザー体験を左右する。

軽量であることは、それ自体が価値だ。


移行チェックリスト

Preactへの移行を検討している人向けに、チェックリストを用意した。

事前確認

  • React 18のConcurrent Features(useTransition等)を使っていない
  • React専用ライブラリへの依存が少ない
  • Server Componentsを使っていない
  • バンドルサイズ削減がプロジェクトにとって重要

移行手順

  1. preactpreact/compatをインストール
  2. Vite/WebpackでReact→Preactエイリアスを設定
  3. TypeScriptの型エラーを修正
  4. 動作確認(特にイベントハンドラ、ref、createPortal)
  5. Preact Devtoolsをインストール

移行後の確認

  • 全機能が正常に動作する
  • バンドルサイズが削減されている
  • パフォーマンスが改善(または維持)されている

関連リソース


Mark It Down -Chrome拡張機能のMarkdownエディタ
AIチャット(ChatGPT、Claude、Gemini)の横で書く。New TabとSide Panelの2モード。
この記事は Mark It Down で書きました。

Discussion