Reactのカスタムフック:ロジックを正しく再利用する方法
1. 概要
Reactアプリケーションの複数のコンポーネントが同じロジックを必要とする場合(API呼び出し、localStorageの読み取り、input debounceなど)、stateとuseEffectをコンポーネント間でコピー&ペーストし続ける状況に陥りやすくなります。この記事では、そのロジックをカスタムフックにパッケージ化する方法を説明します。カスタムフックはuseで始まる関数で、既存のhook(useState、useEffect、useRefなど)を活用して再利用可能にします。読了後、useFetch、useLocalStorage、useDebounceを書き、renderHookを使ってテストできるようになります。
2. 問題:コンポーネント間で繰り返されるロジック
APIを呼び出してloading/errorを管理する必要がある2つのコンポーネントがあるとしましょう:
function CustomerList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/customers')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...描画
}
function OrderList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/orders')
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ...描画
}
CustomerListとOrderListは、URLを除いてほぼ同じロジックを含んでいます。将来的にretryやcancel requestを追加したい場合、あらゆる場所で修正しなければなりません。これがカスタムフックが必要な兆候です。
3. カスタムフックとは
カスタムフックは、通常のJavaScript関数で、名前がuseで始まり、内部で他のReact hookを呼び出します。JSXを返さず、stateと関数を返します。これはコンポーネントに紐付いた「ミニストア」のようなものです。
注意: コンポーネントがカスタムフックを呼び出すたびに、Reactは独立したstateインスタンスを作成します。2つのコンポーネントが
useFetchを使用しても、stateは共有されません。ロジックのみを共有します。
4. 最初のカスタムフック:useFetchを書く
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
解説:
-
cancelledはrace conditionを避けるために使用します。古いリクエストが完了する前にurlが変わった場合、古い結果がstateに設定されません。 -
Cleanup関数(
return () => { cancelled = true })はコンポーネントのunmount時またはurl変更時に実行され、useEffectのライフサイクルに沿っています。 -
Hookは呼び出し元が簡単にdestructureできるように、
{ data, loading, error }オブジェクトを返します。
コンポーネントはこのように簡潔になります:
function CustomerList() {
const { data, loading, error } = useFetch('/api/customers');
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}
function OrderList() {
const { data, loading, error } = useFetch('/api/orders');
// レンダリングロジックは同様ですが、fetchロジックは重複していません
}
5. 2番目の例:useLocalStorage
stateをlocalStorageと同期するロジックも、よくコピー&ペーストされます:
// useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
window.localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
解説:
-
useState(() => ...)は遅延初期化子を使用します。関数はマウント時に1回だけ実行され、毎回のレンダリングでlocalStorageを読み込みません。 -
Hookは
useStateと同じtupleの[value, setValue]を返すため、ユーザーにはすぐに馴染みやすくなります。
コンポーネントで使用:
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
現在のテーマ: {theme}
</button>
);
}
5.1. stateが以前の値に依存する場合
以前の値に基づいて更新する必要がある場合(カウンターなど)、stale closureを避けるために関数型アップデートを使用してください:
setValue((prev) => prev + 1); // setValue(value + 1)より安全です
6. 3番目の例:useDebounce
検索inputをdebounceするのは非常に一般的なタスクです:
// useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
解説:
-
valueが変わるたびに新しいsetTimeoutが作成され、古いsetTimeoutはcleanupでclearTimeoutされます。 -
ユーザーが
delayMs以内に入力を停止してからのみ、debouncedが更新されます。
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
7. 命名規則とRules of Hooks
カスタムフックは、組み込みhookと同じようにRules of Hooksに従う必要があります:
- 名前は
useで始まる必要があります。ESLintプラグインeslint-plugin-react-hooksはこのプレフィックスに基づいてルールをチェックします。useがなければ、これはhookとして認識されません。 - hookは関数のトップレベルでのみ呼び出します。
if、for、またはコールバック内では呼び出さないでください。 - hookはfunction componentまたは別のカスタムhook内でのみ呼び出します。通常の関数では呼び出さないでください。
npm install eslint-plugin-react-hooks --save-dev
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
exhaustive-depsを有効にすると、カスタムフック内のuseEffectのdependencyを忘れなくなります。これは「stale data」バグの最も一般的な原因です。
8. カスタムフックのテスト
カスタムフックはJSXを返さないため、コンポーネントのようにレンダリングできません。@testing-library/reactライブラリは、テスト環境でhookを呼び出すためにrenderHookを提供します。
<!-- Reactプロジェクトに適用されません。依存関係はnpmでインストールしてください。以下を参照してください。 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
it('delayが終わった後にのみ値を更新', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
expect(result.current).toBe('a');
rerender({ value: 'ab', delay: 300 });
expect(result.current).toBe('a'); // まだすぐには変わっていません
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('ab'); // debounce完了
vi.useRealTimers();
});
});
解説:
-
renderHookはhookを呼び出す関数を受け取り、result.currentを返します。これは現在のレンダリング時にhookが返す値です。 -
rerenderは新しいpropsを使用してコンポーネントを再レンダリングをシミュレートします(ユーザーが入力するときにvalueが変わるのと同じです)。 -
vi.useFakeTimers()+act(() => vi.advanceTimersByTime(...))は実際に待つ必要なく時間を制御でき、テストが高速で安定します。
✓ useDebounce > delayが終わった後にのみ値を更新
9. よくあるエラーと、いつ使用すべきでない場合
よくあるエラー:
-
useEffect内のcleanupを忘れる:useFetchで、cancelledフラグがないと、古いリクエストが後から返された場合、新しいデータを上書きする可能性があり、間違ったデータが表示されます。 -
dependencyの欠落: hookの
useEffectが外部変数を使用しているのにdependency arrayで宣言していない場合、hookは古い値(stale closure)を使用します。 -
毎回のレンダリング時に不要な新しいオブジェクトを返す: hookが毎回
{ data, loading, error }をオブジェクトリテラルで返す場合、下流のReact.memoを使用するコンポーネントは、参照が変わるため余分に再レンダリングされる可能性があります。実際に問題が測定できれば、useMemoを使用できます。
カスタムフックを書くべきではない場合:
-
ロジックが1つの場所だけで使用され、再利用の可能性がない場合。分離するとコードが追跡しにくくなるだけです(別のファイルにジャンプして理解する必要があります)。
-
ロジックが完全に純粋な計算(純粋関数)で、stateやeffectと無関係な場合。通常のJS関数を書くべきで、hookは必要ありません。
-
複数のコンポーネント間でツリーの異なる場所のstate(ロジックだけでなく)を共有する必要がある場合。カスタムフックはこれができません。ContextまたはState管理ライブラリ(Zustand、Reduxなど)が必要です。
注意: カスタムフックはロジックの重複の問題を解決します。コンポーネント間のstate共有の問題を解決しません。この2つの問題を混同しないでください。
10. まとめ
カスタムフックは、stateを持つロジックをコンポーネントから分離するツールで、コードを簡潔にし、テストしやすく、再利用しやすくしてくれます。覚えるべきことは:useで名前を始める、Rules of Hooksに従う、renderではなくrenderHookでテストすることです。上記のuseFetch、useLocalStorage、useDebounceを使えば、useMediaQueryやuseFormなどの他のhookを「一度書いて、どこでも使う」という精神で書くフレームワークを手に入れることができます。