はじめに
この記事は、「React Hooksの基礎から実務での使いどころ、よくある落とし穴までを一気に学べる」を目標にしたサークル内勉強会用スライドを、Qiita記事向けに再構成したものです。
対象読者は次のような方を想定しています。
-
useStateは知っているが、他のHooksはあまり触ったことがない - Hooksの「動き」は分かるけど「実務でどう使うか」がイメージできない
- React Router DOMを使ったページ遷移も合わせて押さえたい
今回のゴールは以下の5つです。
- React Hooksが「何をするためのもの」か理解する
- 主要なHooksを一通り知る(
useState〜useContextまで) - 実務でどう使われるか / どこでハマりやすいかまで知る
- React Router DOMでページ遷移を実装できるようになる
- (勉強会では)配布したサンプルポートフォリオにHooksを実装して完成させる
各章、基本 → 実務での使いどころ → よくある落とし穴 の順で見ていきます。
目次
- なぜHooksが生まれたのか
- 補足:レンダリング・再レンダリング・副作用とは?
- useState — 状態を持つ
- useEffect — 副作用を扱う
- useMemo / useCallback — 計算をメモ化する
- useRef — DOMや値を保持する
- useReducer — 複雑な状態をまとめる
- createContext / useContext — 状態を共有する
- カスタムフック — 自分だけのHookを作る
- React Router DOM — ページを分割する
- まとめ
1. なぜHooksが生まれたのか
Before Hooks(Class Component時代)
- 状態管理は
this.state/this.setState - ライフサイクルは
componentDidMountなどのメソッドで分散 - ロジックの再利用が難しい(HOCやRender Propsで無理やり共有)
After Hooks(2019年〜)
- 関数コンポーネントのままstateやライフサイクルを扱える
- 関連するロジックを1箇所にまとめられる
- カスタムフックでロジックを部品化・再利用できる
実務ではもうClass Componentで新規実装することはほぼありません。既存コードの保守で読む機会があるくらいです。
補足:レンダリング・再レンダリング・副作用とは?
Hooksの説明にはこの後も何度も「レンダリング」「再レンダリング」「副作用」という言葉が出てきます。ふわっとしたまま読み進めると後半で必ずつまずくので、ここで一度立ち止まって整理しておきます。
レンダリングとは
Reactにおける「レンダリング」とは、コンポーネント関数を実行して、画面に表示する内容(JSX)を計算することです。ブラウザの実際の画面(DOM)をその都度全部作り直しているわけではなく、「今回はこういう見た目にしたい」という設計図をReactが作る工程、とイメージすると分かりやすいです。
function Hello({ name }) {
console.log("レンダリングされた!");
return <p>こんにちは、{name}さん</p>;
}
この console.log は、コンポーネント関数が実行される(=レンダリングされる)たびに呼ばれます。初回表示のときはもちろん、次に説明する「再レンダリング」が起きたときにも毎回呼ばれます。
再レンダリングとは
再レンダリングとは、一度画面に表示されたコンポーネントが、何かのきっかけでもう一度レンダリングされる(=関数がもう一度実行される)ことです。主なきっかけは次の3つです。
| きっかけ | 例 |
|---|---|
| stateが変わった |
setCount(count + 1) を呼んだ |
| propsが変わった | 親コンポーネントが渡す値が変わった |
| 親が再レンダリングされた | 特に対策しない限り、親が再レンダリングされると子も再レンダリングされる |
function Counter() {
const [count, setCount] = useState(0);
console.log("レンダリングされた。count =", count);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
ボタンを押す → setCount が呼ばれる → 再レンダリングが起きて関数がもう一度実行される(console.log も再度呼ばれる) → 画面上の数字も新しい値に更新される、という流れです。
「再レンダリングされる」=「コンポーネント関数がもう一度実行される」ということなので、関数の中で宣言した普通のローカル変数(let x = 0 など)は再レンダリングのたびにリセットされてしまいます。値を再レンダリングをまたいで保持したいから useState や useRef が必要になる、というのはここから来ています。
副作用(side effect)とは
副作用とは、「レンダリング(=見た目を計算する処理)」そのものとは直接関係のない、外の世界とのやり取りのことです。具体的には、
- サーバーへのデータ取得(
fetchなど) - タイマー(
setTimeout/setInterval) -
document.titleの変更、windowへのイベント登録 - ローカルストレージやDOMを直接操作する処理
などを指します。レンダリングは本来「propsやstateから見た目を計算するだけ」の処理であってほしいのに、これらは関数の外側の状態(ネットワーク、ブラウザAPI、グローバルな値など)に影響したり、影響を受けたりします。そのため「本編(レンダリング)に対する副作用」と呼ばれます。
こうした処理をレンダリング中(コンポーネント関数の中に直接)書いてしまうと、
- レンダリングされるたびに何度もAPIが呼ばれてしまう
- Reactが将来的にレンダリングを複数回試したり、途中で中断したりしても安全に動く保証がなくなる
といった問題が起きます。そこで「副作用はレンダリングが終わった後にまとめて実行する」ための仕組みが、次に紹介する useEffect です。
function Profile({ userId }) {
// ❌ レンダリング中に直接副作用を書くのはNG
// fetch(`/api/users/${userId}`).then(...);
// ✅ useEffectの中に副作用を書く
useEffect(() => {
fetch(`/api/users/${userId}`).then(...);
}, [userId]);
return <div>...</div>;
}
この3つの言葉のイメージを持ったうえで、実際に各Hookを見ていきましょう。
2. useState — 状態を持つ
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
-
useState(初期値)→[現在の値, 更新関数]のペアを返す -
setCountを呼ぶと再レンダリングが起きる - 更新関数には値 or 「前の値を受け取る関数」を渡せる
useState の注意点
// NG: 連続で呼んでも1回分しか反映されないことがある
setCount(count + 1);
setCount(count + 1);
// OK: 関数形式で「前の値」を確実に受け取る
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
Stateは直接書き換えない! state.push(...) ではなく setState([...state, item]) のように新しい値を作って渡す。
useState 実務での使いどころ
| ユースケース | 例 |
|---|---|
| フォーム入力 | const [email, setEmail] = useState("") |
| トグルUI | モーダルの開閉、アコーディオン、タブ切り替え |
| APIの状態管理 |
data / isLoading / error の3点セット |
| 一時的なUI状態 | ホバー中フラグ、選択中の行、ページ番号 |
// 関連する値は1つのオブジェクトにまとめてもOK
const [form, setForm] = useState({ name: "", email: "" });
setForm((prev) => ({ ...prev, email: "new@example.com" }));
オブジェクトstateを更新するときは ...prev でコピーを忘れると、更新していないフィールドが消えてしまう。
3. useEffect — 副作用を扱う
「副作用」=レンダー以外でやりたいこと(通信・タイマー・DOM操作など)
useEffect(() => {
console.log("マウントされた or 依存値が変わった");
return () => {
console.log("クリーンアップ(アンマウント前 or 再実行前)");
};
}, [依存配列]);
| 依存配列 | 実行タイミング |
|---|---|
| 省略 | 毎レンダー後 |
[] |
初回マウント時のみ |
[a, b] |
初回 + a か b が変わった時 |
useEffect の実例:データ取得
function ProjectList() {
const [projects, setProjects] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setProjects(projectsData);
setIsLoading(false);
}, 600);
return () => clearTimeout(timer); // クリーンアップ
}, []); // 初回のみ実行
if (isLoading) return <Loading />;
return <List items={projects} />;
}
useEffect 実務での使いどころ
- データ取得(API呼び出し、ページ遷移時の再取得)
-
外部イベントの購読/解除(
window.addEventListener("resize", ...)) -
外部システムとの同期(
document.titleの変更、WebSocket接続) -
タイマー処理(
setIntervalでのポーリング、自動保存)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
「登録したら必ず解除する」がセット。addしたらremove、setしたらclearを書く癖をつける。
よくある落とし穴① 無限ループ
// NG: オブジェクト/配列を依存配列に入れると、
// 毎レンダーで「新しい参照」になり、無限に発火する
const options = { limit: 10 };
useEffect(() => {
fetchData(options);
}, [options]); // ← 毎回 options !== 前回の options
// OK: プリミティブな値だけを依存配列に入れる
useEffect(() => {
fetchData({ limit });
}, [limit]);
「依存配列を減らしたくてESLintの警告を無視する」のは大体バグの温床。警告が出たら「本当にこの値を無視していいか」を考える。
よくある落とし穴② レースコンディション
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data); // 古いリクエストの結果を無視
});
return () => {
ignore = true; // userIdが変わったら前のリクエストは捨てる
};
}, [userId]);
-
userIdが素早く切り替わると、後から呼んだリクエストが先に返ることがある - クリーンアップで「もう使わないフラグ」を立てて古い結果を捨てるのが定石
- 実務では
AbortControllerや React Query / SWR がこれを肩代わりしてくれる
4. useMemo / useCallback — 計算をメモ化する
useMemo — 計算をメモ化する
const filteredProjects = useMemo(
() => projects.filter((p) => p.title.includes(searchText)),
[projects, searchText] // これらが変わった時だけ再計算
);
- 重い計算・配列操作を依存値が変わった時だけやり直す
- 依存配列が変わらなければ、前回の計算結果をそのまま再利用
useCallback — 関数をメモ化する
const handleSearchChange = useCallback((value) => {
setSearchText(value);
}, []); // 依存配列が変わらなければ同じ関数を使い回す
-
useMemoの「関数版」 - 子コンポーネントに関数をpropsで渡すとき、毎回新しい関数が作られて無駄な再レンダリングが起きるのを防げる
-
React.memoとセットで使うと効果を発揮する
実務での考え方
最初から全部につけるのはNG。React公式も「まず測ってから最適化する」ことを推奨している。軽い計算にまで使うと、コードが複雑になるだけで逆に遅くなることもある。
本当に必要になる代表的な場面
| 場面 | 理由 |
|---|---|
| 配列の filter/sort/reduce が重い or 頻繁 | 毎レンダー計算を避けたい |
React.memo した子コンポーネントに関数/オブジェクトを渡す |
参照が変わると memo が効かない |
Context の value に渡すオブジェクト |
毎回新しい参照だと全consumerが再レンダー |
| 他のHookの依存配列に渡す値 | 参照の安定化が必要 |
5. useRef — DOMや値を保持する
function ContactForm() {
const nameInputRef = useRef(null);
useEffect(() => {
nameInputRef.current?.focus(); // マウント時にフォーカス
}, []);
return <input ref={nameInputRef} name="name" />;
}
-
.currentに値を持たせられる「箱」 - 値を変えても再レンダリングされない(useStateとの一番の違い)
- DOM要素への参照 / タイマーID / 前回の値の保持などに使う
実務での使いどころ
// 1. setInterval / setTimeout のIDを保持する
const timerRef = useRef(null);
timerRef.current = setInterval(tick, 1000);
// ...後で clearInterval(timerRef.current)
// 2. 「前回の値」を覚えておく(usePrevious パターン)
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
レンダー中に ref.current を読み書きしない(表示に反映されないのに値だけ変わり、バグの温床になる)。基本は「イベントハンドラの中」「useEffectの中」で触る。
6. useReducer — 複雑な状態をまとめる
function likeReducer(state, action) {
switch (action.type) {
case "toggle":
return { liked: !state.liked, count: state.count + (state.liked ? -1 : 1) };
default:
return state;
}
}
function LikeButton() {
const [state, dispatch] = useReducer(likeReducer, { liked: false, count: 42 });
return <button onClick={() => dispatch({ type: "toggle" })}>{state.count}</button>;
}
- 複数の値が連動して変わる状態を1つのreducer関数にまとめられる
-
useStateをいくつも並べるより、更新ロジックが1箇所にまとまり見通しが良い
useReducer を使うべきタイミング
こういう時は useState を並べるより useReducer
|
具体例 |
|---|---|
| 複数のstateが同時に・連動して変わる | フォームの「入力値」と「エラー」と「送信中」 |
| 次のstateが前のstateとactionから決まる | カート(追加・削除・数量変更) |
| 状態遷移が多い | ウィザード形式のステップ管理 |
迷ったら最初は useState でOK。「更新ロジックがあちこちに散らばって辛い」と感じたら useReducer への切り替えを検討する、で十分。
7. createContext / useContext — 状態を共有する
状態をPropsのバケツリレーなしで下の階層まで届ける仕組みです。
// 1. Contextを作る
const ThemeContext = createContext({ theme: "light", toggleTheme: () => {} });
// 2. Providerで囲んで値を配る
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<App />
</ThemeContext.Provider>
// 3. 好きな深さのコンポーネントから読む
const { theme, toggleTheme } = useContext(ThemeContext);
Context を使うべき時 / 使わなくていい時
| 使うべき | 使わなくていい |
|---|---|
| テーマ(ダーク/ライト) | ボタンの中だけで完結するstate |
| ログイン中のユーザー情報 | フォーム1個の入力値 |
| 言語設定(i18n) | 親から子1階層だけで渡せる値 |
Contextは「グローバル変数」に近い便利さと危険さがある。なんでもContextに入れるとデータの流れが追いにくくなる。
よくある落とし穴:再レンダリング
// NG: 毎レンダーで value が新しいオブジェクトになり、
// 全consumerが無駄に再レンダリングされる
<ThemeContext.Provider value={{ theme, toggleTheme }}>
// OK: useMemoでvalueを安定化する
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
<ThemeContext.Provider value={value}>
実務では「頻繁に変わる値」と「あまり変わらない値」でContextを分けることも多い(例: AuthContext と AuthDispatchContext を分離)。
8. カスタムフック — 自分だけのHookを作る
function useProjects() {
const [projects, setProjects] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setProjects(projectsData);
setIsLoading(false);
}, 600);
return () => clearTimeout(timer);
}, []);
return { projects, isLoading };
}
-
useから始まるただの関数。中で他のHooksを呼べる - 「useState + useEffect」のセットをコンポーネントの外に切り出せる
- 複数のコンポーネントで同じロジックを使い回せる
実務でよく使うカスタムフック集
| フック名 | 用途 |
|---|---|
useDebounce(value, delay) |
検索入力などの発火を遅らせる |
useLocalStorage(key, initial) |
stateをlocalStorageと同期する |
useMediaQuery(query) |
画面幅に応じてUIを出し分ける |
useOnClickOutside(ref, handler) |
ドロップダウンの外側クリックで閉じる |
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Hooksのルール(超重要)
- トップレベルでのみ呼ぶ → if文やfor文、ネストした関数の中で呼ばない
- Reactの関数コンポーネント / カスタムフックの中でのみ呼ぶ → 普通のJS関数やイベントハンドラの中では呼ばない
// NG
if (isLoggedIn) {
const [name, setName] = useState("");
}
// OK
const [name, setName] = useState("");
if (isLoggedIn) {
// ...
}
実務では eslint-plugin-react-hooks を必ず導入する。このルール違反や依存配列の書き忘れをリアルタイムで警告してくれる。
9. React Router DOM — ページを分割する
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
</Routes>
</BrowserRouter>
<Link to="/projects">Projects</Link>
- SPA(1枚のHTML)のまま、URLごとに表示するコンポーネントを切り替える
-
Link/NavLinkでページ遷移(ブラウザリロードなし)
React Router のHooks
import { useParams, useNavigate } from "react-router-dom";
function ProjectDetail() {
const { id } = useParams(); // URLの :id 部分を取得
const navigate = useNavigate(); // JSからページ遷移させる
const project = projects.find((p) => p.id === id);
return (
<div>
<button onClick={() => navigate(-1)}>← 戻る</button>
<h1>{project?.title}</h1>
</div>
);
}
/projects/task-board にアクセスすると id === "task-board" が取れます。
React Router 実務Tips
// クエリパラメータ(?tag=react)を扱う
const [searchParams, setSearchParams] = useSearchParams();
const tag = searchParams.get("tag");
// どのpathにも一致しない = 404ページ
<Route path="*" element={<NotFound />} />
// ログインしていないと弾く「認証ガード」の基本形
function RequireAuth({ children }) {
const { user } = useContext(AuthContext);
return user ? children : <Navigate to="/login" replace />;
}
本格的なアプリでは、ページ単位で React.lazy + Suspense を組み合わせてコード分割(初期表示を軽くする)するのも定番のテクニックです。
まとめ
- Hooksは「関数コンポーネントに状態やライフサイクルを持たせる仕組み」
-
useState/useEffectが基本、useMemo/useCallback/useRef/useReducerは最適化・特定用途 -
createContext+useContextでProps地獄から解放される - カスタムフックでロジックを部品化できる
- React Router DOMでページ単位のアプリが作れる
次のステップ(実務でさらに使うもの)
| これを学んだら | 次はこれ |
|---|---|
useEffect でのデータ取得 |
React Query / SWR(キャッシュ・再取得を肩代わり) |
useReducer + Context |
Zustand / Redux Toolkit(大規模な状態管理) |
フォームの useState 地獄 |
React Hook Form / Zod によるバリデーション |
| ESLintの手動運用 |
eslint-plugin-react-hooks を必ず導入 |
お疲れ様でした! 実装、頑張ってください 🎉