Reactは「Hooksを覚えれば書ける」言語ですが、「書ける」と「壊れずに保守できる」の間には深い溝があります。実プロダクトのコードレビューで筆者が指摘する内容の8割は、新しい概念ではなく「ありふれたアンチパターンの再発」です。本記事では現役エンジニアのリファクタリング現場で頻発する25のアンチパターンを、すべて Before / After のコードペアで整理します。
対象読者は20〜40代のWebエンジニアで、useState / useEffect はひととおり書けるが「自分のコードがレビューで何を指摘されているのか分からない」「Reactの正解が分からない」と感じている方を想定しています。各章は悪い例 → 良い例 → なぜダメかの3点セットで、写経すれば現場でそのまま使えるようにしました。
アンチパターン25選の全体像と優先度
まず25個を「本番障害につながる致命度」と「レビューで頻出するか」で整理します。すべてを一度に直すのは現実的ではないので、致命度A → B → C の順で潰すのが基本戦略です。
| 致命度 | 主なアンチパターン | 典型的な症状 | 修正コスト |
|---|---|---|---|
| A(本番障害級) | useEffect依存配列省略 / 不変性違反 / setState連続呼び | 無限ループ / 古い値表示 / 状態破壊 | 低〜中 |
| A | useEffectでfetch / レンダリング中の副作用 | 競合状態 / hydrationエラー | 中 |
| B(保守性低下) | 巨大Context / propsドリル / props spread | 再レンダリング爆発 / 型崩壊 | 中〜高 |
| B | 巨大useEffect / ロジック混在 | テスト不能 / 変更影響範囲が読めない | 中 |
| C(微細だが頻発) | key=index / inline関数 / memo乱用 | 差分計算ミス / メモ化無効化 | 低 |
| C | any濫用 / console.log残し / クラス残存 | 型崩壊 / 情報漏洩 / 学習コスト増 | 低 |
本記事の前提知識として、useStateの正しい使い方、useEffectの依存配列とクリーンアップ、useMemo / useCallbackの使い分けあたりは前提とします。本記事ではこれらを誤用した場合の症状にフォーカスします。
レンダリング起因のアンチパターン6選
まず「画面がチラつく」「無限に再レンダリングする」など、レンダリングの仕組みを取り違えたときに起きる障害から潰していきます。Reactの基本原則は「state/propsが変われば再レンダリング、それ以外では再レンダリングしない」です。
#1 keyにindexを使う(リスト破壊の温床)
map()のループ変数indexをkeyに渡すと、並び替え・削除・先頭追加でReactが要素を取り違え、フォームのフォーカスや内部state(useState)が崩壊します。
// ❌ Bad: indexをkeyに使う
type Todo = { id: string; title: string; done: boolean };
const TodoListBad = ({ todos }: { todos: Todo[] }) => (
<ul>
{todos.map((todo, i) => (
<li key={i}>
<input type="checkbox" defaultChecked={todo.done} />
{todo.title}
</li>
))}
</ul>
);
// → 先頭にtodoを追加すると、各liのcheckbox状態が1つずつズレる
// ✅ Good: ドメイン上一意なidをkeyに使う
const TodoListGood = ({ todos }: { todos: Todo[] }) => (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" defaultChecked={todo.done} />
{todo.title}
</li>
))}
</ul>
);
// idがない場合は crypto.randomUUID() で生成して state にidを持たせる
#2 propsをspreadで横流ししてany化
「とりあえず{...props}で流す」設計は、型と責務の境界が消える典型的アンチパターンです。短期的には楽ですが、改修時に「このコンポーネントが何を受け取るか」を読めなくなります。
// ❌ Bad: propsの中身がブラックボックス
const ButtonBad = (props: any) => {
return <button {...props} className="btn">{props.label}</button>;
};
// → onClick? type? disabled? styleの上書き? 何でも入ってくる
// ✅ Good: 受け取るpropsを明示しつつ、HTML属性は型で許可制にする
import type { ComponentPropsWithoutRef } from "react";
type ButtonProps = {
label: string;
variant?: "primary" | "secondary";
} & Omit<ComponentPropsWithoutRef<"button">, "children">;
const ButtonGood = ({ label, variant = "primary", className, ...rest }: ButtonProps) => (
<button {...rest} className={`btn btn--${variant} ${className ?? ""}`}>
{label}
</button>
);
// → variantで意図を表現し、HTML属性はComponentPropsWithoutRefで安全に通す
#3 inline関数をmemoした子に渡してメモ効果を消す
「React.memoで囲んだのに再レンダリングが止まらない」相談の9割はこれです。親が毎回新しい関数オブジェクトを生成すると、memoの参照比較は必ず変更ありと判定されます。
// ❌ Bad: memoした子なのに毎回再レンダリング
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log("Child rendered");
return <button onClick={onClick}>click</button>;
});
const ParentBad = () => {
const [count, setCount] = useState(0);
// ← 毎renderで新しい関数が作られる
return (
<>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>parent</button>
<Child onClick={() => console.log("hi")} />
</>
);
};
// ✅ Good: useCallbackで参照を固定する
const ParentGood = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("hi");
}, []);
return (
<>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>parent</button>
<Child onClick={handleClick} />
</>
);
};
// React 19 + React Compilerが入っていれば自動最適化されるが、
// 現状の業務コードベースでは手動でuseCallbackを当てる必要があるシーンが多い
#4 useStateの初期値を毎renderで計算する
// ❌ Bad: 重い初期化を毎renderで実行
const HeavyInitBad = () => {
// localStorage.getItemはmount時だけで良いのに、毎renderで実行される
const [value, setValue] = useState(JSON.parse(localStorage.getItem("data") ?? "[]"));
return <Viewer data={value} />;
};
// ✅ Good: lazy initializer(関数を渡す)で初回のみ評価
const HeavyInitGood = () => {
const [value, setValue] = useState(() =>
JSON.parse(localStorage.getItem("data") ?? "[]")
);
return <Viewer data={value} />;
};
// → 初期計算が重い場合は必ず関数形式で渡す
#5 派生stateをuseStateで二重管理(derived state)
「propsから計算できる値」をわざわざuseStateに入れてuseEffectで同期する、というのは状態のソース・オブ・トゥルースを破壊するアンチパターンです。
// ❌ Bad: propsをstateにコピーして同期する
const UserCardBad = ({ user }: { user: { name: string } }) => {
const [name, setName] = useState(user.name);
useEffect(() => {
setName(user.name); // propsが変わる度にsetState → 余分なrender
}, [user.name]);
return <p>{name}</p>;
};
// ✅ Good: propsをそのまま使う or useMemoで派生
const UserCardGood = ({ user }: { user: { name: string } }) => (
<p>{user.name}</p>
);
// 加工が必要なら useMemo で派生させる
const UserCardWithLabel = ({ user }: { user: { name: string; age: number } }) => {
const label = useMemo(() => `${user.name}(${user.age})`, [user.name, user.age]);
return <p>{label}</p>;
};
#6 setStateを連続で同期的に呼ぶ
// ❌ Bad: 古いstateを参照したsetState連打
const CounterBad = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// → 期待: 3増える / 実際: 1しか増えない(全てclosure内のcountを参照)
};
return <button onClick={handleClick}>{count}</button>;
};
// ✅ Good: 関数形式のupdaterで前回値を確実に参照
const CounterGood = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// → 期待通り3増える
};
return <button onClick={handleClick}>{count}</button>;
};
useEffect起因のアンチパターン5選
useEffectは「外部世界との同期」専用ツールです。「propsから計算できる」「ユーザー操作で発火する」処理にuseEffectを使うのは、ほぼ間違いと思って構いません。
#7 useEffectでデータフェッチ(競合状態の温床)
// ❌ Bad: useEffect内fetch + キャンセル無し
const UserPageBad = ({ id }: { id: string }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then(setUser);
}, [id]);
// → idが連続で変わると、古いfetchが後から完了してsetUserしてしまう
return user ? <Profile user={user} /> : <Loading />;
};
// ✅ Good: TanStack Queryに任せる(キャッシュ・競合制御・retryが一括解決)
import { useQuery } from "@tanstack/react-query";
const UserPageGood = ({ id }: { id: string }) => {
const { data: user, isLoading } = useQuery({
queryKey: ["user", id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((r) => r.json() as Promise<User>),
});
if (isLoading) return <Loading />;
return user ? <Profile user={user} /> : null;
};
TanStack Query を使わない場合でも、AbortController でリクエストをキャンセルする必要があります。詳しい設計はTanStack Query完全実践ガイドにまとめています。
#8 useEffectの依存配列省略・嘘の依存
// ❌ Bad: 依存配列を空にして「最初の1回だけ」を狙う
const SearchBad = ({ keyword }: { keyword: string }) => {
const [result, setResult] = useState<Result[]>([]);
useEffect(() => {
search(keyword).then(setResult);
}, []); // ← keywordが変わっても再実行されない
return <List items={result} />;
};
// ✅ Good: 依存はすべて配列に入れる + ESLintルールで強制
// eslint-plugin-react-hooks の exhaustive-deps を warn → error に上げる
const SearchGood = ({ keyword }: { keyword: string }) => {
const [result, setResult] = useState<Result[]>([]);
useEffect(() => {
let cancelled = false;
search(keyword).then((r) => {
if (!cancelled) setResult(r);
});
return () => {
cancelled = true;
};
}, [keyword]);
return <List items={result} />;
};
#9 1つのuseEffectに複数責務を詰め込む
// ❌ Bad: 認証・ログ・スクロール・タイトル同期を1つで処理
useEffect(() => {
document.title = page.title;
analytics.track("page_view", { page: page.id });
if (!user) router.push("/login");
window.scrollTo(0, 0);
const id = setInterval(() => refetch(), 30_000);
return () => clearInterval(id);
}, [page, user, router, refetch]);
// → 1つの依存変更で全副作用が再実行 / テスト不能 / 依存漏れの温床
// ✅ Good: 責務ごとにuseEffectを分割
useEffect(() => {
document.title = page.title;
}, [page.title]);
useEffect(() => {
analytics.track("page_view", { page: page.id });
}, [page.id]);
useEffect(() => {
if (!user) router.push("/login");
}, [user, router]);
useEffect(() => {
const id = setInterval(refetch, 30_000);
return () => clearInterval(id);
}, [refetch]);
// → 依存変更時の再実行範囲が最小化 / それぞれ単体テスト可能
#10 レンダリング中に副作用を起こす
// ❌ Bad: render関数本体でsetStateやfetch
const PageBad = ({ id }: { id: string }) => {
const [user, setUser] = useState<User | null>(null);
if (!user) {
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then(setUser);
}
// → renderが二度走る都度fetch起動 / Strict Modeで顕在化
// ✅ Good: 副作用はuseEffect(or useQuery)に閉じ込める
const PageGood = ({ id }: { id: string }) => {
const { data: user } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
});
return user ? <Profile user={user} /> : <Loading />;
};
#11 巨大なuseEffectで状態遷移を表現
// ❌ Bad: useState複数 + 巨大useEffectで状態遷移
const [step, setStep] = useState<"idle" | "loading" | "ok" | "ng">("idle");
const [data, setData] = useState<Data | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (step === "loading") {
api.fetch().then(
(d) => { setData(d); setStep("ok"); },
(e) => { setError(e); setStep("ng"); }
);
}
}, [step]);
// → setState連打で中間状態がチラつく / 状態遷移が読めない
// ✅ Good: useReducerで状態遷移を1か所に
type State =
| { tag: "idle" }
| { tag: "loading" }
| { tag: "ok"; data: Data }
| { tag: "ng"; error: Error };
type Action =
| { type: "fetch" }
| { type: "ok"; data: Data }
| { type: "ng"; error: Error };
const reducer = (s: State, a: Action): State => {
switch (a.type) {
case "fetch": return { tag: "loading" };
case "ok": return { tag: "ok", data: a.data };
case "ng": return { tag: "ng", error: a.error };
}
};
// → tagged union で「不可能な状態」が型として表現不可能になる
状態が4つ以上ある場合は素直にuseReducer、またはZustand / Jotaiを検討してください。
状態設計のアンチパターン4選
#12 巨大Contextで全画面を再レンダリング
// ❌ Bad: user/theme/cart/notification を1つのContextに突っ込む
type AppCtx = {
user: User | null;
theme: "light" | "dark";
cart: CartItem[];
notifications: Notification[];
setUser: (u: User | null) => void;
// ... 大量のsetter
};
const AppContext = createContext<AppCtx | null>(null);
// → cartが変わるだけで全消費コンポーネントが再render
// ✅ Good: 関心ごとに分割 + selector付きストアを採用
// パターンA: Contextを関心別に分ける
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<"light" | "dark">("light");
// パターンB: Zustandでselectorによる部分購読
import { create } from "zustand";
type Store = {
cart: CartItem[];
addItem: (item: CartItem) => void;
};
const useStore = create<Store>((set) => ({
cart: [],
addItem: (item) => set((s) => ({ cart: [...s.cart, item] })),
}));
const CartCount = () => {
const count = useStore((s) => s.cart.length); // ← cart.lengthが変わった時だけrender
return <span>{count}</span>;
};
#13 不変性違反:配列・オブジェクトを直接mutate
// ❌ Bad: pushでstateを直接書き換え
const TodoAppBad = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const add = (t: Todo) => {
todos.push(t); // 参照は同じ
setTodos(todos); // → Reactは「変わってない」と判断、再renderしない
};
};
// ✅ Good: 新しい配列・オブジェクトを生成
const TodoAppGood = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const add = (t: Todo) => setTodos((prev) => [...prev, t]);
const toggle = (id: string) =>
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
);
const remove = (id: string) =>
setTodos((prev) => prev.filter((t) => t.id !== id));
};
// ネストが深い場合はImmerで「mutateっぽく」書きつつ不変性を担保
import { produce } from "immer";
const updateNested = (prev: Tree, id: string) =>
produce(prev, (draft) => {
const node = draft.children.find((c) => c.id === id);
if (node) node.checked = true;
});
#14 propsドリル(4層以上の中継)
// ❌ Bad: 5層下に渡すためだけにprops中継
const App = () => { const [user, setUser] = useState<User|null>(null);
return <Layout user={user} />; };
const Layout = ({ user }: { user: User|null }) => <Header user={user} />;
const Header = ({ user }: { user: User|null }) => <Nav user={user} />;
const Nav = ({ user }: { user: User|null }) => <UserMenu user={user} />;
const UserMenu = ({ user }: { user: User|null }) => <span>{user?.name}</span>;
// ✅ Good: ContextまたはZustandで「使う場所で直接取得」
const UserContext = createContext<User | null>(null);
const App = () => {
const [user] = useState<User | null>(null);
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
};
const UserMenu = () => {
const user = useContext(UserContext);
return <span>{user?.name}</span>;
};
// 状態管理ライブラリの選定基準は /react-state-management-comparison-2026/ を参照
#15 Reduxを「学習目的」で全採用する
2026年現在、新規プロジェクトでいきなりReduxを入れる正当性はかなり下がっています。「サーバー状態 = TanStack Query / クライアント状態 = useState + Zustand or Jotai」で済むケースが大半です。
// ❌ Bad: 単純なtoggle状態にReduxを使う
// store.ts / slice.ts / dispatch呼び出し ... の3ファイル増える
import { createSlice } from "@reduxjs/toolkit";
const sidebarSlice = createSlice({
name: "sidebar",
initialState: { open: false },
reducers: { toggle: (s) => { s.open = !s.open; } },
});
// → 1コンポーネントの状態に対してオーバーキル
// ✅ Good: 局所状態はuseState、共有が必要になってからZustand化
const Sidebar = () => {
const [open, setOpen] = useState(false);
return (
<aside data-open={open}>
<button onClick={() => setOpen((o) => !o)}>toggle</button>
</aside>
);
};
// 別画面から開閉操作が必要になった時点で初めてZustand/Contextに移す
コンポーネント設計のアンチパターン5選
#16 1コンポーネントにロジック・表示・通信を全部入れる
// ❌ Bad: UI + fetch + バリデーション + 集計が混在
const DashboardBad = () => {
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
fetch("/api/orders").then((r) => r.json()).then(setOrders);
}, []);
const total = orders.reduce((sum, o) => sum + o.amount, 0);
const valid = orders.every((o) => o.amount >= 0);
return (
<div>
{!valid && <p>不正なデータがあります</p>}
<p>合計: {total}円</p>
<ul>{orders.map((o) => <li key={o.id}>{o.amount}</li>)}</ul>
</div>
);
};
// ✅ Good: 通信はcustom hookに、表示はdumb componentに分離
const useOrders = () =>
useQuery({
queryKey: ["orders"],
queryFn: () => fetch("/api/orders").then((r) => r.json() as Promise<Order[]>),
});
const summarize = (orders: Order[]) => ({
total: orders.reduce((s, o) => s + o.amount, 0),
valid: orders.every((o) => o.amount >= 0),
});
const DashboardGood = () => {
const { data: orders = [] } = useOrders();
const { total, valid } = useMemo(() => summarize(orders), [orders]);
return <OrderSummary orders={orders} total={total} valid={valid} />;
};
// → useOrders / summarize / OrderSummary が単体テスト可能に
カスタムフックへの切り出し方はカスタムフック作り方完全ガイドで詳述しています。
#17 React.memoの乱用
// ❌ Bad: なんとなく全コンポーネントをmemoで囲む
export default React.memo(function Label({ text }: { text: string }) {
return <span>{text}</span>;
});
// → 比較コストの方が高い / props変化が常にあると無意味
// ✅ Good: 計測してから「重い」「頻繁にrenderされる」コンポーネントだけmemo
// React DevTools ProfilerでactualDurationが大きい/render回数の多い箇所を特定してから貼る
export default React.memo(HeavyChart, (prev, next) => prev.data === next.data);
// React 19 + React Compilerが入っていれば自動最適化されるので、
// 手動memo化は「Compilerが効かないコードパス」に限定する
#18 forwardRef忘れでrefが付かない
// ❌ Bad: 自作Inputにrefを渡せない
const MyInputBad = ({ value, onChange }: {
value: string;
onChange: (v: string) => void;
}) => (
<input value={value} onChange={(e) => onChange(e.target.value)} />
);
// 呼び出し側:
const Parent = () => {
const ref = useRef<HTMLInputElement>(null);
return <MyInputBad ref={ref} value="" onChange={() => {}} />;
// → 型エラー or refが効かない
};
// ✅ Good (React 19+): refを通常のpropsとして受け取る
type MyInputProps = {
value: string;
onChange: (v: string) => void;
ref?: React.Ref<HTMLInputElement>;
};
const MyInputGood = ({ value, onChange, ref }: MyInputProps) => (
<input ref={ref} value={value} onChange={(e) => onChange(e.target.value)} />
);
// React 18以下なら forwardRef
const MyInputLegacy = forwardRef<HTMLInputElement, Omit<MyInputProps, "ref">>(
({ value, onChange }, ref) => (
<input ref={ref} value={value} onChange={(e) => onChange(e.target.value)} />
)
);
useRefとforwardRefのより詳細な使い分けはuseRef完全実践ガイドを参照ください。
#19 CSS-in-JSの過剰使用
// ❌ Bad: 全コンポーネントにstyled.divとprops連動スタイル
import styled from "styled-components";
const Box = styled.div<{ $padding: number; $color: string }>`
padding: ${(p) => p.$padding}px;
color: ${(p) => p.$color};
`;
// → ランタイムCSS生成でレンダリングが重くなる / Server Components未対応問題
// ✅ Good: Tailwind or CSS Modules / Vanilla Extract等ビルド時CSS
// Tailwind例
const Box = ({ padding, color, children }: BoxProps) => (
<div className={`p-${padding} text-${color}`}>{children}</div>
);
// CSS Modules例
import styles from "./Box.module.css";
const Box2 = ({ children }: { children: React.ReactNode }) => (
<div className={styles.box}>{children}</div>
);
// → ランタイムコスト0、Server Componentsとも互換
#20 ContextプロバイダのProvider地獄
// ❌ Bad: ネスト10段のProvider
<ThemeProvider>
<AuthProvider>
<QueryClientProvider client={qc}>
<LocaleProvider>
<NotificationProvider>
<ModalProvider>
<ToastProvider>
<App />
</ToastProvider>
</ModalProvider>
</NotificationProvider>
</LocaleProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>
// ✅ Good: composeヘルパで平坦化
type ProviderComponent = React.FC<{ children: React.ReactNode }>;
const compose = (...providers: ProviderComponent[]): ProviderComponent =>
({ children }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children as React.ReactElement
);
const AppProviders = compose(
ThemeProvider,
AuthProvider,
LocaleProvider,
NotificationProvider
);
// 使用側
<AppProviders>
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</AppProviders>
型・モダンReact関連のアンチパターン5選
#21 TypeScriptのany濫用
// ❌ Bad: イベント・APIレスポンス・refを全部any
const FormBad = () => {
const ref = useRef<any>(null);
const onChange = (e: any) => setValue(e.target.value);
const fetchUser = async (id: any): Promise<any> => {
const r = await fetch(`/api/users/${id}`);
return r.json();
};
};
// ✅ Good: 正しい型・unknown・zodで検証
const FormGood = () => {
const ref = useRef<HTMLInputElement>(null);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setValue(e.target.value);
const userSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof userSchema>;
const fetchUser = async (id: string): Promise<User> => {
const r = await fetch(`/api/users/${id}`);
const raw: unknown = await r.json();
return userSchema.parse(raw); // 実行時検証
};
};
TypeScriptのより詳しい使いこなしはTypeScript 完全実践ガイドを参照してください。
#22 Server Componentに “use client” を過剰指定
// ❌ Bad: トップで "use client" → 全配下がClient Component化
"use client";
import { Hero } from "./Hero";
import { Pricing } from "./Pricing";
import { ContactForm } from "./ContactForm"; // ←これだけInteractive
export default function Page() {
return (
<>
<Hero />
<Pricing />
<ContactForm />
</>
);
}
// → SSR利益消失 / バンドル肥大
// ✅ Good: Interactiveが必要な葉だけ "use client"
// page.tsx (Server Component のまま)
import { Hero } from "./Hero";
import { Pricing } from "./Pricing";
import { ContactForm } from "./ContactForm";
export default function Page() {
return (
<>
<Hero />
<Pricing />
<ContactForm />
</>
);
}
// ContactForm.tsx ← ここだけClient
"use client";
export const ContactForm = () => {
const [name, setName] = useState("");
return <input value={name} onChange={(e) => setName(e.target.value)} />;
};
#23 Strict Mode警告を無視する
// ❌ Bad: StrictModeを外して2回呼び問題を「治った」ことにする
// main.tsx
createRoot(document.getElementById("root")!).render(
<App /> // StrictMode削除
);
// → 開発時の副作用検知能力を捨てている / 本番でも潜在バグは生きている
// ✅ Good: StrictModeを保ち、副作用側を冪等にする
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
// useEffectは「2回呼ばれても結果が同じ」になるよう設計する
useEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") setError(e);
});
return () => controller.abort();
}, []);
#24 クラスコンポーネント残存
// ❌ Bad: ライフサイクル多用のクラスコンポーネント
class TimerBad extends React.Component<{}, { count: number }> {
state = { count: 0 };
private id?: ReturnType<typeof setInterval>;
componentDidMount() {
this.id = setInterval(() => this.setState({ count: this.state.count + 1 }), 1000);
}
componentWillUnmount() { if (this.id) clearInterval(this.id); }
render() { return <span>{this.state.count}</span>; }
}
// → Hooksで完全に置き換え可能 / React 19のSuspense連携や新機能と相性が悪い
// ✅ Good: 関数コンポーネント + useEffect
const TimerGood = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <span>{count}</span>;
};
// エラーバウンダリだけは2026年現在もクラスが必要(react-error-boundaryを使うと不要)
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary fallback={<p>エラー</p>}>
<TimerGood />
</ErrorBoundary>
#25 console.log・テストコードの本番残し
// ❌ Bad: 本番にconsole.logが大量に残る
const submit = async (form: FormData) => {
console.log("submit", form); // ← APIキー・個人情報が混入する事故源
await api.submit(form);
};
// ✅ Good: ロガー経由 + ビルド時除去
// logger.ts
export const log = {
debug: (...args: unknown[]) => {
if (import.meta.env.DEV) console.debug(...args);
},
info: (...args: unknown[]) => analytics.event("info", { args }),
error: (e: unknown) => { Sentry.captureException(e); },
};
// vite.config.ts でビルド時にconsole.*を削除
export default defineConfig({
esbuild: { drop: ["console", "debugger"] },
});
// ESLintでconsoleを禁止
// .eslintrc: { rules: { "no-console": ["error", { allow: ["warn", "error"] }] } }
アンチパターン早見表:症状からの逆引き
「症状は分かるが原因が分からない」ときの逆引き表です。コードレビュー時のチェックリストとしても使えます。
| 症状 | 疑うべきアンチパターン | 修正方針 |
|---|---|---|
| 無限ループ・止まらない再render | #5 / #7 / #8 / #10 | useEffect分割 + 依存配列見直し |
| memoが効かない | #3 / #17 | useCallback / Compilerに任せる |
| 並び替えでフォームがバグる | #1 | keyを一意idに |
| setState連打しても1しか増えない | #6 | 関数形式updater |
| 古いデータが画面に残る | #7 | TanStack Queryでクエリキー化 |
| 1か所変えたら別画面が壊れる | #12 / #16 | Context分割 + hook切り出し |
| 型エラーが大量発生 | #2 / #21 | any撤廃 + zod導入 |
| SSRが効かない | #22 | “use client” を葉に限定 |
移行ロードマップ:既存コードベースの直し方
既存のレガシーReactコードを一気に直すのは現実的ではありません。「ビルド時に検知可能」→「ランタイムで検知可能」→「設計の問題」の順で着手するのが効率的です。
- 第1段階(1日): ESLintで自動検知できる#1 / #6 / #8 / #21 / #25を一掃
- 第2段階(1週間): #7のfetch系を全てTanStack Queryに移行 / #10の同期副作用を消す
- 第3段階(2〜4週間): #9 / #11の巨大useEffect分割、#16のロジック・UI分離
- 第4段階(継続): #12 / #14の状態管理、#22 / #24のモダンReact対応
ESLint設定の最低ライン
// eslint.config.js (Flat Config)
import reactHooks from "eslint-plugin-react-hooks";
import tsParser from "@typescript-eslint/parser";
import ts from "@typescript-eslint/eslint-plugin";
export default [
{
files: ["**/*.{ts,tsx}"],
languageOptions: { parser: tsParser },
plugins: { "react-hooks": reactHooks, "@typescript-eslint": ts },
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error", // warnでなくerror
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "error",
"no-console": ["error", { allow: ["warn", "error"] }],
"react/jsx-key": "error",
},
},
];
テストでアンチパターンを検出する
// React Testing Libraryで「壊れていないか」をテスト
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { CounterGood } from "./Counter";
describe("CounterGood", () => {
it("関数形式updaterで3連打が3カウントされる", () => {
render(<CounterGood />);
const btn = screen.getByRole("button");
fireEvent.click(btn);
fireEvent.click(btn);
fireEvent.click(btn);
expect(btn).toHaveTextContent("3");
});
});
// → アンチパターン#6の回帰テストとして機能
テスト環境のセットアップはReact Testing Library完全実践ガイドで詳述しています。
よくある質問(FAQ)
Q1. React Compilerが入れば手動最適化は不要ですか?
A. ほぼYesですが、本記事の#3 / #17のようなメモ化系は自動化される一方、#7のキャンセル処理 / #13の不変性違反 / #9の責務分割といった設計レベルの問題はCompilerでは解決しません。Compiler導入後も本記事のアンチパターン群は引き続き有効です。
Q2. useEffectでfetchが「すべて悪」なのですか?
A. プロトタイプや学習用なら問題ありません。しかし「キャッシュ」「並列実行」「楽観的更新」「再試行」を自前実装する瞬間に破綻するため、本番ではTanStack Query等のサーバー状態ライブラリを推奨します。
Q3. Reduxは完全に不要になりましたか?
A. 不要ではありません。複雑なドメインロジックを持つエンタープライズSPA、Redux DevToolsによる時系列デバッグが必須の業務システム、既存資産がある現場では引き続き合理的選択です。「学習目的の新規導入」がアンチパターンなだけです。詳細はRedux Toolkit完全実践ガイドを参照ください。
Q4. propsドリルは何層から「悪」ですか?
A. 経験則として3層までは許容、4層を超えたら設計を疑うのが目安です。ただし「中継しているコンポーネントが、そのpropsを使っているかのように見える」場合はリファクタリング不要です。型の所在地と責務が一致しているかが判断軸です。
Q5. React.memoはいつ使うべきですか?
A. ProfilerでactualDurationが大きく、かつ親の再renderで頻繁に再描画されるコンポーネントに限定すべきです。「念のため」のmemoは比較コストの方が高くつきます。React Compiler導入後は基本的に手動memoは不要です。
Q6. Server ComponentとClient Componentの境界はどう引くべき?
A. 「ユーザー操作 / ブラウザAPI / state / effect」のいずれかが必要な葉のみClient Component化します。ページ全体ではなく「ボタンだけ」「フォームだけ」のような最小単位で切るのが原則です。
Q7. クラスコンポーネントは全て書き換えるべき?
A. 動いているコードを急いで書き換える必要はありません。ただし新規追加・大幅改修のタイミングで関数コンポーネント化するのは強く推奨します。React 19以降の新機能(Server Components / use()フック / Suspense連携など)はクラスでは恩恵を受けられません。
独学が辛くなったら:体系的に学べる選択肢
本記事のような「アンチパターン → 修正」は多数のレビュー経験を経て初めて自然に書けるようになります。独学で「自分のコードが正解か判断できない」と感じたら、現役エンジニアにレビューしてもらう環境を作るのが最速です。
- テックアカデミー:現役エンジニアの週2メンタリングで、自分のコードを直接レビューしてもらえる。Reactコースあり
- 侍エンジニア:オーダーメイドカリキュラムで「現職コードの改善」をテーマに進められる。転職保証コースもあり
- DMM WEBCAMP:転職保証つき。React/Next.js含むモダンフロントのカリキュラム
- レバテックキャリア:すでに実務経験がある人向け。React/TypeScript案件の年収レンジを確認するだけでも価値あり
自走できる方はReact Hooks完全実践ガイドから順に、パフォーマンス最適化、テストまで踏破するルートが最短です。
まとめ:アンチパターンは「知らないと避けられない」
本記事で扱った25のアンチパターンは、すべて「公式ドキュメントには書いてあるが、初学者が踏みやすい」パターンです。Reactは自由度が高いがゆえに、悪い書き方も問題なく動いてしまいます。だからこそ、レビューで指摘される前に自分でアンチパターンを検知できる目を養うことが、中堅エンジニアへの最短ルートです。
- 致命度Aから順に潰す:useEffect系・不変性違反・setState連打が最優先
- ESLintで自動検知できる範囲は仕組み化:exhaustive-deps / no-explicit-any / jsx-keyはerrorに
- memoは計測してから貼る:「念のため」は逆効果。React Compilerに任せられるなら任せる
- サーバー状態とクライアント状態を分ける:fetch系はTanStack Queryに退避
- Server Componentは葉だけClient化:“use client”の伝播を最小限に
本記事のコード片はすべてコピペで動くことを確認しています。手元の業務コードと突き合わせ、1つでも当てはまるアンチパターンがあれば、まずはそこから直してみてください。直すたびに、Reactとの付き合い方が確実に変わっていきます。

コメント