React Custom Hookの実務的な使い方|実装パターンと業務での活用事例
React開発において、Custom Hook(カスタムフック)は非常に強力なツールです。しかし、教科書的な例だけでは実務でどう活用すればよいか、判断がつきにくいものです。本記事では、実際のプロジェクトで使用されるCustom Hookの実装パターンと、その活用方法を紹介します。
1. Custom Hookの簡易的な解説
React Custom Hookは、React Hooksの機能を組み合わせて、ロジックを再利用可能な関数にしたものです。useState、useEffect、useContextなどのHooksを内部で使用し、コンポーネント間でロジックを共有できます。
基本的な特徴は以下の通りです:
- 「use」で始まる関数として定義する
- 内部で他のHooksを呼び出せる
- ステートフルなロジックをコンポーネントから分離できる
- 複数のコンポーネントで同じロジックを再利用できる
2. 業務でのユースケース
実務開発では、以下のようなシーンでCustom Hookが活躍します:
ユースケース1:フォーム管理の複雑さへの対応
複数のフォームフィールドを持つページでは、各フィールドの入力値管理、バリデーション、送信処理などが複雑になります。これらをCustom Hookに集約することで、コンポーネントのコードがシンプルになります。
ユースケース2:API通信のロジック共有
複数のコンポーネントから同じAPIエンドポイントへアクセスする場合、ローディング状態やエラーハンドリングをCustom Hookで統一できます。
ユースケース3:ローカルストレージの操作
ユーザーの設定やアプリケーションの状態をローカルストレージに保存・読み込みする処理は、複数箇所で必要になることが多いです。
3. 実装コード|実務で使える4つのパターン
パターン1:フォーム管理Hook
以下は、実務でよく使われるフォーム管理のCustom Hookです。入力値の管理、バリデーション、リセット機能が含まれています。
// useForm.ts
import { useState, useCallback } from 'react';
interface FormErrors {
[key: string]: string;
}
interface UseFormOptions {
initialValues: Record<string, any>;
onSubmit: (values: Record<string, any>) => Promise<void> | void;
validate?: (values: Record<string, any>) => FormErrors;
}
export const useForm = ({
initialValues,
onSubmit,
validate,
}: UseFormOptions) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const fieldValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
setValues((prev) => ({
...prev,
[name]: fieldValue,
}));
// リアルタイムバリデーション
if (touched[name] && validate) {
const newErrors = validate({ ...values, [name]: fieldValue });
setErrors((prev) => ({
...prev,
[name]: newErrors[name] || '',
}));
}
},
[values, touched, validate]
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name } = e.target;
setTouched((prev) => ({
...prev,
[name]: true,
}));
if (validate) {
const newErrors = validate(values);
setErrors((prev) => ({
...prev,
[name]: newErrors[name] || '',
}));
}
},
[values, validate]
);
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (validate) {
const newErrors = validate(values);
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
return;
}
}
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
},
[values, validate, onSubmit]
);
const resetForm = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
resetForm,
setValues,
};
};
使用例:
// LoginForm.tsx
import { useForm } from './useForm';
interface LoginFormValues {
email: string;
password: string;
}
const LoginForm = () => {
const validate = (values: LoginFormValues) => {
const errors: FormErrors = {};
if (!values.email) {
errors.email = 'メールアドレスは必須です';
} else if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(values.email)) {
errors.email = '有効なメールアドレスを入力してください';
}
if (!values.password) {
errors.password = 'パスワードは必須です';
} else if (values.password.length < 8) {
errors.password = 'パスワードは8文字以上である必要があります';
}
return errors;
};
const handleLoginSubmit = async (values: LoginFormValues) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error('ログインに失敗しました');
}
};
const form = useForm({
initialValues: { email: '', password: '' },
validate,
onSubmit: handleLoginSubmit,
});
return (
<form onSubmit={form.handleSubmit}>
<div>
<input
type=\"email\"
name=\"email\"
value={form.values.email}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder=\"メールアドレス\"
/>
{form.touched.email && form.errors.email && (
<span style={{ color: 'red' }}>{form.errors.email}</span>
)}
</div>
<div>
<input
type=\"password\"
name=\"password\"
value={form.values.password}
onChange={form.handleChange}
onBlur={form.handleBlur}
placeholder=\"パスワード\"
/>
{form.touched.password && form.errors.password && (
<span style={{ color: 'red' }}>{form.errors.password}</span>
)}
</div>
<button type=\"submit\" disabled={form.isSubmitting}>
{form.isSubmitting ? 'ログイン中...' : 'ログイン'}
</button>
</form>
);
};
パターン2:API通信Hook(useFetch)
実務では、複数のコンポーネントから異なるAPIエンドポイントへアクセスします。このHookで、データ取得、ローディング、エラーを統一的に管理できます。
// useFetch.ts
import { useState, useEffect, useCallback } from 'react';
interface UseFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: Record<string, any>;
skip?: boolean;
}
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export const useFetch = <T = any,>(
url: string,
options: UseFetchOptions = {}
): UseFetchState<T> & { refetch: () => Promise<void> } => {
const { method = 'GET', headers = {}, body, skip = false } = options;
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: !skip,
error: null,
});
const fetchData = useCallback(async () => {
if (skip) return;
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const token = localStorage.getItem('authToken');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const result = (await response.json()) as T;
setState({ data: result, loading: false, error: null });
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setState((prev) => ({ ...prev, error, loading: false }));
}
}, [url, method, headers, body, skip]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(async () => {
await fetchData();
}, [fetchData]);
return { ...state, refetch };
};
使用例:
// UserList.tsx
import { useFetch } from './useFetch';
interface User {
id: number;
name: string;
email: string;
}
const UserList = () => {
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return (
<div>
<button onClick={refetch}>更新</button>
<ul>
{users?.map((user) => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</div>
);
};
パターン3:ローカルストレージHook(useLocalStorage)
ユーザーの設定やアプリケーション状態の永続化に使います。
// useLocalStorage.ts
import { useState, useCallback, useEffect } from 'react';
export const useLocalStorage = <T,>(
key: string,
initialValue: T
): [T, (value: T) => void, () => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key \"${key}\":`, error);
return initialValue;
}
});
const setValue = useCallback(
(value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error setting localStorage key \"${key}\":`, error);
}
},
[key]
);
const removeValue = useCallback(() => {
try {
setStoredValue(initialValue);
window.localStorage.removeItem(key);
} catch (error) {
console.error(`Error removing localStorage key \"${key}\":`, error);
}
}, [key, initialValue]);
// 別タブでの変更を検知
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue, removeValue];
};
使用例:
// ThemeToggle.tsx
import { useLocalStorage } from './useLocalStorage';
type Theme = 'light' | 'dark';
const ThemeToggle = () => {
const [theme, setTheme] = useLocalStorage<Theme>('app-theme', 'light');
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<div style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff',
padding: '20px',
}}>
<p>現在のテーマ: {theme}</p>
<button onClick={toggleTheme}>テーマを切り替え</button>
</div>
);
};
パターン4:デバウンス付きHook(useDebounce)
検索ボックスなどで、入力値の変化に対して遅延実行が必要な場合に使います。
// useDebounce.ts
import { useState, useEffect } from 'react';
export const useDebounce = <T,>(value: T, delay: number = 500): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
使用例:
// ProductSearch.tsx
import { useState } from 'react';
import { useDebounce } from './useDebounce';
import { useFetch } from './useFetch';
interface SearchResult {
id: number;
name: string;
}
const ProductSearch = () => {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const { data: results, loading } = useFetch<SearchResult[]>(
`/api/products/search?q=${debouncedSearchTerm}`,
{ skip: !debouncedSearchTerm }
);
return (
<div>
<input
type=\"text\"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder=\"商品を検索...\"
/>
{loading && <p>検索中...</p>}
<ul>
{results?.map((result) => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</div>
);
};
4. よくある応用パターン
複合Hook:useFormWithAPI
フォーム送信とAPI通信を組み合わせたHookです。実務では非常に頻繁に使われます。
// useFormWithAPI.ts
import { useState, useCallback } from 'react';
import { useForm } from './useForm';
interface UseFormWithAPIOptions {
initialValues: Record<string, any>;
validate?: (values: Record<string, any>) => Record<string, string>;
apiEndpoint: string;
method?: 'POST' | 'PUT' | 'PATCH';
onSuccess?: () => void;
}
export const useFormWithAPI = ({
initialValues,
validate,
apiEndpoint,
method = 'POST',
onSuccess,
}: UseFormWithAPIOptions) => {
const [apiError, setApiError] = useState<string | null>(null);
const onSubmit = useCallback(
async (values: Record<string, any>) => {
setApiError(null);
try {
const token = localStorage.getItem('authToken');
const response = await fetch(apiEndpoint, {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify(values),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'APIリクエストに失敗しました');
}
onSuccess?.();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
setApiError(message);
throw error;
}
},
[apiEndpoint, method, onSuccess]
);
const form = useForm({
initialValues,
validate,
onSubmit,
});
return {
...form,
apiError,
setApiError,
};
};
ページネーションHook(usePagination)
リスト表示の際にページング機能が必要な場合が多いです。
// usePagination.ts
import { useState, useMemo } from 'react';
interface UsePaginationOptions {
items: any[];
itemsPerPage: number;
}
export const usePagination = ({ items, itemsPerPage }: UsePaginationOptions) => {
const [currentPage, setCurrentPage] = useState(1);
const { currentItems, totalPages } = useMemo(() => {
const total = Math.ceil(items.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const current = items.slice(startIndex, endIndex);
return {
currentItems: current,
totalPages: total,
};
}, [items, itemsPerPage, currentPage]);
const goToPage = (page: number) => {
const pageNumber = Math.max(1, Math.min(page, totalPages));
setCurrentPage(pageNumber);
};
return {
currentItems,
currentPage,
totalPages,
goToPage,
nextPage: () => goToPage(currentPage + 1),
prevPage: () => goToPage(currentPage - 1),
};
};
5. 実務での注意点
注意点1:無限ループの防止
useEffectの依存配列を正しく設定しないと、無限ループが発生します。特に、フェッチ関数をuseCallbackでメモ化することが重要です。
// 悪い例
const MyComponent = () => {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data').then(res => res.json()).then(setData);
}, []); // 空の依存配列は問題ありませんが...
return <div>{JSON.stringify(data)}</div>;
};
// 良い例
const MyComponent = () => {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const response = await fetch('/api/data');
const result = await response.json();
setData(result);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{JSON.stringify(data)}</div>;
};
注意点2:メモリリークの防止
コンポーネントがアンマウントされる際に、タイマーやリスナーをクリーンアップする必要があります。
// useOnlineStatus.ts
import { useState, useEffect } from 'react';
export const useOnlineStatus = () => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// クリーンアップ関数でリスナーを削除
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
};
注意点3:キャッシング戦略
同じデータを何度も取得するのは非効率です。実務では、キャッシング機能をHookに組み込むことが一般的です。
// useFetchWithCache.ts
import { useState, useCallback } from 'react';
const cache = new Map<string, { data: any; timestamp: number }>();
const CACHE_DURATION = 5 * 60 * 1000; // 5分
export const useFetchWithCache = <T = any,>(url: string) => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async (forceRefresh = false) => {
// キャッシュが有効か確認
if (!forceRefresh && cache.has(url)) {
const cached = cache.get(url)!;
if (Date.now() - cached.timestamp < CACHE_DURATION) {
setData(cached.data);
return;
}
}
setLoading(true);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Fetch failed');
const result = (await response.json()) as T;
// キャッシュに保存
cache.set(url, { data: result, timestamp: Date.now() });
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
return { data, loading, error, fetchData };
};
注意点4:TypeScriptの型安全性
Custom Hookを使う際は、ジェネリクスを活用して型安全性を確保しましょう。
// 型安全なHookの例
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const useTypedFetch = <T,>(url: string) => {
// ...実装
return {
data: null as T | null,
loading: false,
error: null as Error | null,
};
};
// 使用時に型が自動推論される
const MyComponent = () => {
const { data } = useTypedFetch<ApiResponse<{ id: number; name: string }>>('/api/user');
// dataの型は ApiResponse<{ id: number; name: string }> | null として推論される
};
注意点5:テスタビリティ
Custom Hookのテストは、@testing-library/reactの renderHook を使用します。
// useForm.test.ts
import { renderHook, act } from '@testing-library/react';
import { useForm } from './useForm';
describe('useForm', () => {
it('フォームの入力値が更新される', () => {
const { result } = renderHook(() =>
useForm({
initialValues: { email: '', password: '' },
onSubmit: async () => {},
})
);
const event = {
target: { name: 'email', value: 'test@example.com' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
act(() => {
result.current.handleChange(event);
});
expect(result.current.values.email).toBe('test@example.com');
});
});
6. まとめ
React Custom Hookは、適切に使用することで、コードの再利用性と保守性を大幅に向上させます。実務開発では、フォーム管理、API通信、ローカルストレージの操作など、繰り返される処理をCustom Hookで統一することが非常に重要です。
本記事で紹介した4つの基本パターン(useForm、useFetch、useLocalStorage、useDebounce)と、それらの組み合わせ方を理解することで、ほとんどの実務シナリオに対応できます。
重要なのは、以下の点を常に意識することです:
- 無限ループ防止:useCallbackとuseEffectの依存配列を正確に設定する
- メモリリーク防止:クリーンアップ関数を必ず実装する
- 型安全性:TypeScriptのジェネリクスを活用する
- パフォーマンス:不要な再計算やレンダリングを避ける
- テスタビリティ:Hookのロジックは独立してテスト可能にする
これらを意識して、適切なCustom Hookを設計・実装することで、スケーラブルで保守しやすいReactアプリケーションを構築できるでしょう。

