Reactカスタムフック入門|ロジックを再利用する基本パターン
Reactカスタムフックの基本的な作り方から実践的な活用例まで解説。ロジックの再利用によってコードの保守性と可読性を向上させる方法を詳しく紹介
みなさん、Reactを使っていて「同じコードを何度も書いてる気がする」と感じたことはありませんか?
「APIの呼び出しが似たような感じで、何度も書いてる」 「状態管理のロジックが複数のコンポーネントで重複してる」
こんな経験、ありますよね。
そんな時に活躍するのがカスタムフックです。 これを使うと、同じロジックを何度も書く必要がなくなります。
この記事では、カスタムフックの基本から実践的な使い方まで、初心者にもわかりやすく解説します。 コードの保守性と可読性を向上させるコツも一緒にお伝えしますので、ぜひ参考にしてください。
カスタムフックとは
カスタムフックって、聞いたことありますか?
簡単に言うと、状態管理のロジックを再利用できる関数のことです。 複数のコンポーネントで同じようなロジックを使いたい時に、とても便利な機能なんです。
基本的な概念
まずは、カスタムフックがどんなものかを理解しましょう。
// ❌ 同じロジックを複数のコンポーネントで重複
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}
function UserSettings() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Settings for {user?.name}</div>;
}
上のコードを見てください。 同じようなロジックが2回も書かれていますね。
これがカスタムフックを使うと、こんなに簡潔になります。
// ✅ カスタムフックで共通ロジックを抽象化
function useUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser()
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { user, loading, error };
}
// 簡潔になったコンポーネント
function UserProfile() {
const { user, loading, error } = useUser();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
}
function UserSettings() {
const { user, loading, error } = useUser();
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>Settings for {user?.name}</div>;
}
どうでしょうか? すごくスッキリしましたよね。
このuseUser
がカスタムフックです。
共通のロジックを1つの関数にまとめることで、コードの重複を避けることができます。
カスタムフックのルール
カスタムフックを作る時は、いくつかのルールがあります。
// ✅ 正しいカスタムフック
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
// ❌ 間違った使用例
function NotAHook() {
// 関数名が"use"で始まらない
const [state, setState] = useState(0); // Hooksを使用しているのに命名規則違反
return state;
}
function useInvalidHook() {
if (Math.random() > 0.5) {
// 条件分岐内でHooksを使用(ルール違反)
const [state, setState] = useState(0);
}
return null;
}
ルールはとてもシンプルです。
1. 関数名は必ず「use」で始める
これはReactの決まりごとです。
useCounter
、useToggle
のように、use
で始めてください。
2. Hooksは条件分岐の中で使わない
useState
やuseEffect
は、必ず関数のトップレベルで使いましょう。
3. 必ず値を返す カスタムフックは、コンポーネントが使うデータや関数を返します。
これだけ覚えておけば大丈夫です。
カスタムフックのメリット
カスタムフックを使うと、どんな良いことがあるのでしょうか?
// メリット1: ロジックの再利用
const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => !prev);
}, []);
return [value, toggle];
};
// 複数の場所で使用可能
function Modal() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>Open Modal</button>
{isOpen && <div>Modal Content</div>}
</div>
);
}
function Sidebar() {
const [isExpanded, toggleExpanded] = useToggle(true);
return (
<div>
<button onClick={toggleExpanded}>Toggle Sidebar</button>
<aside style={{ width: isExpanded ? '200px' : '50px' }}>
Sidebar
</aside>
</div>
);
}
このuseToggle
フックは、オン・オフの切り替えが必要な場面で使えます。
モーダルの開閉、サイドバーの表示・非表示など、いろんな場所で活用できますね。
// メリット2: テストの容易さ
// カスタムフックは単体でテストできる
import { renderHook, act } from '@testing-library/react';
test('useToggle should toggle value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);
});
カスタムフックは、コンポーネントとは別にテストできます。 これにより、バグの発見が早くなり、コードの品質が向上します。
// メリット3: 関心の分離
// コンポーネントはUIのみに集中できる
function TodoApp() {
const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
const { filter, setFilter, filteredTodos } = useTodoFilter(todos);
// UIロジックのみに集中
return (
<div>
<TodoInput onAdd={addTodo} />
<TodoFilter filter={filter} onFilterChange={setFilter} />
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
このTodoApp
コンポーネントは、UIの表示だけに集中できています。
データの管理はカスタムフックが担当しているので、コンポーネントがとてもシンプルになりました。
まとめると、カスタムフックのメリットは以下の通りです:
- ロジックの再利用:同じ機能を複数の場所で使える
- テストの容易さ:ロジックを独立してテストできる
- 関心の分離:UIとロジックを分けて考えられる
次は、実際によく使われるカスタムフックのパターンを見てみましょう。
基本的なカスタムフックパターン
実際のアプリケーションでよく使われるパターンを紹介します。
状態管理パターン
シンプルな状態管理を抽象化するパターンです。
// ローカルストレージと同期する状態管理
function useLocalStorage(key, initialValue) {
// 初期値を取得
const [storedValue, setStoredValue] = useState(() => {
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) => {
try {
// 関数が渡された場合は実行
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
// 値を削除する関数
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
このuseLocalStorage
フックは、ブラウザのローカルストレージと連携します。
どんな処理をしているか詳しく見てみましょう:
最初に、ローカルストレージから値を取得します。 見つからない場合は、初期値を使用します。
setValue
関数で値を更新すると、自動的にローカルストレージにも保存されます。
removeValue
関数で値を削除することもできます。
// 使用例
function Settings() {
const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'ja');
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">ライト</option>
<option value="dark">ダーク</option>
</select>
<select value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="ja">日本語</option>
<option value="en">English</option>
</select>
<button onClick={removeTheme}>テーマをリセット</button>
</div>
);
}
使い方はとても簡単です。
普通のuseState
と同じように使えますが、値が自動的にローカルストレージに保存されます。
ページをリロードしても、設定値が保持されているので便利ですね。
// カウンター機能のカスタムフック
function useCounter(initialValue = 0, step = 1) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + step);
}, [step]);
const decrement = useCallback(() => {
setCount(prev => prev - step);
}, [step]);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
const setValue = useCallback((value) => {
setCount(typeof value === 'function' ? value : value);
}, []);
return {
count,
increment,
decrement,
reset,
setValue,
// 便利なプロパティ
isZero: count === 0,
isNegative: count < 0,
isPositive: count > 0,
};
}
このuseCounter
フックは、カウンターの機能を提供します。
主な機能:
increment
:値を増やすdecrement
:値を減らすreset
:初期値に戻すsetValue
:直接値を設定する
さらに、isZero
やisPositive
などの便利なプロパティも含まれています。
// 使用例
function CounterApp() {
const { count, increment, decrement, reset, isZero } = useCounter(0, 2);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+2</button>
<button onClick={decrement}>-2</button>
<button onClick={reset} disabled={isZero}>
Reset
</button>
</div>
);
}
このカウンターは、2ずつ増減します。
isZero
を使って、値が0の時はリセットボタンを無効にしています。
// トグル機能のカスタムフック
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => !prev);
}, []);
const setTrue = useCallback(() => {
setValue(true);
}, []);
const setFalse = useCallback(() => {
setValue(false);
}, []);
return {
value,
toggle,
setTrue,
setFalse,
setValue,
};
}
このuseToggle
フックは、true/falseの切り替えを簡単にします。
主な機能:
toggle
:値を反転させるsetTrue
:trueに設定setFalse
:falseに設定
// 使用例
function ToggleDemo() {
const modal = useToggle(false);
const sidebar = useToggle(true);
return (
<div>
<button onClick={modal.toggle}>
{modal.value ? 'Close' : 'Open'} Modal
</button>
<button onClick={sidebar.toggle}>
{sidebar.value ? 'Hide' : 'Show'} Sidebar
</button>
{modal.value && (
<div className="modal">
<p>Modal Content</p>
<button onClick={modal.setFalse}>Close</button>
</div>
)}
<aside style={{ display: sidebar.value ? 'block' : 'none' }}>
Sidebar Content
</aside>
</div>
);
}
モーダルの開閉やサイドバーの表示・非表示など、いろんな場面で活用できます。
フォーム管理パターン
フォームの状態管理とバリデーションを抽象化します。
// 基本的なフォーム管理フック
function useForm(initialValues = {}) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
// フィールドの値を更新
const setValue = useCallback((name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
// エラーをクリア
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
}, [errors]);
// フィールドがタッチされたことを記録
const setTouched = useCallback((name) => {
setTouched(prev => ({ ...prev, [name]: true }));
}, []);
// エラーを設定
const setFieldError = useCallback((name, error) => {
setErrors(prev => ({ ...prev, [name]: error }));
}, []);
// フォームをリセット
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
// フィールドの変更ハンドラ
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.target;
setValue(name, type === 'checkbox' ? checked : value);
}, [setValue]);
// フィールドのblurハンドラ
const handleBlur = useCallback((e) => {
setTouched(e.target.name);
}, [setTouched]);
// バリデーション
const validate = useCallback((validationRules) => {
const newErrors = {};
Object.keys(validationRules).forEach(field => {
const rule = validationRules[field];
const value = values[field];
if (rule.required && (!value || value.toString().trim() === '')) {
newErrors[field] = rule.required;
} else if (value && rule.pattern && !rule.pattern.test(value)) {
newErrors[field] = rule.patternMessage || 'Invalid format';
} else if (value && rule.minLength && value.length < rule.minLength) {
newErrors[field] = `Minimum ${rule.minLength} characters required`;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [values]);
return {
values,
errors,
touched,
setValue,
setTouched,
setFieldError,
reset,
handleChange,
handleBlur,
validate,
isValid: Object.keys(errors).length === 0,
isDirty: Object.keys(touched).length > 0,
};
}
このuseForm
フックは、フォームの状態管理を簡単にします。
主な機能:
- 値の管理:フォームの入力値を保存
- エラー管理:バリデーションエラーを管理
- タッチ状態:フィールドがタッチされたかを記録
- バリデーション:入力値の検証
// 使用例
function ContactForm() {
const form = useForm({
name: '',
email: '',
message: ''
});
const validationRules = {
name: {
required: '名前は必須です',
minLength: 2
},
email: {
required: 'メールアドレスは必須です',
pattern: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
patternMessage: '正しいメールアドレスを入力してください'
},
message: {
required: 'メッセージは必須です',
minLength: 10
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (form.validate(validationRules)) {
console.log('Form data:', form.values);
// API呼び出しなど
form.reset();
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">名前</label>
<input
id="name"
name="name"
value={form.values.name}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
{form.errors.name && form.touched.name && (
<span className="error">{form.errors.name}</span>
)}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
name="email"
type="email"
value={form.values.email}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
{form.errors.email && form.touched.email && (
<span className="error">{form.errors.email}</span>
)}
</div>
<div>
<label htmlFor="message">メッセージ</label>
<textarea
id="message"
name="message"
value={form.values.message}
onChange={form.handleChange}
onBlur={form.handleBlur}
/>
{form.errors.message && form.touched.message && (
<span className="error">{form.errors.message}</span>
)}
</div>
<button type="submit" disabled={!form.isValid}>
送信
</button>
<button type="button" onClick={form.reset}>
リセット
</button>
</form>
);
}
このフォームでは、以下の処理が自動的に行われます:
- 入力値の管理
- バリデーションの実行
- エラーメッセージの表示
- フォームのリセット
フォームを作るのが、とても簡単になりますね。
非同期処理パターン
API呼び出しなどの非同期処理を抽象化します。
// 基本的な非同期処理フック
function useAsync(asyncFunction, dependencies = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const execute = useCallback(async (...args) => {
setLoading(true);
setError(null);
try {
const result = await asyncFunction(...args);
setData(result);
return result;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
}, [asyncFunction]);
useEffect(() => {
execute();
}, dependencies);
return {
data,
loading,
error,
execute,
// 便利なプロパティ
isSuccess: !loading && !error && data !== null,
isError: !loading && error !== null,
};
}
このuseAsync
フックは、非同期処理の共通パターンを提供します。
主な機能:
- ローディング状態:処理中かどうかを管理
- エラー状態:エラーが発生したかを管理
- データ状態:取得したデータを管理
- 再実行:処理を再度実行できる
// API呼び出し専用フック
function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async (requestOptions = {}) => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
...requestOptions,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
return result;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
}, [url, options]);
const post = useCallback(async (body) => {
return fetchData({
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(body),
});
}, [fetchData, options.headers]);
const put = useCallback(async (body) => {
return fetchData({
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: JSON.stringify(body),
});
}, [fetchData, options.headers]);
const del = useCallback(async () => {
return fetchData({
method: 'DELETE',
});
}, [fetchData]);
return {
data,
loading,
error,
get: fetchData,
post,
put,
delete: del,
refetch: () => fetchData(),
};
}
このuseApi
フックは、API呼び出しを簡単にします。
主な機能:
- GET:データを取得
- POST:データを送信
- PUT:データを更新
- DELETE:データを削除
// 使用例
function UserProfile({ userId }) {
const {
data: user,
loading,
error,
refetch
} = useApi(`/api/users/${userId}`);
const {
post: updateUser,
loading: updating,
error: updateError
} = useApi(`/api/users/${userId}`);
const handleUpdate = async (userData) => {
try {
await updateUser(userData);
refetch(); // データを再取得
} catch (error) {
console.error('Update failed:', error);
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
<button onClick={refetch} disabled={loading}>
Refresh
</button>
<UserEditForm
user={user}
onSubmit={handleUpdate}
loading={updating}
error={updateError}
/>
</div>
);
}
このようにAPI呼び出しが、とても簡単になります。
ローディング状態やエラーの管理も自動的に行われるので、コンポーネントはUIに集中できますね。
次は、さらに実践的なカスタムフックの例を見てみましょう。
実践的なカスタムフック例
実際のアプリケーション開発で使える実用的なカスタムフックを紹介します。
データフェッチングフック
高度なデータフェッチング機能を持つフックです。
// 高機能なデータフェッチングフック
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef();
const fetchData = useCallback(async (fetchOptions = {}) => {
// 前回のリクエストをキャンセル
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
signal: abortControllerRef.current.signal,
...options,
...fetchOptions,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
return result;
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
throw err;
} finally {
setLoading(false);
}
}, [url, options]);
// コンポーネントアンマウント時のクリーンアップ
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
data,
loading,
error,
refetch: fetchData,
// 便利なメソッド
post: useCallback((body) => fetchData({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}), [fetchData]),
put: useCallback((body) => fetchData({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}), [fetchData]),
delete: useCallback(() => fetchData({
method: 'DELETE',
}), [fetchData]),
};
}
このuseFetch
フックは、先ほどの基本版よりも高機能です。
新しい機能:
- リクエストキャンセル:前回のリクエストを自動的にキャンセル
- クリーンアップ:コンポーネントが削除される時の処理
- エラーハンドリング:キャンセルエラーを適切に処理
これにより、より安全で高性能なAPI呼び出しが可能になります。
// ページネーション付きデータフェッチング
function usePaginatedFetch(baseUrl, pageSize = 10) {
const [page, setPage] = useState(1);
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const fetchPage = useCallback(async (pageNumber) => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`${baseUrl}?page=${pageNumber}&limit=${pageSize}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(prev => pageNumber === 1 ? result.items : [...prev, ...result.items]);
setTotal(result.total);
setHasMore(result.items.length === pageSize);
return result;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
}, [baseUrl, pageSize]);
const loadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
fetchPage(nextPage);
}
}, [loading, hasMore, page, fetchPage]);
const refresh = useCallback(() => {
setPage(1);
setData([]);
fetchPage(1);
}, [fetchPage]);
// 初回読み込み
useEffect(() => {
fetchPage(1);
}, [fetchPage]);
return {
data,
loading,
error,
hasMore,
total,
page,
loadMore,
refresh,
// 計算プロパティ
isFirstPage: page === 1,
totalPages: Math.ceil(total / pageSize),
loadedCount: data.length,
};
}
このusePaginatedFetch
フックは、ページネーション機能付きです。
主な機能:
- ページ管理:現在のページ番号を管理
- データ蓄積:読み込んだデータを蓄積
- 追加読み込み:「もっと読む」機能
- リフレッシュ:最初から読み込み直し
// 使用例
function ProductList() {
const {
data: products,
loading,
error,
hasMore,
loadMore,
refresh,
loadedCount,
total
} = usePaginatedFetch('/api/products', 20);
if (error) {
return (
<div>
<p>Error: {error.message}</p>
<button onClick={refresh}>Retry</button>
</div>
);
}
return (
<div>
<div className="header">
<h1>Products ({loadedCount} of {total})</h1>
<button onClick={refresh}>Refresh</button>
</div>
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
{loading && <div>Loading...</div>}
{hasMore && !loading && (
<button onClick={loadMore}>Load More</button>
)}
{!hasMore && products.length > 0 && (
<p>All products loaded!</p>
)}
</div>
);
}
このコンポーネントでは、以下の機能が実現されています:
- 商品の一覧表示
- 追加読み込み(Load More)
- リフレッシュ機能
- 読み込み状態の表示
とても便利な機能ですね。
認証管理フック
ユーザー認証状態を管理するフックです。
// 認証管理フック
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// トークンの検証と初期化
useEffect(() => {
const initAuth = async () => {
try {
const token = localStorage.getItem('authToken');
if (token) {
const response = await fetch('/api/auth/verify', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem('authToken');
}
}
} catch (err) {
console.error('Auth initialization failed:', err);
localStorage.removeItem('authToken');
} finally {
setLoading(false);
}
};
initAuth();
}, []);
// ログイン
const login = useCallback(async (credentials) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) {
throw new Error('Login failed');
}
const { user: userData, token } = await response.json();
localStorage.setItem('authToken', token);
setUser(userData);
return userData;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
}, []);
// ログアウト
const logout = useCallback(async () => {
setLoading(true);
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('authToken')}`
},
});
} catch (err) {
console.error('Logout error:', err);
} finally {
localStorage.removeItem('authToken');
setUser(null);
setLoading(false);
}
}, []);
// パスワード変更
const changePassword = useCallback(async (currentPassword, newPassword) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('authToken')}`,
},
body: JSON.stringify({ currentPassword, newPassword }),
});
if (!response.ok) {
throw new Error('Password change failed');
}
return true;
} catch (err) {
setError(err);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
user,
loading,
error,
login,
logout,
changePassword,
// 便利なプロパティ
isAuthenticated: !!user,
isAdmin: user?.role === 'admin',
userName: user?.name,
userEmail: user?.email,
};
}
このuseAuth
フックは、ユーザー認証を管理します。
主な機能:
- 自動ログイン:ページリロード時の認証状態復元
- ログイン処理:認証情報の送信と保存
- ログアウト処理:認証情報の削除
- パスワード変更:パスワード更新機能
// 権限チェックフック
function usePermissions(requiredPermissions = []) {
const { user, isAuthenticated } = useAuth();
const hasPermission = useCallback((permission) => {
if (!isAuthenticated || !user) return false;
return user.permissions?.includes(permission) || user.role === 'admin';
}, [isAuthenticated, user]);
const hasAllPermissions = useCallback(() => {
return requiredPermissions.every(permission => hasPermission(permission));
}, [requiredPermissions, hasPermission]);
const hasAnyPermission = useCallback(() => {
return requiredPermissions.some(permission => hasPermission(permission));
}, [requiredPermissions, hasPermission]);
return {
hasPermission,
hasAllPermissions,
hasAnyPermission,
canAccess: hasAllPermissions(),
permissions: user?.permissions || [],
role: user?.role,
};
}
このusePermissions
フックは、権限チェックを簡単にします。
主な機能:
- 権限チェック:特定の権限を持っているかチェック
- 複数権限:すべての権限を持っているかチェック
- アクセス可能性:画面にアクセスできるかチェック
// 使用例
function ProtectedComponent() {
const { user, isAuthenticated, logout } = useAuth();
const { canAccess } = usePermissions(['read:posts', 'write:posts']);
if (!isAuthenticated) {
return <LoginForm />;
}
if (!canAccess) {
return <div>You don't have permission to access this resource.</div>;
}
return (
<div>
<header>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</header>
<main>
<PostList />
<PostEditor />
</main>
</div>
);
}
このコンポーネントでは、以下の処理が行われています:
- 認証状態のチェック
- 権限のチェック
- 適切な画面の表示
認証と権限管理が、とても簡単に実装できますね。
リアルタイムデータフック
WebSocketを使ったリアルタイムデータ管理です。
// WebSocketフック
function useWebSocket(url, options = {}) {
const [socket, setSocket] = useState(null);
const [lastMessage, setLastMessage] = useState(null);
const [readyState, setReadyState] = useState(WebSocket.CONNECTING);
const [error, setError] = useState(null);
const reconnectTimeoutRef = useRef();
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = options.maxReconnectAttempts || 5;
const reconnectInterval = options.reconnectInterval || 3000;
const connect = useCallback(() => {
try {
const ws = new WebSocket(url);
ws.onopen = () => {
setReadyState(WebSocket.OPEN);
setError(null);
reconnectAttemptsRef.current = 0;
console.log('WebSocket connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setLastMessage(message);
options.onMessage?.(message);
};
ws.onclose = (event) => {
setReadyState(WebSocket.CLOSED);
setSocket(null);
if (!event.wasClean && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
console.log(`Reconnecting... (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
connect();
}, reconnectInterval);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setError(error);
};
setSocket(ws);
} catch (err) {
setError(err);
}
}, [url, options, maxReconnectAttempts, reconnectInterval]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (socket) {
socket.close();
}
}, [socket]);
const sendMessage = useCallback((message) => {
if (socket && readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
return true;
}
return false;
}, [socket, readyState]);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [connect, disconnect]);
return {
socket,
lastMessage,
readyState,
error,
sendMessage,
disconnect,
reconnect: connect,
// 便利なプロパティ
isConnecting: readyState === WebSocket.CONNECTING,
isOpen: readyState === WebSocket.OPEN,
isClosing: readyState === WebSocket.CLOSING,
isClosed: readyState === WebSocket.CLOSED,
};
}
このuseWebSocket
フックは、WebSocket接続を管理します。
主な機能:
- 自動接続:コンポーネントマウント時の自動接続
- 自動再接続:接続が切れた時の自動再接続
- メッセージ送信:JSONメッセージの送信
- 状態管理:接続状態の管理
// リアルタイムチャットフック
function useChat(roomId) {
const [messages, setMessages] = useState([]);
const [users, setUsers] = useState([]);
const [typing, setTyping] = useState([]);
const { sendMessage, lastMessage, isOpen } = useWebSocket(
`ws://localhost:8080/chat/${roomId}`,
{
onMessage: (message) => {
switch (message.type) {
case 'message':
setMessages(prev => [...prev, message.data]);
break;
case 'user_joined':
setUsers(prev => [...prev, message.data]);
break;
case 'user_left':
setUsers(prev => prev.filter(user => user.id !== message.data.id));
break;
case 'typing_start':
setTyping(prev => [...prev, message.data.userId]);
break;
case 'typing_stop':
setTyping(prev => prev.filter(id => id !== message.data.userId));
break;
}
}
}
);
const sendChatMessage = useCallback((text) => {
if (isOpen) {
sendMessage({
type: 'message',
data: { text, timestamp: Date.now() }
});
}
}, [sendMessage, isOpen]);
const startTyping = useCallback(() => {
if (isOpen) {
sendMessage({ type: 'typing_start' });
}
}, [sendMessage, isOpen]);
const stopTyping = useCallback(() => {
if (isOpen) {
sendMessage({ type: 'typing_stop' });
}
}, [sendMessage, isOpen]);
return {
messages,
users,
typing,
sendMessage: sendChatMessage,
startTyping,
stopTyping,
isConnected: isOpen,
};
}
このuseChat
フックは、チャット機能を提供します。
主な機能:
- メッセージ管理:チャットメッセージの管理
- ユーザー管理:チャットルームのユーザー管理
- タイピング表示:誰がタイピング中かを表示
- リアルタイム更新:すべてリアルタイムで更新
// 使用例
function ChatRoom({ roomId }) {
const {
messages,
users,
typing,
sendMessage,
startTyping,
stopTyping,
isConnected
} = useChat(roomId);
const [inputValue, setInputValue] = useState('');
const typingTimeoutRef = useRef();
const handleInputChange = (e) => {
setInputValue(e.target.value);
// タイピング開始
startTyping();
// タイピング停止のタイマー
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
stopTyping();
}, 1000);
};
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
sendMessage(inputValue);
setInputValue('');
stopTyping();
}
};
return (
<div className="chat-room">
<div className="chat-header">
<h2>Room: {roomId}</h2>
<div className="connection-status">
{isConnected ? '🟢 Connected' : '🔴 Disconnected'}
</div>
</div>
<div className="users-list">
<h3>Users ({users.length})</h3>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
<div className="messages">
{messages.map((message, index) => (
<div key={index} className="message">
<strong>{message.sender}:</strong> {message.text}
<span className="timestamp">
{new Date(message.timestamp).toLocaleTimeString()}
</span>
</div>
))}
{typing.length > 0 && (
<div className="typing-indicator">
{typing.join(', ')} typing...
</div>
)}
</div>
<form onSubmit={handleSubmit} className="message-form">
<input
type="text"
value={inputValue}
onChange={handleInputChange}
placeholder="Type a message..."
disabled={!isConnected}
/>
<button type="submit" disabled={!isConnected || !inputValue.trim()}>
Send
</button>
</form>
</div>
);
}
このチャットルームでは、以下の機能が実現されています:
- リアルタイムメッセージ送受信
- ユーザーのオンライン状態表示
- タイピング中の表示
- 接続状態の表示
とても高機能なチャットアプリが作れますね。
次は、カスタムフックのテストとデバッグ方法を見てみましょう。
テストとデバッグ
カスタムフックのテストとデバッグ方法を紹介します。
カスタムフックのテスト
React Testing Libraryを使ったテスト方法です。
// テスト対象のカスタムフック
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
このuseCounter
フックのテストを書いてみましょう。
// テストコード
import { renderHook, act } from '@testing-library/react';
describe('useCounter', () => {
test('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should decrement count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(3));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(5);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(3);
});
test('should update when initial value changes', () => {
let initialValue = 0;
const { result, rerender } = renderHook(() => useCounter(initialValue));
expect(result.current.count).toBe(0);
initialValue = 10;
rerender();
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
テストのポイント:
renderHook
でカスタムフックを実行act
で状態更新をラップresult.current
で現在の値にアクセスrerender
で再レンダリングをシミュレート
カスタムフックのテストは、コンポーネントのテストよりもシンプルですね。
// 非同期フックのテスト
describe('useAsync', () => {
const mockAsyncFunction = jest.fn();
beforeEach(() => {
mockAsyncFunction.mockClear();
});
test('should handle successful async operation', async () => {
const mockData = { id: 1, name: 'Test' };
mockAsyncFunction.mockResolvedValue(mockData);
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(mockAsyncFunction)
);
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(mockAsyncFunction).toHaveBeenCalledTimes(1);
});
test('should handle async operation error', async () => {
const mockError = new Error('Test error');
mockAsyncFunction.mockRejectedValue(mockError);
const { result, waitForNextUpdate } = renderHook(() =>
useAsync(mockAsyncFunction)
);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(mockError);
});
test('should re-execute when dependencies change', async () => {
let dependency = 'initial';
mockAsyncFunction.mockResolvedValue('result');
const { rerender, waitForNextUpdate } = renderHook(() =>
useAsync(mockAsyncFunction, [dependency])
);
await waitForNextUpdate();
expect(mockAsyncFunction).toHaveBeenCalledTimes(1);
dependency = 'changed';
rerender();
await waitForNextUpdate();
expect(mockAsyncFunction).toHaveBeenCalledTimes(2);
});
});
非同期テストのポイント:
waitForNextUpdate
で非同期処理の完了を待機mockResolvedValue
で成功のシミュレートmockRejectedValue
でエラーのシミュレート- 依存配列の変更による再実行のテスト
非同期フックのテストも、このパターンを覚えれば簡単にできますね。
モックとスタブ
外部依存をモックする方法です。
// fetchをモックしたAPIフックのテスト
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
fetch.mockClear();
});
test('should fetch data successfully', async () => {
const mockData = { users: [{ id: 1, name: 'John' }] };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result, waitForNextUpdate } = renderHook(() =>
useApi('/api/users')
);
expect(result.current.loading).toBe(false);
act(() => {
result.current.get();
});
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
expect(fetch).toHaveBeenCalledWith('/api/users', {});
});
test('should handle fetch error', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 500,
});
const { result, waitForNextUpdate } = renderHook(() =>
useApi('/api/users')
);
act(() => {
result.current.get();
});
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toBe(null);
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error.message).toBe('HTTP error! status: 500');
});
});
APIテストのポイント:
global.fetch
をモック関数に置き換えmockResolvedValueOnce
でレスポンスのシミュレート- HTTPエラーのテスト
- 正常なレスポンスとエラーレスポンスの両方をテスト
// localStorageをモックしたテスト
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
global.localStorage = localStorageMock;
describe('useLocalStorage', () => {
beforeEach(() => {
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
});
test('should return initial value when localStorage is empty', () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() =>
useLocalStorage('test-key', 'default-value')
);
expect(result.current[0]).toBe('default-value');
expect(localStorageMock.getItem).toHaveBeenCalledWith('test-key');
});
test('should return stored value from localStorage', () => {
localStorageMock.getItem.mockReturnValue('"stored-value"');
const { result } = renderHook(() =>
useLocalStorage('test-key', 'default-value')
);
expect(result.current[0]).toBe('stored-value');
});
test('should update localStorage when value changes', () => {
localStorageMock.getItem.mockReturnValue(null);
const { result } = renderHook(() =>
useLocalStorage('test-key', 'default-value')
);
act(() => {
result.current[1]('new-value');
});
expect(result.current[0]).toBe('new-value');
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'test-key',
'"new-value"'
);
});
});
ローカルストレージテストのポイント:
localStorage
をモックオブジェクトに置き換えgetItem
、setItem
の動作をシミュレート- 初期値と保存値の両方をテスト
- JSON文字列の変換も確認
デバッグ用フック
カスタムフックのデバッグに役立つツールです。
// デバッグ用フック
function useDebugValue(value, formatter) {
useDebugValue(value, formatter);
}
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef();
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props });
const changedProps = {};
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changedProps[key] = {
from: previousProps.current[key],
to: props[key]
};
}
});
if (Object.keys(changedProps).length) {
console.log('[Why-Did-You-Update]', name, changedProps);
}
}
previousProps.current = props;
});
}
このuseWhyDidYouUpdate
フックは、再レンダリングの原因を調べます。
どのpropsが変更されたかをコンソールに出力してくれるので、とても便利です。
// 使用例
function useCounterWithDebug(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// React DevToolsでの表示用
useDebugValue(count, count => `Count: ${count}`);
// 再レンダリングの原因を追跡
useWhyDidYouUpdate('useCounter', { initialValue });
const increment = useCallback(() => {
console.log('Counter incremented');
setCount(prev => prev + 1);
}, []);
const decrement = useCallback(() => {
console.log('Counter decremented');
setCount(prev => prev - 1);
}, []);
const reset = useCallback(() => {
console.log('Counter reset');
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
デバッグフックのポイント:
useDebugValue
でReact DevToolsに情報を表示useWhyDidYouUpdate
で再レンダリングの原因を追跡- コンソールログで動作を確認
これらのツールを使うことで、カスタムフックの動作をより詳しく理解できますね。
次は、カスタムフックの活用についてまとめてみましょう。
まとめ
カスタムフックを活用することで、Reactアプリケーションが劇的に改善されます。
主要なメリット
カスタムフック導入によって得られる主なメリットをまとめます。
ロジックの再利用 同じ機能を複数のコンポーネントで使用できます。 APIコールやフォーム管理など、よく使う機能をまとめられます。
関心の分離 UIロジックとビジネスロジックを分離できます。 コンポーネントは表示に集中し、データ管理はフックが担当します。
テストの容易さ ロジックを独立してテストできます。 バグの発見が早くなり、コードの品質が向上します。
保守性の向上 変更箇所を一箇所に集約できます。 機能の修正や拡張が簡単になります。
可読性の向上 コンポーネントがシンプルになります。 コードの理解が格段に楽になります。
作成時のベストプラクティス
効果的なカスタムフックを作成するためのポイントです。
// ✅ 良いカスタムフック
function useUserData(userId) {
// 1. 明確な命名(use + 機能名)
// 2. 適切な依存配列の管理
// 3. エラーハンドリング
// 4. クリーンアップ処理
// 5. 型定義(TypeScript使用時)
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal
});
const userData = await response.json();
setUser(userData);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
};
if (userId) {
fetchUser();
}
return () => controller.abort();
}, [userId]);
return { user, loading, error };
}
重要なポイント:
- 明確な命名:関数名から機能がわかるように
- エラーハンドリング:予期しないエラーを適切に処理
- クリーンアップ:メモリリークを防ぐ
- 依存配列:無限ループを避ける
- 型定義:TypeScriptを使う場合は型を定義
活用パターンの使い分け
状況に応じた適切なパターンの選択指針です。
// 状態管理パターン
// 単純な状態の管理と操作
const useToggle = (initial) => { /* ... */ };
const useCounter = (initial) => { /* ... */ };
const useLocalStorage = (key, initial) => { /* ... */ };
// データフェッチングパターン
// API呼び出しとその結果の管理
const useApi = (url) => { /* ... */ };
const usePaginatedData = (endpoint) => { /* ... */ };
const useRealTimeData = (socketUrl) => { /* ... */ };
// フォーム管理パターン
// フォームの状態とバリデーション
const useForm = (initialValues) => { /* ... */ };
const useFieldValidation = (rules) => { /* ... */ };
// 副作用パターン
// DOM操作やイベント監視
const useEventListener = (event, handler) => { /* ... */ };
const useIntersectionObserver = (options) => { /* ... */ };
const useDebounce = (value, delay) => { /* ... */ };
パターンの選び方:
- シンプルな状態管理:基本的な状態管理パターン
- API呼び出し:データフェッチングパターン
- フォーム:フォーム管理パターン
- DOM操作:副作用パターン
今後の学習
カスタムフックをマスターするための学習ステップです。
// 1. 基本パターンの習得
// - useState、useEffectの組み合わせ
// - 依存配列の理解
// - クリーンアップの実装
// 2. 実践的なフックの作成
// - API呼び出し
// - フォーム管理
// - ローカルストレージ連携
// 3. 高度なテクニック
// - パフォーマンス最適化
// - エラーハンドリング
// - TypeScript対応
// 4. ライブラリの活用
// - React Query / SWR
// - Zustand / Recoil
// - React Hook Form
学習のステップ:
- 基本を固める:useStateとuseEffectの理解
- 実践で試す:簡単なフックから始める
- 応用する:複雑なフックにチャレンジ
- ライブラリを活用:既存の優秀なライブラリを学ぶ
最後に
カスタムフックは、React開発において非常に強力なツールです。 適切に活用することで、コードの品質と開発効率を大幅に向上させることができます。
まずは簡単なカスタムフックから始めてみましょう。
useToggle
でオン・オフの切り替えuseCounter
でカウンター機能useLocalStorage
でデータの保存
これらを作って動かしてみるだけでも、カスタムフックの便利さを実感できるはずです。
慣れてきたら、API呼び出しやフォーム管理などの実践的なフックにチャレンジしてください。 継続的に学習と実践を重ねることで、より効率的なReact開発が可能になります。
ぜひ、今日から始めてみてくださいね!