Reactのカスタムフック設計パターン集
概要
Reactのカスタムフックは「状態を持ったロジックの再利用」を実現する仕組みです。しかし書き方の指針がないまま切り出すと、「ただ関数に分けただけ」で終わり、再利用性も可読性も上がらないことがあります。
本記事では、入門者が最初に押さえるべきカスタムフックの設計パターンを、TypeScriptの実装例とともに5つ紹介します。対象は「useStateとuseEffectは書けるが、自作フックの設計に自信がない」という方です。
カスタムフックの基本
カスタムフックは、名前がuseで始まり、内部でReactのフックを呼ぶただの関数です。
import { useState, useCallback } from "react";
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, reset };
}
useで始めることでReactのLintルールが使い方を検証できます。そして、コンポーネントから「状態を持つロジック」を切り離し、名前を付けるのが目的です。
パターン1: 状態カプセル化フック
関連する状態と操作をひとまとめにします。
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const;
}
戻り値は、値が少なく順序に意味があるならタプル、多いなら名前付きオブジェクトにすると読みやすくなります。
パターン2: データ取得フック
非同期処理を包み、data / loading / errorの3状態を返します。
type FetchState<T> = { data: T | null; loading: boolean; error: Error | null };
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let aborted = false;
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => !aborted && setState({ data, loading: false, error: null }))
.catch((e: unknown) => {
if (aborted) return;
if (e instanceof Error && e.name === "AbortError") return;
setState({
data: null,
loading: false,
error: e instanceof Error ? e : new Error("unknown"),
});
});
return () => {
aborted = true;
controller.abort();
};
}, [url]);
return state;
}
ポイントはクリーンアップです。アンマウント後のsetStateを防ぎ、url変更時に前のリクエストを中断することで競合状態を避けられます。実務ではTanStack Queryなどのライブラリに任せるのが定石ですが、内部理解のために一度は自作しておく価値があります。
パターン3: 外部システム購読フック
ブラウザAPIやイベントを購読します。
function useWindowSize() {
const [size, setSize] = useState(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
useEffect(() => {
const onResize = () =>
setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return size;
}
addEventListenerとremoveEventListenerは必ずセットにします。解除を怠るとリスナーが積み上がり、メモリリークや多重発火の原因になります。
パターン4: 値の保持フック
useRefを使ってレンダーをまたいで値を保持します。代表例が「前回の値」を覚えるフックです。
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
useRefは値を変えても再レンダーを起こさないため、「表示に使わないが覚えておきたい値」に向いています。
パターン5: ロジック合成フック
小さなフックを組み合わせて上位の関心事を作ります。
function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function useSearch(query: string) {
const debounced = useDebouncedValue(query, 400);
return useFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(debounced)}`);
}
単機能のフックを合成して「入力を間引いて検索する」を表現しています。小さく作って組み合わせる、これが設計の中心です。
設計を崩さないための注意点
- フックはコンポーネントまたは別のフックのトップレベルでのみ呼ぶ。条件分岐やループの中で呼ばない。
useEffect/useCallback/useMemoの依存配列は、内部で参照する値をすべて列挙する。eslint-plugin-react-hooksを導入して機械的に検証する。- 「2回書いたら共通化」程度の温度感にし、早すぎる抽象化を避ける。
- カスタムフックはJSXを返さない。表示はコンポーネントの責務に留める。
まとめ
カスタムフックの設計は「状態カプセル化 / データ取得 / 外部システム購読 / 値の保持 / ロジック合成」の5パターンをベースに考えると整理しやすくなります。いずれも「関連する状態と操作に名前を付け、UIから切り離す」という同じ原則の応用です。まず小さく作り、必要になったら合成する。これがカスタムフックを破綻させないコツです。
