TypeScript×カスタムフック型定義完全ガイド〜useState/useEffect/useReducer/タプル戻り値【2026年版】〜

Reactのカスタムフックを書くようになると、必ず壁にぶつかるのが「型注釈」の問題です。useStateの初期値がnullのときどう型を付けるか、useReducerのActionをどう判別共用体で表現するか、戻り値はタプルとオブジェクトどちらにすべきか――こうした「カスタムフック設計における型の付け方」を一切ごまかさず徹底解説するのが本記事の目的です。

本記事は TypeScript 5.x / React 19 準拠で、コピペで動く50以上のコードサンプルを通して、useStateからuseImperativeHandleまでの全ビルトインフック・実用カスタムフック15種・テスト戦略・ESLintルール・アンチパターンまで、「カスタムフック×型注釈」に完全特化した知識を体系化します。「フックの基本的な使い方」は useState完全ガイドuseEffect完全ガイドuseReducer完全ガイドカスタムフック作り方完全ガイド でカバー済みのため、本記事では一切重複させず、「型をどう書くか」のみを掘り下げます。

  1. 1. なぜカスタムフックの型注釈は難しいのか
    1. 1.1 本記事で扱う対象バージョン
    2. 1.2 strict設定を前提にする
  2. 2. useStateの型注釈パターン
    1. 2.1 型推論に任せるパターン
    2. 2.2 初期値がnullの場合は型注釈が必須
    3. 2.3 オブジェクトリテラルは「広い型」を明示
    4. 2.4 配列の初期値は要素型を明示
    5. 2.5 setStateの関数引数は型推論が効く
    6. 2.6 Dispatch型を引数として受け渡す
    7. 2.7 ユニオン型のステートマシン
  3. 3. useEffectとuseLayoutEffectの型
    1. 3.1 戻り値の型はvoid | (() => void)
    2. 3.2 依存配列の型は ReadonlyArray<unknown>
    3. 3.3 async関数はそのまま渡せない
    4. 3.4 EffectCallback型を再利用
  4. 4. useReducerの型注釈
    1. 4.1 基本パターン:State型とAction型
    2. 4.2 discriminated unionでペイロード型を厳密化
    3. 4.3 網羅性チェック(exhaustiveness check)
    4. 4.4 初期化関数とlazy initialization
    5. 4.5 Reducer型ヘルパーで再利用
  5. 5. useContextの型注釈
    1. 5.1 デフォルト値がnullの場合の安全な書き方
    2. 5.2 Providerの外で使われたらthrowするカスタムフック
    3. 5.3 デフォルト値を「未初期化センチネル」にする
  6. 6. useRefの型注釈
    1. 6.1 DOM参照は HTMLElement系の型
    2. 6.2 各DOM要素の型
    3. 6.3 ミュータブルな値を保持する場合
    4. 6.4 前回値を保持するカスタムフック
  7. 7. useMemoとuseCallbackの型推論
    1. 7.1 useMemoは戻り値型で推論される
    2. 7.2 useCallback の型注釈
    3. 7.3 ジェネリックな比較関数をuseMemoでメモ化
  8. 8. カスタムフックの戻り値:タプル vs オブジェクト
    1. 8.1 タプル戻り値:as constで型を絞る
    2. 8.2 戻り値の型を明示する
    3. 8.3 オブジェクト戻り値:プロパティ名で意味が明確
    4. 8.4 タプルとオブジェクトの選び方
  9. 9. 実用カスタムフック15選(型注釈付き)
    1. 9.1 useToggle
    2. 9.2 useDebounce
    3. 9.3 useLocalStorage
    4. 9.4 useFetch
    5. 9.5 useMutation
    6. 9.6 useDisclosure(Modal用)
    7. 9.7 useClickOutside
    8. 9.8 useMediaQuery
    9. 9.9 useEventListener(EventMapジェネリクス)
    10. 9.10 useDeferredValueの型
    11. 9.11 useTransitionの型
    12. 9.12 useImperativeHandle と forwardRef(React 19以前互換)
    13. 9.13 React 19新方式:forwardRefなしでrefを受け取る
    14. 9.14 useInterval(useRef + useEffect)
    15. 9.15 useIsMounted
  10. 10. 型安全なReducer Builder
    1. 10.1 Action Creator関数で型を一元化
    2. 10.2 Map形式のReducer Builder
  11. 11. カスタムフックのテスト戦略
    1. 11.1 @testing-library/react の renderHook 型
    2. 11.2 ジェネリックなフックのテスト
    3. 11.3 wrapperでProviderを渡す
  12. 12. ESLintルールとカスタムフックの型
    1. 12.1 react-hooks/exhaustive-deps の効かせ方
    2. 12.2 カスタムフックの命名規則
  13. 13. アンチパターン10選
    1. 13.1 anyで逃げる
    2. 13.2 戻り値の型を明示しない長大なフック
    3. 13.3 useRefにDOM参照と値保持を混在
    4. 13.4 useStateで巨大なオブジェクトを管理
    5. 13.5 useEffectの依存配列にオブジェクトリテラル
    6. 13.6 useCallbackの依存配列を空にしてstaleな値を参照
    7. 13.7 Context の value をオブジェクトリテラルで毎回作る
    8. 13.8 早すぎるカスタムフック化
    9. 13.9 useEffect で async 関数を直接渡す
    10. 13.10 ジェネリクスが効かない設計
  14. 14. まとめ:カスタムフック型設計の原則

1. なぜカスタムフックの型注釈は難しいのか

JavaScriptで書かれたカスタムフックをTypeScriptに移植するとき、多くの開発者が以下の3点でつまずきます。

  • 初期値とジェネリクスの関係:useState(null)はTypeScript側でnull型に推論されてしまい、後から文字列を入れられない
  • 戻り値の形状:タプルにすべきかオブジェクトにすべきか、ジェネリクスでの推論可否が変わる
  • 依存配列の型:useCallbackuseMemoで渡す関数の型が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完全ガイド

コメント

タイトルとURLをコピーしました