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)

ルール名概要
noDerivedStateEffectuseEffect内で派生状態を設定することを禁止
noFetchInEffectuseEffect内でのfetch()を禁止(react-query/SWR推奨)
noCascadingSetState連鎖的なsetState呼び出しを検出(3回以上)
noEffectEventHandleruseEffectをイベントハンドラとして使用するパターンを禁止
noDerivedUseStatepropsから直接初期化するuseStateを検出
preferUseReducer5個以上の関連useStateuseReducerで管理することを推奨
rerenderDependencies依存配列内のオブジェクト/配列リテラルを警告

パフォーマンス(performance)

ルール名概要
noInlinePropOnMemoComponentmemo()コンポーネントへのインライン関数・オブジェクト参照を禁止
noUsememoSimpleExpression単純な式へのuseMemo使用を警告
noLayoutPropertyAnimationレイアウトプロパティのアニメーションを禁止(transform推奨)
noTransitionAlltransition: allの使用を警告
noLargeAnimatedBlur10px超のblur値のアニメーションを警告
noScaleFromZeroscale: 0からのアニメーションを警告
noPermanentWillChange常時will-changeを警告
rerenderMemoWithDefaultValue空オブジェクト・配列をデフォルト値とする場合を検出
renderingAnimateSvgWrapperSVG要素への直接アニメーションを警告
renderingUsetransitionLoading非同期操作でのuseTransition使用を提案
renderingHydrationNoFlickerマウント時のuseEffect(setState, [])によるフラッシュを警告

アーキテクチャ(architecture)

ルール名概要
noGenericHandlerNameshandleClickのような汎用ハンドラ名を警告
noGiantComponent300行を超えるコンポーネントを検出
noRenderInRenderJSX内のインラインrender関数を検出
noNestedComponentDefinition親コンポーネント内でのコンポーネント定義を禁止

バンドルサイズ(bundle-size)

ルール名概要
noBarrelImportバレルファイル(index)からのインポートを禁止
noFullLodashImportlodash/lodash-esの全体インポートを禁止
noMomentmoment.js(300KB超)の使用を警告(date-fns/dayjs推奨)
preferDynamicImport重いライブラリの静的インポートをReact.lazy()推奨
noUndeferredThirdPartydefer/asyncなしのscriptタグを警告

セキュリティ(security)

ルール名概要
noEvaleval()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()生成を検出
jsMinMaxLooparray.sort()[0]でのmin/max取得をMath.min()/max()推奨
jsSetMapLookupsループ内のarray.includes()をSet(O(1))推奨
jsBatchDomCss連続したstyle割り当てをcssText/classList推奨
jsIndexMapsループ内のarray.find()/findIndex()をMap利用推奨
jsCacheStoragelocalStorage/sessionStorageの重複呼び出しを検出(2回以上)
jsEarlyExit3段以上のネストを早期リターンに変換を提案
asyncParallel独立したawait文をPromise.all()で並列化推奨(3個以上)

Next.js(nextjs)

ルール名概要
nextjsNoImgElement<img>禁止、next/image推奨
nextjsAsyncClientComponentクライアントコンポーネントでのasync禁止
nextjsNoAElement内部リンクの<a>禁止、next/link推奨
nextjsNoUseSearchParamsWithoutSuspenseSuspenseなしのuseSearchParams()禁止
nextjsNoClientFetchForServerDataページ/レイアウトでのuseEffect+fetch禁止
nextjsMissingMetadataメタデータエクスポートの欠落を検出
nextjsNoClientSideRedirectuseEffect内のrouter.push等を禁止
nextjsNoRedirectInTryCatchtry-catch内のredirect()禁止
nextjsImageMissingSizesfillプロパティ付きImageのsizes属性欠落を検出
nextjsNoNativeScriptネイティブ<script>禁止、next/script推奨
nextjsInlineScriptMissingIdインライン<Script>のid欠落を検出
nextjsNoFontLink<link>でのGoogle Fonts読み込み禁止
nextjsNoCssLink<link>でのCSS読み込み禁止
nextjsNoPolyfillScriptポリフィルCDN使用禁止

React Native(react-native)

ルール名概要
rnNoRawTextText外のRawテキスト(クラッシュ原因)を検出
rnNoDeprecatedModules廃止モジュールのインポートを検出
rnNoLegacyExpoPackages廃止Expoパッケージを検出
rnNoDimensionsGetDimensions.get()を警告(useWindowDimensions推奨)
rnNoInlineFlatlistRenderitemFlatListのインラインrenderItemを検出
rnNoLegacyShadowStylesレガシーshadowスタイルを検出
rnPreferReanimatedreact-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で更新しない

firstNamelastNameから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 — レイアウトプロパティをアニメーションしない

widthheightmarginなどをアニメーションさせると毎フレームでレイアウト再計算が発生します。

// 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


noTransitionAlltransition: 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>length0のとき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」などのセクションで説明されているパターンが、noDerivedStateEffectnoEffectEventHandlerとして実装されているのが良いなと思いました。

実際に本番プロジェクトに取り入れて運用してみたいと思っています。CIに組み込んでスコアの推移を追いながら、コードベースの健全性を可視化できるのは面白そうです。

まだルールが少ないカテゴリもあるので、ルールの拡充に自分でも貢献できたら良いなとも思っています。チームで困っているパターンをルール化してPRを出すのも良さそうです。


参考リンク