はじめに
useState や useEffect を使いこなせるようになってきたけど、「カスタムフックって何?本当に必要?」と感じていませんか。
この記事を読み終えると、次の3つが自力でできるようになります。
-
useFormInput-- フォーム入力ロジックをコンポーネントから切り出す -
useFetch-- データ取得の loading / error / data 3状態を管理する -
useLocalStorage-- localStorage と useState を組み合わせて永続化する
なぜカスタムフックが必要なのか
同じ useState + useEffect のパターンが複数のコンポーネントに散らばっている状態、よくありますよね。
カスタムフックはそのロジックを「関数として切り出す」だけの話です。
特別な魔法はありません。でも、3つの恩恵があります。
| 恩恵 | 説明 |
|---|---|
| 再利用性 | 同じロジックを複数のコンポーネントで使い回せる |
| テスタビリティ | ロジック単体を renderHook でテストできる |
| 可読性 | コンポーネントに「何をするか」だけが残り、「どうやるか」が分離される |
カスタムフックの基本ルール3つ
Rules of Hooks を守らないと、バグの温床になります。必ず覚えておきましょう。
ルール1: 名前は必ず use で始める
useFormInput や useFetch のように。
これにより、React が「これはフックだ」と認識し、違反を lint で検出できます。
ルール2: 内部でフックを呼び出せる
カスタムフックの中では useState や useEffect など他のフックを自由に使えます。
それが普通の関数との違いです。
ルール3: トップレベルでのみ呼び出す
条件分岐やループの中でフックを呼んではいけません。
これは組み込みフックと同じルールです。後の「よくある落とし穴」で詳しく扱います。
実践パターン1: useFormInput -- フォーム入力の共通化
フォームを実装するとき、こんなコードを何度も書いていませんか。
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
フィールドが増えるたびに同じパターンが増殖します。
これを useFormInput として切り出しましょう。
import { useState, useCallback } from 'react';
type UseFormInputReturn = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
reset: () => void;
};
export function useFormInput(initialValue: string = ''): UseFormInputReturn {
const [value, setValue] = useState(initialValue);
// useCallback で関数の参照を安定させる
// これにより、親コンポーネントの再レンダリングで不要な子再レンダリングを防げる
const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}, []);
const reset = useCallback(() => {
setValue(initialValue);
}, [initialValue]);
return { value, onChange, reset };
}
useCallback を使って onChange と reset の参照を安定させています。
React.memo でラップした子コンポーネントに渡すとき、参照が毎回変わると再レンダリングが発生してしまいます。
最初から安定した参照を返すクセをつけておくと、後から困りません。
import { useFormInput } from './useFormInput';
export function SignupForm() {
const username = useFormInput('');
const email = useFormInput('');
const password = useFormInput('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log({ username: username.value, email: email.value });
username.reset();
email.reset();
password.reset();
};
return (
<form onSubmit={handleSubmit}>
<input type="text" placeholder="ユーザー名" value={username.value} onChange={username.onChange} />
<input type="email" placeholder="メールアドレス" value={email.value} onChange={email.onChange} />
<input type="password" placeholder="パスワード" value={password.value} onChange={password.onChange} />
<button type="submit">登録</button>
</form>
);
}
コンポーネントに残るのは「何を表示するか」だけになりました。
{...username} のようにスプレッド構文で渡すと reset 関数も DOM 属性として渡ってしまい、React が Unknown prop 'reset' 警告を出します。
value と onChange を個別に指定するか、DOM に渡す用の inputProps を別途返す設計にしましょう。
実践パターン2: useFetch -- データ取得の抽象化
APIからデータを取得するとき、loading・error・data の3状態管理はほぼ毎回必要です。
これを毎回 useEffect に書いていると、コンポーネントがすぐ肥大化しますよね。
import { useState, useEffect } from 'react';
type FetchState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
export function useFetch<T>(url: string | null): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// url が null の場合はフェッチしない
if (url === null) return;
// AbortController はコンポーネントのアンマウント時にリクエストをキャンセルするために使う
// これがないと、アンマウント後にstateを更新しようとしてメモリリークが起きる
const controller = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const json: T = await response.json();
setData(json);
} catch (err) {
// AbortError はキャンセルによる意図的なエラーなので無視する
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
fetchData();
// クリーンアップ関数でリクエストをキャンセル
return () => {
controller.abort();
};
}, [url]); // url が変わったら再フェッチする
return { data, loading, error };
}
import { useFetch } from './useFetch';
type User = {
id: number;
name: string;
email: string;
};
export function UserList() {
const { data, loading, error } = useFetch<User[]>('https://jsonplaceholder.typicode.com/users');
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラーが発生しました: {error}</p>;
if (!data) return null;
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
コンポーネント側では3つの状態を受け取って表示を切り替えるだけです。
フェッチのロジックは完全に useFetch に閉じ込めました。
React 19 の use() について
React 19 では use() というフックが導入され、Promise を直接 Suspense と組み合わせて扱う新しいパターンが使えます。
useFetch のような自前実装が不要になるケースも増えています。
React のバージョンに合わせて使い分けましょう。
実践パターン3: useLocalStorage -- 永続化されるstate
テーマ設定やフォームの一時保存など、「ページをリロードしても消えない state」が欲しいことありますよね。
useState と localStorage を組み合わせた useLocalStorage を作りましょう。
import { useState, useCallback } from 'react';
type UseLocalStorageReturn<T> = [T, (value: T | ((prev: T) => T)) => void];
export function useLocalStorage<T>(key: string, initialValue: T): UseLocalStorageReturn<T> {
const [storedValue, setStoredValue] = useState<T>(() => {
// 初回レンダリング時に localStorage から読み込む
// 遅延初期化(関数を渡す形式)で毎回の読み込みを避ける
try {
const item = window.localStorage.getItem(key);
// JSON.parse は不正な文字列に対して SyntaxError を投げる
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
// SyntaxError(不正なJSON)や SecurityError(プライベートブラウジング)を捕捉
console.warn(`useLocalStorage: key "${key}" の読み込みに失敗しました`, error);
return initialValue;
}
});
// useCallback でセッター関数を安定させる
// これにより、このフックを使うコンポーネントが不必要に再レンダリングされない
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
// setStoredValue の関数形式を使って current から次の値を求める
// こうすることで storedValue を依存配列に入れずに済み、参照が安定する
setStoredValue((current) => {
const valueToStore = value instanceof Function ? value(current) : value;
window.localStorage.setItem(key, JSON.stringify(valueToStore));
return valueToStore;
});
} catch (error) {
// QuotaExceededError(容量超過)等も捕捉
console.warn(`useLocalStorage: key "${key}" への書き込みに失敗しました`, error);
}
},
[key]
);
return [storedValue, setValue];
}
本番環境での型検証について
JSON.parse の結果は型アサーション(as T)で強制キャストしているため、実際の値が期待する型と一致する保証はありません。
本番環境では zod 等のスキーマバリデーションライブラリを使って型の正確性を保証することを推奨します。
import { useLocalStorage } from './useLocalStorage';
type Theme = 'light' | 'dark';
export function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<Theme>('theme', 'light');
return (
<div>
<p>現在のテーマ: {theme}</p>
<button onClick={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}>
テーマを切り替える
</button>
</div>
);
}
戻り値をタプル [storedValue, setValue] にしているので、useState と同じ感覚で使えます。
リロードしても theme の値が保持されます。
よくある落とし穴
カスタムフックを書き始めると、ハマりやすいポイントが3つあります。
コードで確認しましょう。
落とし穴1: 条件分岐の中でフックを呼ぶ(Rules of Hooks 違反)
function BadComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
if (isLoggedIn) {
// エラー: フックの呼び出し順序が変わるとReactが状態を追跡できなくなる
const { data } = useFetch('/api/user');
}
return <div>...</div>;
}
function GoodComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
// フックは常にトップレベルで呼ぶ
// isLoggedIn が false のとき null を渡すとフェッチをスキップできる
const { data } = useFetch(isLoggedIn ? '/api/user' : null);
if (!isLoggedIn) return <div>ログインしてください</div>;
return <div>{data?.name}</div>;
}
React はフックを「呼び出された順番」で識別しています。
条件分岐でフックが呼ばれたり呼ばれなかったりすると、その順番がズレてバグになります。
落とし穴2: useEffect のクリーンアップを忘れてメモリリーク
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
// コンポーネントがアンマウントされた後も timer が動き続ける
}, []);
useEffect(() => {
const timer = setInterval(() => {
console.log('tick');
}, 1000);
return () => {
clearInterval(timer); // アンマウント時にタイマーを止める
};
}, []);
setInterval・addEventListener・WebSocket など、後始末が必要なものは必ずクリーンアップ関数を書きましょう。
落とし穴3: 不要な useCallback/useMemo の乱用
const handleClick = useCallback(() => {
setCount(1); // 単純な操作に useCallback は不要
}, []);
// 子コンポーネントに渡す関数や、useEffect の依存配列に入れる関数には使う
const fetchUser = useCallback(async () => {
const user = await api.getUser(userId);
setUser(user);
}, [userId]);
useCallback や useMemo はパフォーマンス最適化ツールです。
「とりあえず全部つける」はコードを複雑にするだけです。
まず動かして、プロファイラで問題を確認してから使いましょう。
カスタムフックを書くときの設計指針
落とし穴を踏まえた上で、カスタムフックを設計するときのベストプラクティスをまとめます。
指針1: 1フック1責務
useUserFormWithFetch のように複数の責務を詰め込まないこと。
「フォームの管理」と「データ取得」は別のフックに分けましょう。
一つの役割に絞ることで、テストも再利用もしやすくなります。
指針2: 戻り値の形はケースバイケース
| 戻り値の数 | 推奨形式 | 例 |
|---|---|---|
| 2つ以下 | タプル | [value, setValue] |
| 3つ以上 | オブジェクト | { data, loading, error } |
タプルは const [count, setCount] = useCounter() のように名前を自由につけられます。
オブジェクトは const { data, loading } = useFetch(url) のように分かりやすい名前が付いています。
指針3: 返却する関数は useCallback で安定させる
フックが返す関数は useCallback で包んで参照を安定させましょう。
呼び出し側がその関数を useEffect の依存配列に入れたとき、不要な再実行を防げます。
指針4: テストしやすい設計(副作用を引数で注入可能に)
// fetchFn を引数で受け取ることで、テスト時にモックを注入できる
export function useFetch<T>(
url: string,
fetchFn: typeof fetch = fetch // デフォルトはグローバルfetch
): FetchState<T> {
// ...
}
// テストでは
const { result } = renderHook(() =>
useFetch('/api/users', mockFetch)
);
副作用(fetch、localStorage、Date など)を引数として受け取れる設計にすると、テスト時に簡単にモックできます。
指針5: Next.js 等では 'use client' ディレクティブが必要
useState や useEffect を使うカスタムフックは、サーバーコンポーネントでは動きません。
Next.js の App Router を使っている場合、カスタムフックを使うコンポーネントのファイル先頭に 'use client' ディレクティブが必要です。
React Server Components(RSC)との使い分けは別途深掘りが必要なので、詳細は公式ドキュメントや関連記事を参照してください。
まとめ
カスタムフックは特別なものではありません。
「コンポーネントからロジックを抜き出す関数」に過ぎません。
名前が use で始まり、内部でフックを呼べる。それだけです。
この記事で扱った3つのパターンを振り返ります。
-
useFormInput-- フォームの value / onChange / reset をまとめて管理 -
useFetch-- loading / error / data の3状態と AbortController によるキャンセル -
useLocalStorage-- localStorage と useState の橋渡し、エラーハンドリング付き
次のステップ
実際に手を動かすことが大事です。
次の3つのアクションをおすすめします。
-
自分のプロジェクトで重複している
useState + useEffectを探して切り出す
小さなフックを1つ作るだけで、感覚がつかめますよ。 -
renderHookを使ってカスタムフックのテストを書く
@testing-library/reactのrenderHookでコンポーネントなしにフックをテストできます。
ロジックが分離されているから、テストがシンプルになるのを実感できます。 -
useReducerや React Query との使い分けを学ぶ
状態が複雑になってきたらuseReducer、サーバー状態の管理には React Query(TanStack Query)が強力です。
カスタムフックの設計力が上がると、これらのライブラリの良さも理解しやすくなります。