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-dndやreact-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を使っていない
- バンドルサイズ削減がプロジェクトにとって重要
移行手順
-
preactとpreact/compatをインストール - Vite/WebpackでReact→Preactエイリアスを設定
- TypeScriptの型エラーを修正
- 動作確認(特にイベントハンドラ、ref、createPortal)
- Preact Devtoolsをインストール
移行後の確認
- 全機能が正常に動作する
- バンドルサイズが削減されている
- パフォーマンスが改善(または維持)されている
関連リソース
Mark It Down -Chrome拡張機能のMarkdownエディタ
AIチャット(ChatGPT、Claude、Gemini)の横で書く。New TabとSide Panelの2モード。
この記事は Mark It Down で書きました。
Discussion