Reactのカスタムフックを書くようになると、必ず壁にぶつかるのが「型注釈」の問題です。useStateの初期値がnullのときどう型を付けるか、useReducerのActionをどう判別共用体で表現するか、戻り値はタプルとオブジェクトどちらにすべきか――こうした「カスタムフック設計における型の付け方」を一切ごまかさず徹底解説するのが本記事の目的です。
本記事は TypeScript 5.x / React 19 準拠で、コピペで動く50以上のコードサンプルを通して、useStateからuseImperativeHandleまでの全ビルトインフック・実用カスタムフック15種・テスト戦略・ESLintルール・アンチパターンまで、「カスタムフック×型注釈」に完全特化した知識を体系化します。「フックの基本的な使い方」は useState完全ガイド・useEffect完全ガイド・useReducer完全ガイド・カスタムフック作り方完全ガイド でカバー済みのため、本記事では一切重複させず、「型をどう書くか」のみを掘り下げます。
- 1. なぜカスタムフックの型注釈は難しいのか
- 2. useStateの型注釈パターン
- 3. useEffectとuseLayoutEffectの型
- 4. useReducerの型注釈
- 5. useContextの型注釈
- 6. useRefの型注釈
- 7. useMemoとuseCallbackの型推論
- 8. カスタムフックの戻り値:タプル vs オブジェクト
- 9. 実用カスタムフック15選(型注釈付き)
- 9.1 useToggle
- 9.2 useDebounce
- 9.3 useLocalStorage
- 9.4 useFetch
- 9.5 useMutation
- 9.6 useDisclosure(Modal用)
- 9.7 useClickOutside
- 9.8 useMediaQuery
- 9.9 useEventListener(EventMapジェネリクス)
- 9.10 useDeferredValueの型
- 9.11 useTransitionの型
- 9.12 useImperativeHandle と forwardRef(React 19以前互換)
- 9.13 React 19新方式:forwardRefなしでrefを受け取る
- 9.14 useInterval(useRef + useEffect)
- 9.15 useIsMounted
- 10. 型安全なReducer Builder
- 11. カスタムフックのテスト戦略
- 12. ESLintルールとカスタムフックの型
- 13. アンチパターン10選
- 14. まとめ:カスタムフック型設計の原則
1. なぜカスタムフックの型注釈は難しいのか
JavaScriptで書かれたカスタムフックをTypeScriptに移植するとき、多くの開発者が以下の3点でつまずきます。
- 初期値とジェネリクスの関係:
useState(null)はTypeScript側でnull型に推論されてしまい、後から文字列を入れられない - 戻り値の形状:タプルにすべきかオブジェクトにすべきか、ジェネリクスでの推論可否が変わる
- 依存配列の型:
useCallbackやuseMemoで渡す関数の型がanyになりがち
本記事ではこれらを根本から解決し、「型注釈の付け方一つで保守性が劇的に変わる」ことを実感していただきます。
1.1 本記事で扱う対象バージョン
{
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}
1.2 strict設定を前提にする
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict: trueでないと、本記事で紹介する型注釈の多くは効力を失います。詳しくは tsconfig.json完全ガイド を参照してください。
2. useStateの型注釈パターン
2.1 型推論に任せるパターン
初期値からTypeScriptが型を推論できる場合は、明示的な型注釈は不要です。
import { useState } from "react";
// number型と推論される
const [count, setCount] = useState(0);
setCount(1); // OK
// setCount("1"); // Error: Argument of type 'string' is not assignable
// string型と推論される
const [name, setName] = useState("Alice");
// boolean型と推論される
const [isOpen, setIsOpen] = useState(false);
2.2 初期値がnullの場合は型注釈が必須
もっとも事故が起きやすいのがこのパターンです。何も書かないとnull型に固定されてしまいます。
import { useState } from "react";
// NG: null型に固定されてしまい、Userを入れられない
const [user1, setUser1] = useState(null);
// setUser1({ id: 1 }); // Error
// OK: ジェネリクスで「User | null」を明示
type User = { id: number; name: string };
const [user2, setUser2] = useState<User | null>(null);
setUser2({ id: 1, name: "Alice" }); // OK
setUser2(null); // OK
2.3 オブジェクトリテラルは「広い型」を明示
type Form = {
name: string;
email: string;
age: number | null;
};
// 明示的にFormを指定しないとageは「number」に推論されてnullを入れられない
const [form, setForm] = useState<Form>({
name: "",
email: "",
age: null,
});
2.4 配列の初期値は要素型を明示
type Todo = { id: string; text: string; done: boolean };
// NG: never[] に推論されてしまう
// const [todos1, setTodos1] = useState([]);
// OK: ジェネリクスで要素型を明示
const [todos, setTodos] = useState<Todo[]>([]);
setTodos((prev) => [...prev, { id: "1", text: "buy milk", done: false }]);
2.5 setStateの関数引数は型推論が効く
const [count, setCount] = useState(0);
// prevはnumberとして推論される
setCount((prev) => prev + 1);
// オブジェクトでも推論される
type Cart = { items: string[]; total: number };
const [cart, setCart] = useState<Cart>({ items: [], total: 0 });
setCart((prev) => ({
...prev,
total: prev.total + 100,
}));
2.6 Dispatch型を引数として受け渡す
import { Dispatch, SetStateAction, useState } from "react";
type CounterProps = {
count: number;
setCount: Dispatch<SetStateAction<number>>;
};
function CounterButtons({ count, setCount }: CounterProps) {
return (
<>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount((p) => p - 1)}>-1</button>
</>
);
}
2.7 ユニオン型のステートマシン
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
const [state, setState] = useState<FetchState<User>>({ status: "idle" });
// 条件分岐で型が自動的に絞られる(discriminated union)
if (state.status === "success") {
console.log(state.data.name); // dataにアクセス可能
}
3. useEffectとuseLayoutEffectの型
3.1 戻り値の型はvoid | (() => void)
import { useEffect } from "react";
useEffect(() => {
const timerId = setInterval(() => console.log("tick"), 1000);
// クリーンアップ関数の戻り値は void
return () => clearInterval(timerId);
}, []);
3.2 依存配列の型は ReadonlyArray<unknown>
// 依存配列に入れる値の型はチェックされる
const [count, setCount] = useState(0);
const [name, setName] = useState("");
useEffect(() => {
console.log(count, name);
}, [count, name]); // 型はそれぞれ number, string
3.3 async関数はそのまま渡せない
// NG: useEffectの第一引数は () => void | (() => void)
// useEffect(async () => { ... }, []);
// OK: IIFE(即時実行関数)で包む
useEffect(() => {
(async () => {
const res = await fetch("/api/me");
const data = (await res.json()) as User;
console.log(data);
})();
}, []);
3.4 EffectCallback型を再利用
import { EffectCallback } from "react";
const logEffect: EffectCallback = () => {
console.log("mounted");
return () => console.log("unmounted");
};
useEffect(logEffect, []);
4. useReducerの型注釈
4.1 基本パターン:State型とAction型
import { useReducer } from "react";
type State = { count: number };
type Action = { type: "INC" } | { type: "DEC" } | { type: "RESET" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INC":
return { count: state.count + 1 };
case "DEC":
return { count: state.count - 1 };
case "RESET":
return { count: 0 };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: "INC" });
4.2 discriminated unionでペイロード型を厳密化
type TodoState = { todos: Todo[] };
type TodoAction =
| { type: "ADD"; payload: { text: string } }
| { type: "TOGGLE"; payload: { id: string } }
| { type: "REMOVE"; payload: { id: string } }
| { type: "CLEAR" };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD":
return {
todos: [
...state.todos,
{ id: crypto.randomUUID(), text: action.payload.text, done: false },
],
};
case "TOGGLE":
return {
todos: state.todos.map((t) =>
t.id === action.payload.id ? { ...t, done: !t.done } : t,
),
};
case "REMOVE":
return { todos: state.todos.filter((t) => t.id !== action.payload.id) };
case "CLEAR":
return { todos: [] };
}
}
4.3 網羅性チェック(exhaustiveness check)
function assertNever(x: never): never {
throw new Error(`Unhandled action: ${JSON.stringify(x)}`);
}
function strictReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD": /* ... */ return state;
case "TOGGLE": /* ... */ return state;
case "REMOVE": /* ... */ return state;
case "CLEAR": /* ... */ return state;
default:
// 新しいActionを追加し忘れるとここでコンパイルエラーになる
return assertNever(action);
}
}
4.4 初期化関数とlazy initialization
type CounterState = { count: number; history: number[] };
function init(initialCount: number): CounterState {
return { count: initialCount, history: [initialCount] };
}
type CounterAction = { type: "ADD"; n: number };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
return {
count: state.count + action.n,
history: [...state.history, state.count + action.n],
};
}
// 第3引数の初期化関数の引数型が、第2引数(initialArg)の型と一致する必要がある
const [state, dispatch] = useReducer(counterReducer, 10, init);
4.5 Reducer型ヘルパーで再利用
type Reducer<S, A> = (state: S, action: A) => S;
const cartReducer: Reducer<Cart, CartAction> = (state, action) => {
// 実装...
return state;
};
type Cart = { items: string[] };
type CartAction = { type: "ADD"; item: string };
5. useContextの型注釈
5.1 デフォルト値がnullの場合の安全な書き方
import { createContext, useContext, ReactNode } from "react";
type AuthValue = {
user: User | null;
login: (email: string, pw: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const value: AuthValue = {
user: null,
login: async () => {},
logout: () => {},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
5.2 Providerの外で使われたらthrowするカスタムフック
export function useAuth(): AuthValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error("useAuth must be used within <AuthProvider>");
}
return ctx;
}
// 利用側ではnull安全
function Profile() {
const { user } = useAuth(); // user: User | null
return <div>{user?.name ?? "ゲスト"}</div>;
}
5.3 デフォルト値を「未初期化センチネル」にする
const UNSET = Symbol("UNSET");
type ThemeValue = { mode: "light" | "dark"; toggle: () => void };
const ThemeContext = createContext<ThemeValue | typeof UNSET>(UNSET);
export function useTheme(): ThemeValue {
const v = useContext(ThemeContext);
if (v === UNSET) throw new Error("useTheme must be used within <ThemeProvider>");
return v;
}
6. useRefの型注釈
6.1 DOM参照は HTMLElement系の型
import { useRef, useEffect } from "react";
function FocusInput() {
// 初期値nullなら HTMLInputElement | null の型に
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
}
6.2 各DOM要素の型
const divRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const anchorRef = useRef<HTMLAnchorElement>(null);
const selectRef = useRef<HTMLSelectElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
6.3 ミュータブルな値を保持する場合
// タイマーIDの保持
const timerRef = useRef<number | null>(null);
function start() {
timerRef.current = window.setInterval(() => console.log("tick"), 1000);
}
function stop() {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
6.4 前回値を保持するカスタムフック
import { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使い方
const [count, setCount] = useState(0);
const prev = usePrevious(count); // number | undefined
7. useMemoとuseCallbackの型推論
7.1 useMemoは戻り値型で推論される
import { useMemo } from "react";
const items = [1, 2, 3];
// number型に推論される
const sum = useMemo(() => items.reduce((a, b) => a + b, 0), [items]);
// 明示的に指定する場合
const sum2 = useMemo<number>(() => items.reduce((a, b) => a + b, 0), [items]);
7.2 useCallback の型注釈
import { useCallback } from "react";
// 引数と戻り値の型が推論される
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
console.log(e.currentTarget.name);
}, []);
// 明示的にコールバック型を指定
type Handler = (id: string) => void;
const onSelect = useCallback<Handler>((id) => {
console.log(id);
}, []);
7.3 ジェネリックな比較関数をuseMemoでメモ化
function sortBy<T, K extends keyof T>(arr: T[], key: K): T[] {
return [...arr].sort((a, b) => (a[key] > b[key] ? 1 : -1));
}
const users: User[] = /* ... */ [];
const sortedUsers = useMemo(() => sortBy(users, "name"), [users]);
8. カスタムフックの戻り値:タプル vs オブジェクト
カスタムフックの設計で最も議論になるのが「戻り値の形状」です。両者の型挙動の違いを正確に押さえましょう。
8.1 タプル戻り値:as constで型を絞る
// NG: 戻り値が (number | (() => void))[] に推論されてしまう
function useCounterBad(initial: number) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
return [count, increment]; // (number | (() => void))[]
}
// OK: as const でタプル型に固定
function useCounter(initial: number) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
return [count, increment] as const;
// readonly [number, () => void]
}
const [count, inc] = useCounter(0); // count: number, inc: () => void
8.2 戻り値の型を明示する
function useCounter2(initial: number): readonly [number, () => void] {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
return [count, increment];
}
8.3 オブジェクト戻り値:プロパティ名で意味が明確
function useCounterObj(initial: number) {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount((c) => c + 1),
decrement: () => setCount((c) => c - 1),
reset: () => setCount(initial),
};
}
const { count, increment, reset } = useCounterObj(0);
8.4 タプルとオブジェクトの選び方
| 項目 | タプル | オブジェクト |
|---|---|---|
| 分割代入時の自由命名 | ○(const [a, b] = ...) |
△({count: c}と書く必要あり) |
| 戻り値が多い場合の視認性 | △ | ○ |
| useStateとの一貫性 | ○ | × |
| 追加項目への耐性(API進化) | × | ○ |
推奨:2〜3個の戻り値ならタプル、4個以上ならオブジェクト。
9. 実用カスタムフック15選(型注釈付き)
9.1 useToggle
import { useCallback, useState } from "react";
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
const setOn = useCallback(() => setValue(true), []);
const setOff = useCallback(() => setValue(false), []);
return [value, { toggle, setOn, setOff }] as const;
}
// 使い方
const [isOpen, { toggle, setOn, setOff }] = useToggle();
9.2 useDebounce
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// 使い方:string でも number でも T が推論される
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
9.3 useLocalStorage
import { Dispatch, SetStateAction, useCallback, useState } from "react";
export function useLocalStorage<T>(
key: string,
initial: T,
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw === null ? initial : (JSON.parse(raw) as T);
} catch {
return initial;
}
});
const setAndStore: Dispatch<SetStateAction<T>> = useCallback(
(next) => {
setValue((prev) => {
const resolved =
typeof next === "function"
? (next as (p: T) => T)(prev)
: next;
try {
localStorage.setItem(key, JSON.stringify(resolved));
} catch {
/* ignore */
}
return resolved;
});
},
[key],
);
return [value, setAndStore];
}
// 使い方
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
9.4 useFetch
import { useEffect, useState } from "react";
type FetchResult<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
export function useFetch<T>(url: string): FetchResult<T> {
const [state, setState] = useState<FetchResult<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetch(url)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
})
.then((data) => !cancelled && setState({ status: "success", data }))
.catch((e: Error) => !cancelled && setState({ status: "error", error: e }));
return () => {
cancelled = true;
};
}, [url]);
return state;
}
// 使い方
type User = { id: number; name: string };
const result = useFetch<User>("/api/me");
if (result.status === "success") {
console.log(result.data.name);
}
9.5 useMutation
import { useCallback, useState } from "react";
type MutationState<TData> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: TData }
| { status: "error"; error: Error };
export function useMutation<TData, TVariables>(
mutationFn: (vars: TVariables) => Promise<TData>,
) {
const [state, setState] = useState<MutationState<TData>>({ status: "idle" });
const mutate = useCallback(
async (vars: TVariables): Promise<TData> => {
setState({ status: "loading" });
try {
const data = await mutationFn(vars);
setState({ status: "success", data });
return data;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
setState({ status: "error", error: err });
throw err;
}
},
[mutationFn],
);
return { ...state, mutate };
}
// 使い方
const { mutate, status } = useMutation<User, { name: string }>(async (vars) => {
const res = await fetch("/api/users", { method: "POST", body: JSON.stringify(vars) });
return res.json();
});
9.6 useDisclosure(Modal用)
import { useCallback, useState } from "react";
type DisclosureReturn = {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
};
export function useDisclosure(initial = false): DisclosureReturn {
const [isOpen, setIsOpen] = useState(initial);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
return { isOpen, open, close, toggle };
}
9.7 useClickOutside
import { RefObject, useEffect } from "react";
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (event: MouseEvent | TouchEvent) => void,
) {
useEffect(() => {
const listener = (e: MouseEvent | TouchEvent) => {
const el = ref.current;
if (!el || el.contains(e.target as Node)) return;
handler(e);
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", listener);
};
}, [ref, handler]);
}
// 使い方
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false));
9.8 useMediaQuery
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return 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;
}
const isWide = useMediaQuery("(min-width: 768px)");
9.9 useEventListener(EventMapジェネリクス)
import { RefObject, useEffect, useRef } from "react";
// Window用
export function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
): void;
// HTMLElement用
export function useEventListener<
K extends keyof HTMLElementEventMap,
T extends HTMLElement,
>(type: K, handler: (event: HTMLElementEventMap[K]) => void, element: RefObject<T | null>): void;
// 実装
export function useEventListener(
type: string,
handler: (event: Event) => void,
element?: RefObject<HTMLElement | null>,
): void {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const target = element?.current ?? window;
const listener = (e: Event) => handlerRef.current(e);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
}, [type, element]);
}
// 使い方:eventがKeyboardEventに自動推論される
useEventListener("keydown", (e) => {
if (e.key === "Escape") console.log("ESC");
});
9.10 useDeferredValueの型
import { useDeferredValue, useState, useMemo } from "react";
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue<string>(query); // 型は string で推論される
const filtered = useMemo(() => heavyFilter(deferredQuery), [deferredQuery]);
function heavyFilter(_q: string): string[] {
return [];
}
9.11 useTransitionの型
import { useTransition, useState } from "react";
function Search() {
const [isPending, startTransition] = useTransition();
const [list, setList] = useState<string[]>([]);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
startTransition(() => {
setList(heavyFilter(v));
});
};
return (
<>
<input onChange={onChange} />
{isPending && <span>Loading...</span>}
<ul>{list.map((x) => <li key={x}>{x}</li>)}</ul>
</>
);
}
function heavyFilter(_q: string): string[] {
return [];
}
9.12 useImperativeHandle と forwardRef(React 19以前互換)
import { forwardRef, useImperativeHandle, useRef } from "react";
export type FocusableInputHandle = {
focus: () => void;
clear: () => void;
};
type Props = { placeholder?: string };
export const FocusableInput = forwardRef<FocusableInputHandle, Props>(
function FocusableInput(props, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = "";
},
}));
return <input ref={inputRef} placeholder={props.placeholder} />;
},
);
// 使う側
const ref = useRef<FocusableInputHandle>(null);
ref.current?.focus();
9.13 React 19新方式:forwardRefなしでrefを受け取る
// React 19 では ref を通常のpropとして受け取れる
type InputProps = {
ref?: React.Ref<HTMLInputElement>;
placeholder?: string;
};
function MyInput({ ref, placeholder }: InputProps) {
return <input ref={ref} placeholder={placeholder} />;
}
9.14 useInterval(useRef + useEffect)
import { useEffect, useRef } from "react";
export function useInterval(callback: () => void, delay: number | null): void {
const savedCallback = useRef<() => void>(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
9.15 useIsMounted
import { useEffect, useRef, useCallback } from "react";
export function useIsMounted(): () => boolean {
const mountedRef = useRef<boolean>(false);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return useCallback(() => mountedRef.current, []);
}
10. 型安全なReducer Builder
10.1 Action Creator関数で型を一元化
// Action Creator
const createAction = <T extends string, P>(type: T) => (payload: P) => ({ type, payload });
const addTodo = createAction<"ADD", { text: string }>("ADD");
const removeTodo = createAction<"REMOVE", { id: string }>("REMOVE");
// 全Actionをユニオン化
type Actions = ReturnType<typeof addTodo> | ReturnType<typeof removeTodo>;
10.2 Map形式のReducer Builder
type Handlers<S, A extends { type: string }> = {
[K in A["type"]]: (state: S, action: Extract<A, { type: K }>) => S;
};
function createReducer<S, A extends { type: string }>(
handlers: Handlers<S, A>,
): (state: S, action: A) => S {
return (state, action) => {
const handler = handlers[action.type as A["type"]];
return handler ? handler(state, action as Extract<A, { type: typeof action.type }>) : state;
};
}
const reducer2 = createReducer<TodoState, TodoAction>({
ADD: (s, a) => ({ todos: [...s.todos, { id: "x", text: a.payload.text, done: false }] }),
TOGGLE: (s, a) => ({ todos: s.todos.map((t) => t.id === a.payload.id ? { ...t, done: !t.done } : t) }),
REMOVE: (s, a) => ({ todos: s.todos.filter((t) => t.id !== a.payload.id) }),
CLEAR: (s) => ({ todos: [] }),
});
11. カスタムフックのテスト戦略
11.1 @testing-library/react の renderHook 型
import { renderHook, act } from "@testing-library/react";
import { describe, expect, it } from "vitest";
describe("useCounter", () => {
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
// result.current は readonly [number, () => void] と推論される
expect(result.current[0]).toBe(0);
act(() => result.current[1]());
expect(result.current[0]).toBe(1);
});
});
11.2 ジェネリックなフックのテスト
it("useDebounce works for string", () => {
const { result, rerender } = renderHook(
({ value }: { value: string }) => useDebounce(value, 100),
{ initialProps: { value: "" } },
);
rerender({ value: "hello" });
// ...
});
11.3 wrapperでProviderを渡す
import { ReactNode } from "react";
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
テストの詳細は React Testing Library完全実践ガイド を参照してください。
12. ESLintルールとカスタムフックの型
12.1 react-hooks/exhaustive-deps の効かせ方
{
"rules": {
"react-hooks/exhaustive-deps": ["warn", {
"additionalHooks": "(useIsomorphicLayoutEffect|useDebounceEffect)"
}]
}
}
12.2 カスタムフックの命名規則
// OK: use+大文字始まりでReactフックとして認識される
export function useMyHook() { /* ... */ }
// NG: useで始まらないとフックルールが効かない
// export function myHook() { useState(0); } // ESLint warning
13. アンチパターン10選
13.1 anyで逃げる
// NG
function useBad() {
const [v, setV] = useState<any>(null);
return [v, setV];
}
// OK: 想定される型を必ず明示
function useGood<T>(initial: T | null = null) {
return useState<T | null>(initial);
}
13.2 戻り値の型を明示しない長大なフック
// NG: 推論結果が複雑すぎて呼び出し側の保守性が悪い
// 戻り値が明示されないため、内部実装変更が呼び出し側に波及する
// OK: 公開フックは戻り値型を明示
type UseDialogReturn = {
isOpen: boolean;
open: () => void;
close: () => void;
};
export function useDialog(): UseDialogReturn {
const [isOpen, setIsOpen] = useState(false);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
};
}
13.3 useRefにDOM参照と値保持を混在
// NG: 同じrefでDOMとカウンタを兼用してしまう
// OK: 用途ごとにrefを分ける
const inputRef = useRef<HTMLInputElement>(null);
const renderCountRef = useRef<number>(0);
13.4 useStateで巨大なオブジェクトを管理
// NG: 1つのuseStateに何でも詰め込む
// OK: useReducerに移行
type AppState = { user: User | null; cart: Cart; ui: { sidebar: boolean } };
type AppAction =
| { type: "SET_USER"; user: User }
| { type: "TOGGLE_SIDEBAR" };
const [app, dispatch] = useReducer<React.Reducer<AppState, AppAction>>(
// reducer
(state, action) => state,
{ user: null, cart: { items: [] }, ui: { sidebar: false } },
);
13.5 useEffectの依存配列にオブジェクトリテラル
// NG: 毎回新しい参照が生成されて無限ループ
// useEffect(() => {}, [{ id: 1 }]);
// OK: プリミティブを依存にする
useEffect(() => {}, [id]);
13.6 useCallbackの依存配列を空にしてstaleな値を参照
// NG: countが古い値で固定される
const onClick = useCallback(() => console.log(count), []);
// OK: refで最新値を保持
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
const onClick2 = useCallback(() => console.log(countRef.current), []);
13.7 Context の value をオブジェクトリテラルで毎回作る
// NG: ProviderのvalueをuseMemoでメモ化しないと全consumerが再レンダー
// <AuthContext.Provider value={{ user, login, logout }}>...
// OK: useMemoでメモ化
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
13.8 早すぎるカスタムフック化
// 単一コンポーネントでしか使わないロジックを無理に抽出しない
// 「2箇所以上で同じロジックを書いた」タイミングで抽出する
13.9 useEffect で async 関数を直接渡す
// NG: 戻り値が Promise になりクリーンアップとして扱えない
// useEffect(async () => { ... }, []);
13.10 ジェネリクスが効かない設計
// NG: 戻り値が unknown 固定
// function useFetchAny(url: string): { data: unknown } { /* ... */ }
// OK: 呼び出し側で型を指定可能に
export function useFetchTyped<T>(url: string): { data: T | null } {
return { data: null };
}
14. まとめ:カスタムフック型設計の原則
- 初期値がnull/undefinedの useState は必ずジェネリクスで型を明示する
- useReducer は discriminated union + 網羅性チェックで設計する
- 戻り値が2〜3個ならタプル(
as const必須)、4個以上ならオブジェクト - 公開カスタムフックは戻り値の型を明示し、内部実装の変更影響を遮断する
- useRef はDOM参照(HTMLElement系)とミュータブル値で型を分ける
- ジェネリクスは「呼び出し側で型が決まるもの」(useFetch / useDebounce / useLocalStorage)に活用する
- any は最後の手段、まずは unknown + 型ガード を検討する
本記事の手法を実装すれば、カスタムフックの保守性・自己ドキュメント性・リファクタ耐性が劇的に向上します。あとは小さなプロジェクトから少しずつ適用し、チームで議論しながら自分たちの設計指針を育てていってください。
関連記事:
useState完全ガイド・
useEffect完全ガイド・
useReducer完全ガイド・
カスタムフック作り方完全ガイド・
ジェネリクス完全ガイド・
型ガード完全ガイド・
tsconfig.json完全ガイド

コメント