第10回
カスタムHookでロジックを再利用する:useFetch・useLocalStorage・useDebounce
Reactのカスタムフックの作り方を解説します。useFetch・useLocalStorage・useDebounceを実装例に、フックの抽出パターン・命名規則・テスト方法まで実践的に学べます。
·13分で読める
たける
複数のページで同じデータ取得のコードを書いてしまってます。コピペしてたら5箇所になって……。
りこ
カスタムHookに切り出すタイミング。「同じロジックが2箇所以上に登場したら」が目安。
ユナ
バックエンドでいうと共通サービス層やリポジトリパターンと同じ発想。データアクセスのロジックをコンポーネント(コントローラ)から分離して、専用の場所にまとめる。
カスタムHookとは
カスタムHookは、複数のHookを組み合わせたロジックを関数として切り出したものです。命名は use で始める必要があります。
// カスタムHookの基本形
function useMyHook(param: string) {
const [state, setState] = useState(null);
useEffect(() => {
// ロジック
}, [param]);
return state;
}同じロジックが複数のコンポーネントに登場したら、カスタムHookへの抽出を検討するサインです。
useFetch:データフェッチを抽象化する
type FetchState<T> = {
data: T | null;
isLoading: boolean;
error: string | null;
};
function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
setIsLoading(true);
setError(null);
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json: T = await response.json();
setData(json);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : '取得失敗');
} finally {
setIsLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url]);
return { data, isLoading, error };
}
// 使い方
type User = { id: number; name: string; email: string };
function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useFetch<User>(`/api/users/${userId}`);
if (isLoading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}useLocalStorage:localStorageと同期するstate
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.error('localStorage の書き込み失敗:', err);
}
},
[key, storedValue]
);
return [storedValue, setValue] as const;
}
// 使い方
function Settings() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage<number>('fontSize', 16);
return (
<div>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? 'ダークモードへ' : 'ライトモードへ'}
</button>
<input
type="range" min={12} max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}useDebounce:入力の間引き処理
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使い方:検索入力を300ms後まで待ってからAPIを叩く
function LiveSearch() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const { data: results } = useFetch<string[]>(
debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="検索..."
/>
<ul>
{results?.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
);
}useMediaQuery:レスポンシブ対応
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 使い方
function ResponsiveNav() {
const isMobile = useMediaQuery('(max-width: 768px)');
return isMobile ? <MobileNav /> : <DesktopNav />;
}カスタムHookのテスト
カスタムHookはReact Testing Libraryの renderHook でテストできます。
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './use-debounce';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('指定時間後に値が更新される', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'hello' } }
);
expect(result.current).toBe('hello');
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // まだ変わっていない
act(() => vi.advanceTimersByTime(300));
expect(result.current).toBe('world'); // 300ms後に更新
});
});まとめ
- カスタムHookは
useで始まる関数。複数コンポーネントで同じロジックを使うときに抽出する useFetchでデータ取得ロジックを集約し、AbortControllerでキャンセルも管理するuseLocalStorageでlocalStorageと連動したstateを実現するuseDebounceで検索入力など頻繁に変化する値を間引くrenderHookでカスタムHookを単体テストできる
次の第11回では、React 18から追加されたコンカレント機能 useTransition を学びます。