TL;DR
- react-doctorはAIコーディングエージェント向けに設計されたReactコード診断ツールで、プロジェクト全体をスキャンして0〜100の健全性スコアを算出する
npx react-doctor@latest .だけで即実行でき、GitHub ActionsへのCI組み込みやClaude Codeなどとの連携にも対応している- AIエージェントにベストプラクティスを学習させるskillも提供されており、Claude Codeとの組み合わせで静的解析+AIレビューの両立が可能
- ルールは状態管理・パフォーマンス・アーキテクチャ・セキュリティなど11カテゴリに分かれており、現時点で47以上のルールが定義されている
- よくレビューで指摘される内容が網羅されており、ルールの品質も十分に高いと感じた
はじめに
AI開発でコードの品質を上げるために、フロントエンドのガイドラインを充実させたいと思っています。具体的には、Claude CodeのskillとしてReactのベストプラクティスをエージェントに覚えさせることで、レビューの精度を上げようとしていました。
そのskillを作成していたとき、react-doctorというツールを見つけました。まさにこういうものが欲しかった!と思い、詳しく調べてみることにしました。特に気になったのは「ルールの品質」です。ルールが粗かったり的外れだったりすれば、むしろ開発の邪魔になります。実際に導入する前に、どのような内容が検出されるのかをしっかり確認したいと思いました。
react-doctorとは
react-doctorは、Million.co(React最適化ライブラリ「Million.js」の開発元)が公開しているReactコード診断ツールです。
“Let coding agents diagnose and fix your React code”
インストール不要で npx から即実行でき、フレームワーク(Next.js / Vite / Remixなど)を自動検出してルールを切り替えてくれます。内部でRust製のOxlintを使っているため、スキャンが高速です。
npx react-doctor@latest .
AIエージェントへのskillインストールや、GitHub ActionsへのCI組み込みにも対応しています。
# Claude Code / Cursor などのエージェントにskillをインストール
curl -fsSL https://react.doctor/install-skill.sh | bash
# GitHub Actionsへの組み込み
- name: React Doctor
uses: millionco/react-doctor@v1
ルールを全部調べてみた
ルールの品質が良くないなら導入したくないと思ったので、実際のルール定義をGitHubのソースコードで確認してみました。現時点(v0.0.28)でのルール一覧を紹介します。今後のバージョンアップでさらに充実していくと思います。
状態管理とエフェクト(state-and-effects)
| ルール名 | 概要 |
|---|---|
noDerivedStateEffect | useEffect内で派生状態を設定することを禁止 |
noFetchInEffect | useEffect内でのfetch()を禁止(react-query/SWR推奨) |
noCascadingSetState | 連鎖的なsetState呼び出しを検出(3回以上) |
noEffectEventHandler | useEffectをイベントハンドラとして使用するパターンを禁止 |
noDerivedUseState | propsから直接初期化するuseStateを検出 |
preferUseReducer | 5個以上の関連useStateをuseReducerで管理することを推奨 |
rerenderDependencies | 依存配列内のオブジェクト/配列リテラルを警告 |
パフォーマンス(performance)
| ルール名 | 概要 |
|---|---|
noInlinePropOnMemoComponent | memo()コンポーネントへのインライン関数・オブジェクト参照を禁止 |
noUsememoSimpleExpression | 単純な式へのuseMemo使用を警告 |
noLayoutPropertyAnimation | レイアウトプロパティのアニメーションを禁止(transform推奨) |
noTransitionAll | transition: allの使用を警告 |
noLargeAnimatedBlur | 10px超のblur値のアニメーションを警告 |
noScaleFromZero | scale: 0からのアニメーションを警告 |
noPermanentWillChange | 常時will-changeを警告 |
rerenderMemoWithDefaultValue | 空オブジェクト・配列をデフォルト値とする場合を検出 |
renderingAnimateSvgWrapper | SVG要素への直接アニメーションを警告 |
renderingUsetransitionLoading | 非同期操作でのuseTransition使用を提案 |
renderingHydrationNoFlicker | マウント時のuseEffect(setState, [])によるフラッシュを警告 |
アーキテクチャ(architecture)
| ルール名 | 概要 |
|---|---|
noGenericHandlerNames | handleClickのような汎用ハンドラ名を警告 |
noGiantComponent | 300行を超えるコンポーネントを検出 |
noRenderInRender | JSX内のインラインrender関数を検出 |
noNestedComponentDefinition | 親コンポーネント内でのコンポーネント定義を禁止 |
バンドルサイズ(bundle-size)
| ルール名 | 概要 |
|---|---|
noBarrelImport | バレルファイル(index)からのインポートを禁止 |
noFullLodashImport | lodash/lodash-esの全体インポートを禁止 |
noMoment | moment.js(300KB超)の使用を警告(date-fns/dayjs推奨) |
preferDynamicImport | 重いライブラリの静的インポートをReact.lazy()推奨 |
noUndeferredThirdParty | defer/asyncなしのscriptタグを警告 |
セキュリティ(security)
| ルール名 | 概要 |
|---|---|
noEval | eval()、setTimeout/setInterval文字列、new Function()を禁止 |
noSecretsInClientCode | クライアントコードへのシークレットのハードコードを検出 |
正確性(correctness)
| ルール名 | 概要 |
|---|---|
noArrayIndexAsKey | 配列インデックスをkeyとして使用することを禁止 |
noPreventDefault | フォーム/リンクでのpreventDefault()を警告 |
renderingConditionalRender | 条件付きレンダリングで.lengthを直接使用することを警告 |
JavaScriptパフォーマンス(js-performance)
| ルール名 | 概要 |
|---|---|
jsCombineIterations | 連続した.map().filter()を単一ループへ統合を提案 |
jsTosortedImmutable | [...arr].sort()をES2023の.toSorted()推奨 |
jsHoistRegexp | ループ内のnew RegExp()生成を検出 |
jsMinMaxLoop | array.sort()[0]でのmin/max取得をMath.min()/max()推奨 |
jsSetMapLookups | ループ内のarray.includes()をSet(O(1))推奨 |
jsBatchDomCss | 連続したstyle割り当てをcssText/classList推奨 |
jsIndexMaps | ループ内のarray.find()/findIndex()をMap利用推奨 |
jsCacheStorage | localStorage/sessionStorageの重複呼び出しを検出(2回以上) |
jsEarlyExit | 3段以上のネストを早期リターンに変換を提案 |
asyncParallel | 独立したawait文をPromise.all()で並列化推奨(3個以上) |
Next.js(nextjs)
| ルール名 | 概要 |
|---|---|
nextjsNoImgElement | <img>禁止、next/image推奨 |
nextjsAsyncClientComponent | クライアントコンポーネントでのasync禁止 |
nextjsNoAElement | 内部リンクの<a>禁止、next/link推奨 |
nextjsNoUseSearchParamsWithoutSuspense | SuspenseなしのuseSearchParams()禁止 |
nextjsNoClientFetchForServerData | ページ/レイアウトでのuseEffect+fetch禁止 |
nextjsMissingMetadata | メタデータエクスポートの欠落を検出 |
nextjsNoClientSideRedirect | useEffect内のrouter.push等を禁止 |
nextjsNoRedirectInTryCatch | try-catch内のredirect()禁止 |
nextjsImageMissingSizes | fillプロパティ付きImageのsizes属性欠落を検出 |
nextjsNoNativeScript | ネイティブ<script>禁止、next/script推奨 |
nextjsInlineScriptMissingId | インライン<Script>のid欠落を検出 |
nextjsNoFontLink | <link>でのGoogle Fonts読み込み禁止 |
nextjsNoCssLink | <link>でのCSS読み込み禁止 |
nextjsNoPolyfillScript | ポリフィルCDN使用禁止 |
React Native(react-native)
| ルール名 | 概要 |
|---|---|
rnNoRawText | Text外のRawテキスト(クラッシュ原因)を検出 |
rnNoDeprecatedModules | 廃止モジュールのインポートを検出 |
rnNoLegacyExpoPackages | 廃止Expoパッケージを検出 |
rnNoDimensionsGet | Dimensions.get()を警告(useWindowDimensions推奨) |
rnNoInlineFlatlistRenderitem | FlatListのインラインrenderItemを検出 |
rnNoLegacyShadowStyles | レガシーshadowスタイルを検出 |
rnPreferReanimated | react-native Animatedを警告(react-native-reanimated推奨) |
rnNoSingleElementStyleArray | 単一要素のスタイル配列を検出 |
サーバー(server)
| ルール名 | 概要 |
|---|---|
serverAuthActions | サーバーアクション内の認証チェック欠落を検出(先頭3ステートメント以内) |
serverAfterNonblocking | サーバーアクション内のconsole/analyticsにafter()使用を推奨 |
クライアント(client)
| ルール名 | 概要 |
|---|---|
clientPassiveEventListeners | スクロール系イベントへの{ passive: true }欠落を検出 |
実際に動かしてみた
Bad Codeのサンプルコードを用意して診断を実行してみました。
npx react-doctor . --yes --offline --verbose
✗ 8 errors ⚠ 50 warnings across 10 files in 128ms
では、カテゴリ別に実際のBad/Goodコード例を見ていきます。
状態管理とエフェクト
noFetchInEffect — useEffect内でfetch()しない
useEffect内のfetch()はウォーターフォール問題・競合状態・開発環境での二重実行など多くの問題を引き起こします。react-queryやSWR、またはServer Componentでのフェッチが推奨されます。
// Bad
export function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => setUsers(data))
}, [])
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
// Good: React 19のuse() + SuspenseでuseEffectを排除
const usersPromise = fetch('/api/users').then((res) => res.json())
function UserListContent() {
const users = use(usersPromise)
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}
export function UserList() {
return (
<Suspense fallback={<p>Loading…</p>}>
<UserListContent />
</Suspense>
)
}
検出: fetch() inside useEffect — use a data fetching library (react-query, SWR) or server component
提案: Use useQuery() from @tanstack/react-query, useSWR(), or fetch in a Server Component instead
noDerivedStateEffect — 派生stateをuseEffectで更新しない
firstNameとlastNameからfullNameを作るような、計算できる値をわざわざuseEffect + setStateで管理するパターンは、余分なレンダーを発生させます。
// Bad
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
// Good: レンダー中に直接計算する
const fullName = firstName + ' ' + lastName
検出: Derived state in useEffect — compute during render instead
提案: For derived state, compute inline: const x = fn(dep). For state resets on prop change, use a key prop: <Component key={prop} />
noCascadingSetState — useEffect内でsetStateを連鎖させない
1つのuseEffect内で3回以上setStateを呼ぶと、複数回の再レンダーが発生します。useReducerでまとめるのが推奨です。
// Bad(setStateが4回)
useEffect(() => {
setLoading(true) // 1回目
setError(null) // 2回目
setData(null) // 3回目 ← 閾値超え
setTimestamp(Date.now())
fetch(`/api/data/${id}`).then(...)
}, [id])
// Good: useReducerで一本化
const [state, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetch(`/api/data/${id}`)
.then((res) => res.json())
.then((data) => dispatch({ type: 'FETCH_SUCCESS', data }))
}, [id])
検出: 6 setState calls in a single useEffect — consider using useReducer or deriving state
提案: Combine into useReducer: const [state, dispatch] = useReducer(reducer, initialState)
noEffectEventHandler — useEffectをイベントハンドラとして使わない
isSubmittedのようなstate変化を監視して副作用を発火させるパターンは、イベントハンドラで直接処理すべきです。
// Bad
useEffect(() => {
if (isSubmitted) {
submitForm()
}
}, [isSubmitted])
// Good
const handleSubmit = () => {
submitForm()
}
<button onClick={handleSubmit}>Submit</button>
検出: useEffect simulating an event handler — move logic to an actual event handler instead
提案: Move the conditional logic into onClick, onChange, or onSubmit handlers directly
noDerivedUseState — propsで初期化したuseStateを使わない
propsから直接useStateを初期化すると、その後propsが変化してもstateは更新されません(stale closure問題)。
// Bad
function UserCard({ name }) {
const [displayName, setDisplayName] = useState(name) // propsを初期値にしている
return <div>{displayName}</div>
}
// Good: propsを直接使う
function UserCard({ name }) {
return <div>{name}</div>
}
// どうしても派生stateが必要な場合はkey propでリセット
<UserCard key={userId} name={name} />
検出: useState initialized from prop "name" — if this value should stay in sync with the prop, derive it during render instead
提案: Remove useState and compute the value inline: const value = transform(propName)
preferUseReducer — 関連するuseStateが多い場合はuseReducerを使う
コンポーネント内に5個以上のuseStateがあると、状態の更新ロジックが分散して保守しづらくなります。
// Bad(useStateが6個)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
// Good
type FormState = {
name: string; email: string; password: string
isLoading: boolean; error: string | null; success: boolean
}
const [state, dispatch] = useReducer(formReducer, initialFormState)
検出: Component "RegistrationForm" has 6 useState calls — consider useReducer for related state
提案: Group related state: const [state, dispatch] = useReducer(reducer, { field1, field2, ... })
rerenderDependencies — 依存配列にオブジェクト・配列リテラルを渡さない
オブジェクトや配列リテラルはレンダーごとに新しい参照が生成されるため、フックが毎回発火します。
// Bad
useEffect(() => {
fetchData(options)
}, [{ id: userId }]) // 毎レンダーで新オブジェクトが生成される
// Good: プリミティブ値を依存配列に渡す
useEffect(() => {
fetchData({ id: userId })
}, [userId])
検出: Object literal in useEffect deps — creates new reference every render, causing infinite re-runs
提案: Extract the object outside the component or useMemo, then pass primitive values in the dependency array
パフォーマンス
noInlinePropOnMemoComponent — memoコンポーネントにインラインpropsを渡さない
memo()でラップしていても、インライン関数・オブジェクトを渡すと毎レンダーで参照が変わり、メモ化が無効になります。
// Bad
<ExpensiveChart
onPointClick={(i) => setSelected(i)} // 毎回新しい関数
style={{ border: '1px solid gray' }} // 毎回新しいオブジェクト
/>
// Good
const CHART_STYLE: React.CSSProperties = { border: '1px solid gray' } // モジュール定数
const handlePointClick = useCallback((i: number) => {
setSelected(i)
}, [])
<ExpensiveChart onPointClick={handlePointClick} style={CHART_STYLE} />
検出: JSX attribute values should not contain functions created in the same scope — ExpensiveChart is wrapped in memo(), so new references cause unnecessary re-renders
noUsememoSimpleExpression — 単純な式にuseMemoを使わない
プリミティブ演算や定数はuseMemoのオーバーヘッドの方が大きいです。
// Bad
const total = useMemo(() => price * quantity, [price, quantity])
const label = useMemo(() => 'Total', [])
const doubled = useMemo(() => price * 2, [price])
// Good
const total = price * quantity
const label = 'Total'
const doubled = price * 2
// 本当にコストの高い計算にだけuseMemoを使う
const expensiveStat = useMemo(() => heavyCalc(price), [price])
検出: useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation
提案: Remove useMemo — property access, math, and ternaries are already cheap without memoization
noLayoutPropertyAnimation — レイアウトプロパティをアニメーションしない
width・height・marginなどをアニメーションさせると毎フレームでレイアウト再計算が発生します。
// Bad (Framer Motion)
<motion.div animate={{ width: 200, height: 100 }} />
// Good: transformまたはlayout propを使う
<motion.div animate={{ scaleX: 1.2 }} layout />
検出: Animating layout property "width" triggers layout recalculation every frame — use transform/scale or the layout prop
提案: Use transform: scaleX() for width, translateY() for vertical movement, or add the layout prop to delegate to Framer Motion’s layout animation engine
noTransitionAll — transition: allを使わない
allは不要なプロパティまでアニメーションさせてしまいます。
// Bad
<div style={{ transition: 'all 0.3s ease' }} />
// Good: 必要なプロパティだけ指定する
<div style={{ transition: 'opacity 0.3s ease, transform 0.3s ease' }} />
検出: transition: "all" animates every property including layout — list only the properties you animate
提案: List specific properties: transition: "opacity 200ms, transform 200ms" — or in Tailwind use transition-colors, transition-opacity, or transition-transform
noLargeAnimatedBlur — 大きなblur値をアニメーションしない
blur()が10pxを超えるとGPUコストが非常に高くなります。
// Bad
<motion.div animate={{ filter: 'blur(20px)' }} />
// Good
<motion.div animate={{ filter: 'blur(4px)' }} />
検出: blur(20px) is expensive — cost escalates with radius and layer size, can exceed GPU memory on mobile
提案: Keep blur radius under 10px, or apply blur to a smaller element. Large blurs multiply GPU memory usage with layer size
noScaleFromZero — scale: 0からアニメーションしない
scale: 0 → 1は不自然に見え、scale: 0.95 + opacity: 0の組み合わせの方が自然です。
// Bad
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} />
// Good
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} />
検出: scale: 0 makes elements appear from nowhere — use scale: 0.95 with opacity: 0 for natural entrance
提案: Use initial={{ scale: 0.95, opacity: 0 }} — elements should deflate like a balloon, not vanish into a point
noPermanentWillChange — will-changeを常時設定しない
常時設定するとGPUメモリを常に占有します。アニメーション中のみ動的に適用すべきです。
// Bad
<div style={{ willChange: 'transform' }} />
// Good: アニメーション前後で動的に切り替える
element.style.willChange = 'transform' // アニメーション前
element.style.willChange = 'auto' // アニメーション後
検出: Permanent will-change wastes GPU memory — apply only during active animation and remove after
提案: Add will-change on animation start (onMouseEnter) and remove on end (onAnimationEnd). Permanent promotion wastes GPU memory and can degrade performance
rerenderMemoWithDefaultValue — デフォルト値に{}や[]を直接書かない
デフォルト値に空オブジェクト・空配列を書くと、毎レンダーで新しい参照が生成されます。
// Bad
function DataTable({ rows = [], options = {}, headers = [] }) { ... }
// Good: モジュールレベルの定数として定義する
const EMPTY_ROWS: Row[] = []
const EMPTY_OPTIONS: Options = {}
const EMPTY_HEADERS: string[] = []
function DataTable({ rows = EMPTY_ROWS, options = EMPTY_OPTIONS, headers = EMPTY_HEADERS }) { ... }
検出: Default prop value [] creates a new array reference every render — extract to a module-level constant
提案: Move to module scope: const EMPTY_ITEMS: Item[] = [] then use as the default value
renderingAnimateSvgWrapper — SVGに直接アニメーションを付けない
<svg>タグはmotion propsに対応していません。ラッパー要素でアニメーションさせます。
// Bad
<svg animate={{ opacity: 1 }} initial={{ opacity: 0 }} />
// Good
<motion.div animate={{ opacity: 1 }} initial={{ opacity: 0 }}>
<svg />
</motion.div>
検出: Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance
提案: Wrap in a <motion.div> and apply animation props there: <motion.div animate={...}><svg /></motion.div>
renderingUsetransitionLoading — ローディング状態にuseTransitionを検討する
isLoadingなどのstateをuseState(false)で管理している場合、useTransitionでより良いUXを実現できます。
// Bad
const [isLoading, setIsLoading] = useState(false)
const handleClick = async () => {
setIsLoading(true)
await doSomething()
setIsLoading(false)
}
// Good
const [isPending, startTransition] = useTransition()
const handleClick = () => {
startTransition(async () => {
await doSomething()
})
}
renderingHydrationNoFlicker — マウント時のuseEffect(setState, [])でフラッシュしない
useEffect + setStateでハイドレーション後に状態を変更するパターンは、フラッシュを引き起こします。
// Bad
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true) // ハイドレーション後に再レンダーが走る
}, [])
if (!isMounted) return null
// Good: useSyncExternalStoreを使う
function useIsClient(): boolean {
return useSyncExternalStore(
() => () => {},
() => true, // クライアント側
() => false // サーバー側(ハイドレーション不一致なし)
)
}
export function ClientOnlyWidget() {
const isClient = useIsClient()
if (!isClient) return null
return <div>Client-only content</div>
}
検出: useEffect(setState, []) on mount causes a flash — consider useSyncExternalStore or suppressHydrationWarning
提案: Use useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) or add suppressHydrationWarning to the element
アーキテクチャ
noGenericHandlerNames — 汎用ハンドラ名を使わない
handleClickのような名前は「何をするのか」を伝えません。
// Bad
const handleClick = () => { ... }
const handleChange = (e) => { ... }
const handleSubmit = (e) => { ... }
// Good: 「何をするか」を名前に込める
function submitLoginCredentials() { ... }
function updateEmail(e) { ... }
noGiantComponent — 300行超のコンポーネントを作らない
300行を超えるコンポーネントは、責務が多すぎるサインです。役割ごとにコンポーネントを分割しましょう。
// Bad: 400行以上のDashboardコンポーネント
function Dashboard() {
// stateが10個、useEffectが5個...(400行以上)
}
// Good: 責務ごとに分割する
function Dashboard() {
return (
<>
<DashboardHeader />
<DashboardSidebar />
<DashboardContent />
</>
)
}
検出: Component "GiantDashboard" is 306 lines — consider breaking it into smaller focused components
提案: Split into focused sub-components: <DashboardHeader />, <DashboardContent />, <DashboardModal />
noRenderInRender — JSX内でインラインrender関数を呼ばない
JSXの中でrenderItem()のような関数を呼ぶパターンは、コンポーネントとして抽出すべきです。
// Bad
function ProductList({ products }) {
const renderHeader = () => <header><h1>Products</h1></header>
return (
<section>
{renderHeader()} {/* JSX内で関数呼び出し */}
</section>
)
}
// Good: 独立したコンポーネントとして抽出する
function ProductHeader() {
return <header><h1>Products</h1></header>
}
function ProductList({ products }) {
return <section><ProductHeader />...</section>
}
検出: Inline render function "renderHeader()" — extract to a separate component for proper reconciliation
提案: Extract to a named component: const ListItem = ({ item }) => <div>{item.name}</div>
noNestedComponentDefinition — コンポーネント内でコンポーネントを定義しない
親コンポーネント内で子コンポーネントを定義すると、毎レンダーで新しいコンポーネント型が生成されてstateがリセットされます。
// Bad
function ItemDashboard({ items }) {
function ItemBadge({ item }) { // 毎レンダーで新しい型が生成される
const [hovered, setHovered] = useState(false) // 毎回リセット!
return <span>{item.name}</span>
}
return <ul>{items.map((item) => <ItemBadge key={item.id} item={item} />)}</ul>
}
// Good: モジュールのトップレベルに定義する
function ItemBadge({ item }) {
const [hovered, setHovered] = useState(false) // 正しく保持される
return <span>{item.name}</span>
}
function ItemDashboard({ items }) {
return <ul>{items.map((item) => <ItemBadge key={item.id} item={item} />)}</ul>
}
検出: Component "ItemBadge" defined inside "ItemDashboard" — creates new instance every render, destroying state
提案: Move to a separate file or to module scope above the parent component
バンドルサイズ
noBarrelImport — バレルファイルからインポートしない
index.ts経由のインポートはtree-shakingが効かず、不要なコードまでバンドルに含まれます。
// Bad
import { Button } from './components/index'
import { formatDate } from '../utils' // indexを暗黙的に参照
// Good: ファイルを直接指定する
import { Button } from './components/Button'
import { formatDate } from '../utils/formatDate'
検出: Import from barrel/index file — import directly from the source module for better tree-shaking
提案: Import from the direct path: import { Button } from './components/Button' instead of ./components
noFullLodashImport — lodashを丸ごとインポートしない
lodash全体インポートは**70KB+**をバンドルに含めます。
// Bad
import _ from 'lodash'
import { debounce } from 'lodash' // これも全体が含まれる
// Good: 個別のサブモジュールをインポートする
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
検出: Importing entire lodash library — import from 'lodash/functionName' instead
提案: Import the specific function: import debounce from 'lodash/debounce' — saves ~70kb
noMoment — moment.jsを使わない
moment.jsは**300KB+**のバンドルサイズを持ちます。
// Bad
import moment from 'moment'
const formatted = moment(date).format('YYYY-MM-DD')
// Good
import { format } from 'date-fns' // ~13KB(必要な関数だけ)
const formatted = format(date, 'yyyy-MM-dd')
検出: moment.js is 300kb+ — use "date-fns" or "dayjs" instead
提案: Replace with import { format } from 'date-fns' (tree-shakeable) or import dayjs from 'dayjs' (2kb)
preferDynamicImport — 重いライブラリは動的インポートにする
Chart.jsやMonaco Editorなどの重いライブラリは初期バンドルに含めず、遅延ロードします。
// Bad
import Chart from 'chart.js'
import { Editor } from 'monaco-editor'
// Good: React.lazyで遅延ロードする
const Chart = React.lazy(() => import('chart.js'))
// Next.jsではdynamicを使う
import dynamic from 'next/dynamic'
const Editor = dynamic(() => import('monaco-editor'), { ssr: false })
検出: "chart.js" is a heavy library — use React.lazy() or next/dynamic for code splitting
提案: Use const Component = dynamic(() => import('library'), { ssr: false }) from next/dynamic or React.lazy()
noUndeferredThirdParty — scriptタグにはdefer/asyncを付ける
defer / asyncのない<script>はHTMLのパースをブロックして初期表示を遅らせます。
// Bad
<script src="https://cdn.example.com/lib.js" />
// Good
<script src="https://cdn.example.com/lib.js" defer />
検出: Synchronous <script> with src — add defer or async to avoid blocking first paint
提案: Use next/script with strategy="lazyOnload" or add the defer attribute
セキュリティ
noSecretsInClientCode — クライアントコードにシークレットをハードコードしない
APIキーやトークンなどのシークレットをソースコードに直接書くと、バンドルに含まれてブラウザから丸見えになります。
// Bad
const STRIPE_SECRET = 'sk_live_DEMO'
const GITHUB_TOKEN = 'ghp_DEMO'
// Good: 環境変数を使う
const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT
// シークレットキーはクライアントに渡さずサーバー経由で呼ぶ
async function initiatePayment() {
return fetch('/api/payment/create-charge', { method: 'POST' })
}
検出: Possible hardcoded secret in "STRIPE_SECRET" — use environment variables instead
提案: Move to server-side process.env.SECRET_NAME. Only NEXT_PUBLIC_* vars are safe for the client (and should not contain secrets)
noEval — eval()と同等の機能を使わない
eval()やnew Function()はXSSの主要な攻撃経路です。
// Bad
const result = eval(expression)
const fn = new Function('return ' + expression)
setTimeout('location.reload()', 5000) // 文字列引数
// Good: アロー関数を使う
setTimeout(() => location.reload(), 5000)
// 計算が必要な場合はホワイトリストベースのパーサーを実装する
function safeCalculate(expression: string): number | null {
if (!/^[\d\s+\-*/().]+$/.test(expression)) return null
// 安全なトークン解析...
}
検出: eval() is a code injection risk — avoid dynamic code execution
提案: Use new Function() only with trusted input; for setTimeout, replace string arguments with arrow functions: setTimeout(() => fn(), delay)
正確性
noArrayIndexAsKey — 配列インデックスをkeyに使わない
インデックスをkeyにすると、リストの並べ替えや削除時に誤ったDOMの再利用が起き、表示バグやstateの混在が発生します。
// Bad
tasks.map((task, index) => <li key={index}>{task.title}</li>)
tags.map((tag, i) => <span key={`tag-${i}`}>#{tag}</span>)
// Good: ユニークなIDを使う
tasks.map((task) => <li key={task.id}>{task.title}</li>)
tags.map((tag) => <span key={tag.id}>#{tag.label}</span>)
検出: Array index "i" used as key — causes bugs when list is reordered or filtered
提案: Use a stable unique identifier: key={item.id} or key={item.slug} — index keys break on reorder/filter
renderingConditionalRender — .lengthで条件付きレンダリングしない
array.length && <JSX>はlengthが0のとき0が画面に表示されるバグがあります。
// Bad
{notifications.length && (
<ul>...</ul>
// notificationsが空配列のとき "0" が表示される
)}
// Good
{notifications.length > 0 && <ul>...</ul>}
{Boolean(notifications.length) && <ul>...</ul>}
検出: Conditional rendering with .length can render '0' — use .length > 0 or Boolean(.length)
提案: Change to {items.length > 0 && <List />} or use a ternary: {items.length ? <List /> : null}
noPreventDefault — フォーム/リンクでpreventDefault()を使わない
preventDefault()はProgressive Enhancementを壊します。React 19のform actionやLinkコンポーネントを活用しましょう。
// Bad
<form onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}} />
// Good: React 19のaction属性(JavaScript無効でも動作)
function performSearch(formData: FormData) {
onSearch(formData.get('query') as string)
}
<form action={performSearch}>...</form>
検出: preventDefault() on <form> onSubmit — form won't work without JavaScript. Consider using a server action for progressive enhancement
提案: Use <form action={serverAction}> (works without JS) or <button> instead of <a> with preventDefault
JavaScriptパフォーマンス
jsCombineIterations — filter + mapの二重イテレーションを避ける
.filter().map()は配列を2回走査します。reduceで1回にまとめるとO(n)になります。
// Bad: O(2n)
const activeNames = items.filter((x) => x.active).map((x) => x.name)
// Good: O(n)
const activeNames = items.reduce<string[]>((acc, x) => {
if (x.active) acc.push(x.name)
return acc
}, [])
jsTosortedImmutable — […arr].sort()をtoSorted()に置き換える
ES2023のtoSorted()は元の配列を変更せず、コードも簡潔になります。
// Bad
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name))
// Good (ES2023 / Node 20+)
const sorted = items.toSorted((a, b) => a.name.localeCompare(b.name))
jsHoistRegexp — ループ内でnew RegExp()を生成しない
ループのたびにRegExpオブジェクトが生成されます。ループ外に移動することで最適化できます。
// Bad
for (const item of items) {
if (new RegExp(pattern).test(item)) { // 毎ループで生成
process(item)
}
}
// Good
const re = new RegExp(pattern)
for (const item of items) {
if (re.test(item)) { process(item) }
}
jsMinMaxLoop — ソートで最小値・最大値を取得しない
array.sort()[0]はO(n log n)です。Math.min()/max()でO(n)で取得できます。
// Bad: O(n log n)
const min = [...prices].sort((a, b) => a - b)[0]
const max = [...prices].sort((a, b) => b - a)[0]
// Good: O(n)
const min = Math.min(...prices)
const max = Math.max(...prices)
jsSetMapLookups — ループ内でarray.includes()を使わない
array.includes()はO(n)のため、ループ内で使うと全体がO(n²)になります。
// Bad: O(n²)
for (const item of items) {
if (allowList.includes(item.id)) { process(item) }
}
// Good: SetでO(n)に改善
const allowSet = new Set(allowList)
for (const item of items) {
if (allowSet.has(item.id)) { process(item) } // O(1)
}
jsBatchDomCss — styleの個別代入をまとめる
styleプロパティへの複数の個別代入は複数回のリフローを引き起こします。
// Bad
element.style.width = '100px'
element.style.height = '50px'
element.style.color = 'red'
// Good
element.style.cssText = 'width: 100px; height: 50px; color: red;'
// またはclassListでクラスを切り替える
element.classList.add('expanded')
jsIndexMaps — ループ内でarray.find()を使わない
ループ内のarray.find()はO(n×m)になります。事前にMapを構築してO(1)ルックアップに改善します。
// Bad: O(n×m)
for (const order of orders) {
const user = users.find((u) => u.id === order.userId)
process(order, user)
}
// Good: O(n + m)
const userMap = new Map(users.map((u) => [u.id, u])) // O(m)で構築
for (const order of orders) {
const user = userMap.get(order.userId) // O(1)
process(order, user)
}
jsCacheStorage — localStorageを同じキーで複数回読まない
localStorage.getItem()は毎回IOが発生します。一度読んで変数にキャッシュします。
// Bad
const name = localStorage.getItem('user-name')
const displayName = localStorage.getItem('user-name') // 2回目の読み込み
const activeTheme = localStorage.getItem('user-name') // 3回目の読み込み
// Good: 1回読んで変数に保持する
const userName = localStorage.getItem('user-name')
const displayName = userName
const activeTheme = userName
jsEarlyExit — 深いネストは早期リターンで解消する
3段以上のネストしたif文は早期リターンで平坦化します。
// Bad(3段ネスト)
if (isLoggedIn) {
if (hasPermission) {
if (isActive) {
doSomething()
}
}
}
// Good
if (!isLoggedIn) return
if (!hasPermission) return
if (!isActive) return
doSomething()
asyncParallel — 独立したawaitはPromise.allでまとめる
3つ以上の連続した独立awaitは直列実行になり、合計待ち時間が伸びます。
// Bad: 直列実行(合計 = A + B + C の時間)
const user = await getUser(id)
const posts = await getPosts(id)
const comments = await getComments(id) // ← 3個目で検出
// Good: 並列実行(合計 = max(A, B, C) の時間)
const [user, posts, comments] = await Promise.all([
getUser(id),
getPosts(id),
getComments(id),
])
検出: 3 sequential await statements that appear independent — use Promise.all() for parallel execution
提案: Use const [a, b] = await Promise.all([fetchA(), fetchB()]) to run independent operations concurrently
Next.js
Next.jsプロジェクトを自動検出した場合のみ有効になるルールです。
nextjsNoImgElement — imgタグではなくnext/imageを使う
// Bad
<img src="/photo.jpg" alt="Photo" />
// Good
import Image from 'next/image'
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />
nextjsAsyncClientComponent — クライアントコンポーネントをasyncにしない
// Bad
'use client'
async function MyComponent() { // asyncはClient Componentで使えない
const data = await fetch('/api/data')
return <div>{data}</div>
}
// Good: データ取得はサーバーコンポーネントで行う
async function ServerComponent() {
const data = await fetch('/api/data')
return <ClientComponent data={data} />
}
nextjsNoUseSearchParamsWithoutSuspense — useSearchParamsはSuspenseで囲む
useSearchParams()をSuspenseなしで使うと、ページ全体がクライアントサイドレンダリングにフォールバックします。
// Bad
export default function Page() {
const searchParams = useSearchParams() // Suspenseなし
return <div>{searchParams.get('q')}</div>
}
// Good
function SearchContent() {
const searchParams = useSearchParams()
return <div>{searchParams.get('q')}</div>
}
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SearchContent />
</Suspense>
)
}
nextjsNoRedirectInTryCatch — try-catch内でredirect()しない
redirect()は内部的に特殊なエラーをスローするため、catchで捕捉されると機能しません。
// Bad
try {
redirect('/login') // NEXT_REDIRECTエラーをスローするがcatchされる
} catch (e) {
console.error(e) // redirectが機能しない
}
// Good: try-catchの外で呼ぶ
if (!session) redirect('/login')
try {
await riskyOperation()
} catch (e) {
console.error(e)
}
serverAuthActions — Server Actionの最初で認証チェックをする(サーバールール)
Server Actionは直接呼び出せるため、必ず認証チェックを先頭3ステートメント以内に置きます。
// Bad
'use server'
export async function deletePost(id: string) {
// 認証チェックなし!誰でも実行できる
await db.posts.delete({ where: { id } })
}
// Good
'use server'
export async function deletePost(id: string) {
const session = await getSession() // 先頭で認証
if (!session?.user) throw new Error('Unauthorized')
await db.posts.delete({ where: { id } })
}
clientPassiveEventListeners — スクロール系イベントに{ passive: true }を付ける(クライアントルール)
{ passive: true }がないと、ブラウザはスクロール前にJavaScriptの完了を待ち、スクロールがカクつきます。
// Bad
element.addEventListener('scroll', handleScroll)
element.addEventListener('touchstart', handleTouch)
// Good
element.addEventListener('scroll', handleScroll, { passive: true })
element.addEventListener('touchstart', handleTouch, { passive: true })
Bad Codeをすべて修正したGood Codeで再スキャンしたところ、機能的な問題が 34件(エラー10件+警告24件)→ 0件 に削減されたことも確認できました。
ルールとうまく付き合う
react-doctorに限った話ではありませんが、レビュー内容はあくまで「参考情報」として活用するのが良いと思っています。
たとえば noDerivedUseState(useStateの初期値にpropsを使わない)は、propsが変化してもstateが追随しないという問題を防ぐためのルールです。ただ、フォームの初期値としてpropsを受け取り、その後はユーザーが自由に編集できる設計は典型的なパターンで、こういった「意図的な初期値」として使いたいケースも普通に存在します。
ルールは「一般的に問題になりやすいパターン」を幅広く検出するように設計されているため、状況によっては当てはまらないこともあります。
それぞれのルールがどのような問題を防ぐために存在するのかを理解したり、AIに「このルールは自分のユースケースに適用すべきか?」と聞いたりしながら、状況に応じて採用するかどうかを判断していくのが良い使い方だと思います。
おわりに
react-doctorのルール一覧を調べてみて、よくレビューで指摘する/されることが割と網羅されているなと感じました。Reactでの開発経験を積むにつれて自然と意識するようになるものの、チームに新しいメンバーが加わったタイミングで指摘することが多い項目もあったりします。
また、Reactの公式ドキュメントで推奨されていることをかなり参考にしてルールが作られている印象で、「You Might Not Need an Effect」などのセクションで説明されているパターンが、noDerivedStateEffectやnoEffectEventHandlerとして実装されているのが良いなと思いました。
実際に本番プロジェクトに取り入れて運用してみたいと思っています。CIに組み込んでスコアの推移を追いながら、コードベースの健全性を可視化できるのは面白そうです。
まだルールが少ないカテゴリもあるので、ルールの拡充に自分でも貢献できたら良いなとも思っています。チームで困っているパターンをルール化してPRを出すのも良さそうです。
