カスタムフックは、コードの再利用においてReactの最も強力な機能の1つです。コンポーネントのロジックを再利用可能な関数に抽出し、コードをよりクリーンで保守しやすくします。
カスタムフックとは?
カスタムフックは単純に以下の条件を満たすJavaScript関数です:
useという単語で始まる- 他のフックを呼び出すことができる
// これはカスタムフック
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return width;
}
// コンポーネントでの使用
function Component() {
const width = useWindowWidth();
return <div>ウィンドウ幅: {width}</div>;
}
flowchart LR
A[カスタムフック] --> B[コンポーネントA]
A --> C[コンポーネントB]
A --> D[コンポーネントC]
style A fill:#3b82f6,color:#fff
各コンポーネントはフックの状態の独立したコピーを取得します。
なぜカスタムフックを作るのか?
1. ロジックを共有、状態は共有しない
カスタムフックはロジックを共有しますが、フックを使用する各コンポーネントは独自の状態を取得します:
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
function ComponentA() {
const { count, increment } = useCounter(0);
// countはComponentBから独立
return <button onClick={increment}>{count}</button>;
}
function ComponentB() {
const { count, increment } = useCounter(100);
// countはComponentAから独立
return <button onClick={increment}>{count}</button>;
}
2. 関心の分離
複雑なロジックをコンポーネントから抽出:
// 前: ロジックがUIと混在
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error error={error} />;
return <Profile user={user} />;
}
// 後: ロジックをカスタムフックに抽出
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}
function UserProfile({ userId }) {
const { user, loading, error } = useUser(userId);
if (loading) return <Spinner />;
if (error) return <Error error={error} />;
return <Profile user={user} />;
}
一般的なカスタムフックパターン
1. useLocalStorage
状態をlocalStorageに永続化:
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);
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function
? value(storedValue)
: value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// 使用例
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
現在: {theme}
</button>
);
}
2. useFetch
汎用データフェッチングフック:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: abortController.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => setLoading(false));
return () => abortController.abort();
}, [url]);
return { data, loading, error };
}
// 使用例
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
3. useDebounce
値をデバウンス:
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用例
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// ユーザーが入力を停止してから300ms後にのみ発火
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="検索..."
/>
);
}
4. useToggle
シンプルなブール状態管理:
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// 使用例
function Modal() {
const { value: isOpen, toggle, setFalse: close } = useToggle();
return (
<>
<button onClick={toggle}>モーダルを開く</button>
{isOpen && (
<div className="modal">
<button onClick={close}>閉じる</button>
</div>
)}
</>
);
}
5. usePrevious
前の値を追跡:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用例
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>現在: {count}, 前: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>増加</button>
</div>
);
}
6. useMediaQuery
CSSメディアクエリに応答:
function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 使用例
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}
ベストプラクティス
1. 常に「use」で始める
useプレフィックスは、ReactがフックとしてKen認識し、フックのルールを適用するために必要です:
// 良い
function useAuth() { }
function useFormValidation() { }
// 悪い - Reactはフックのルールを強制しない
function getAuth() { }
function formValidation() { }
2. 必要なものだけを返す
消費者が必要とするものだけを返す:
// 良い - 必要な値だけを返す
function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
// 避ける - 内部状態を露出しすぎ
function useCounter() {
const [count, setCount] = useState(0);
return { count, setCount }; // setCountは低レベルすぎる
}
3. 一貫した戻り値の形式を使用
形式を選んで一貫させる:
// 配列形式 - 位置による分割代入に適している
function useToggle(initial) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(v => !v);
return [value, toggle]; // useStateのように
}
const [isOpen, toggleOpen] = useToggle(false);
// オブジェクト形式 - 名前付きの値に適している
function useUser(id) {
// ...
return { user, loading, error }; // 名前付きプロパティ
}
const { user, loading } = useUser(123);
4. クリーンアップを処理
サブスクリプションとタイマーを常にクリーンアップ:
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (e) => savedHandler.current(e);
element.addEventListener(eventName, eventListener);
return () => element.removeEventListener(eventName, eventListener);
}, [eventName, element]);
}
5. フックをドキュメント化
/**
* 指定された遅延で値をデバウンスします。
* @param {T} value - デバウンスする値
* @param {number} delay - ミリ秒単位の遅延
* @returns {T} - デバウンスされた値
* @example
* const debouncedSearch = useDebounce(searchTerm, 300);
*/
function useDebounce(value, delay) {
// ...
}
カスタムフックのテスト
@testing-library/react-hooksまたは@testing-library/reactのrenderHookを使用:
import { renderHook, act } from '@testing-library/react';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
まとめ
- カスタムフックはコンポーネントから再利用可能なロジックを抽出する
- 各コンポーネントはフックから独自の状態を取得する
useで始まるフック名を付ける- 消費者が必要とするものだけを返す
- 常に副作用をクリーンアップする
- 一般的なパターン:useLocalStorage、useFetch、useDebounce、useToggle
renderHookを使用してフックをテストする
カスタムフックは、クリーンで再利用可能なReactコードを書くための鍵です。状態を共有せずにロジックを共有でき、コンポーネントをよりシンプルでUIレンダリングに集中させます。
参考文献
- React Documentation: Reusing Logic with Custom Hooks
- Barklund, Morten. React in Depth. Manning Publications, 2024.
- usehooks.com - Reactフックのコレクション