【TypeScript × React】Hooks の型定義完全ガイド|useState・useReducer・useContext・useRef・カスタムフックの型安全パターンを徹底解説

React Hooks を TypeScript で使う場合、「型推論に頼るべきか」「どこでジェネリクスを指定するか」「useRef の型が 3 パターンあってどれを選ぶか」など、意外と迷いが生じます。

特に useReducer + Discriminated Union・useContext の型安全パターン・forwardRef の型定義など、正しく書かないと実行時エラーに直結するパターンが多数あります。

この記事では主要フック全種類の型定義パターンを、NG/OK コード例と理由付きで体系的に解説します。

この記事でわかること

  • useState<T> の型指定と型推論の使い分け
  • useReducer + Discriminated Union で型安全な状態管理
  • useContext の 3 つの型安全パターン(undefined 回避)
  • useRef の 3 つの型パターン(DOM参照・mutable値・初期化なし)
  • forwardRef<T, P> の型定義
  • useCallback / useMemo の型推論と明示
  • イベントハンドラーの正確な型(React.ChangeEvent 等)
  • 型安全なカスタムフックの設計(タプル戻り値・ジェネリクス)
  • useImperativeHandle で公開 API を型定義する
スポンサーリンク

useState

基本:型推論 vs ジェネリクス明示

import { useState } from "react";

// ─── 型推論に任せてよいケース ───
// 初期値から型が明確に決まる場合
const [count, setCount] = useState(0);       // number
const [name,  setName]  = useState("");      // string
const [flag,  setFlag]  = useState(false);   // boolean

// ─── ジェネリクスを明示すべきケース ───

// 1. 初期値が null / undefined で、後で別の型になる
const [user, setUser] = useState<User | null>(null);
//                              ^^^^^^^^^^^^^^^^
//  null だけだと useState<null> と推論されて setUser(user) できなくなる

// 2. 空配列(型が決まらない)
const [items, setItems] = useState<string[]>([]);
//                                 ^^^^^^^^
// [] だけだと never[] と推論される

// 3. Union 型(初期値だけでは型全体が決まらない)
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");

// 4. オブジェクト型(フィールドを明示したい)
interface FormValues {
  email:    string;
  password: string;
}
const [form, setForm] = useState<FormValues>({ email: "", password: "" });

オブジェクトの部分更新(関数型更新)

// NG: スプレッドなしで部分更新しようとするとコンパイルエラー
setForm({ email: "new@example.com" }); // Error: password が欠けている

// OK: 関数型更新でスプレッド
setForm(prev => ({ ...prev, email: "new@example.com" }));

// OK: Partial を使ったヘルパー関数
function updateForm(patch: Partial<FormValues>) {
  setForm(prev => ({ ...prev, ...patch }));
}
updateForm({ email: "new@example.com" }); // OK

useReducer

useReducerDiscriminated Union でアクション型を定義することで、dispatch できる操作を完全に型で制御できます。

import { useReducer } from "react";

// ─── State 型 ───
interface CounterState {
  count:   number;
  history: number[];
}

// ─── Action 型(Discriminated Union)───
// type フィールドで判別、各アクションに固有の payload を付ける
type CounterAction =
  | { type: "increment"; by?: number }   // by は省略可能
  | { type: "decrement"; by?: number }
  | { type: "reset";     to?: number }   // 特定の値にリセット
  | { type: "undo" };                    // payload なし

// ─── Reducer 関数 ───
// 引数と戻り値の型を明示(State を返すことを保証)
function counterReducer(
  state: CounterState,
  action: CounterAction
): CounterState {
  switch (action.type) {
    case "increment":
      // action.by は number | undefined
      const next = state.count + (action.by ?? 1);
      return { count: next, history: [...state.history, state.count] };
    case "decrement":
      const prev = state.count - (action.by ?? 1);
      return { count: prev, history: [...state.history, state.count] };
    case "reset":
      return { count: action.to ?? 0, history: [...state.history, state.count] };
    case "undo":
      // action.by や action.to にアクセスするとコンパイルエラー(型ガードが機能)
      const last = state.history.at(-1) ?? 0;
      return { count: last, history: state.history.slice(0, -1) };
  }
}

// ─── コンポーネントでの使用 ───
function Counter() {
  const [state, dispatch] = useReducer(
    counterReducer,
    { count: 0, history: [] }  // 初期 State
  );

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+1</button>
      <button onClick={() => dispatch({ type: "increment", by: 5 })}>+5</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
      {/* dispatch({ type: "unknown" }) → コンパイルエラー */}
      {/* dispatch({ type: "reset", by: 1 }) → コンパイルエラー(by は reset にない)*/}
    </div>
  );
}
useReducer を選ぶタイミング

  • 状態が複数のフィールドを持ち、更新ロジックが複雑なとき
  • 次の状態が現在の状態に依存するとき(prev => パターンが頻出)
  • 複数の useState がセットで更新されることが多いとき
  • 状態遷移を「操作(アクション)」として明示的に記録したいとき

Discriminated Union のパターンは判別可能なユニオン型ガイドも参照してください。

useContext

createContext のデフォルト値に undefinednull を使うと、利用側で毎回 null チェックが必要になります。3 つのパターンから要件に合うものを選んでください。

パターン 1:デフォルト値なし → カスタムフックで undefined を隠す(推奨)

import { createContext, useContext, useState, type ReactNode } from "react";

interface AuthContextValue {
  user:   User | null;
  login:  (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// undefined をデフォルト値とする(Provider 外で使ったとき undefined になる)
const AuthContext = createContext<AuthContextValue | undefined>(undefined);

// ─── カスタムフックで undefined チェックを一元化 ───
export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (ctx === undefined) {
    throw new Error("useAuth は AuthProvider の内側で使ってください");
  }
  return ctx; // ここでは AuthContextValue(undefined なし)
}

// ─── Provider コンポーネント ───
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    const user = await authApi.login(email, password);
    setUser(user);
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// ─── 利用側 ───
function Header() {
  const { user, logout } = useAuth(); // 型: AuthContextValue(undefined なし)
  return <div>{user?.name ?? "ゲスト"}<button onClick={logout}>ログアウト</button></div>;
}

パターン 2:デフォルト値を持つ(Provider なしでも動く)

// デフォルト値を完全に定義する → undefined にならない
interface ThemeContextValue {
  theme:     "light" | "dark";
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue>({
  theme:       "light",
  toggleTheme: () => {}, // no-op
});

// useContext をそのまま使える(undefined チェック不要)
function ThemeButton() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  return <button onClick={toggleTheme}>{theme === "light" ? "?" : "☀️"}</button>;
}

パターン 3:型アサーションで null 非許容にする(非推奨)

// NG(非推奨): null! で型をごまかす
// Provider 外で使うと実行時エラーだがコンパイルは通る
const UserContext = createContext<User>(null!);

// パターン 1 のカスタムフックアプローチが明示的で安全

useRef

useRef には使い方によって 3 つの型パターンがあります。どれを使うかによってコンパイラの挙動が変わるため、正確に使い分けてください。

パターン シグネチャ 用途 .current の型
DOM 参照 useRef<T>(null) DOM 要素の取得 T | null(読み取り専用)
mutable 値 useRef<T>(initialValue) 再レンダリングを起こさない値の保持 T(書き換え可能)
遅延初期化 useRef<T>() 後から代入、初期値なし T | undefined(書き換え可能)

パターン 1:DOM 要素への参照(最頻出)

import { useRef, useEffect } from "react";

// 型引数に DOM 要素の型、初期値は null
function SearchInput() {
  const inputRef = useRef<HTMLInputElement>(null);
  //                      ^^^^^^^^^^^^^^^^  ^^^^
  //                      DOM要素の型       初期値null

  useEffect(() => {
    // inputRef.current は HTMLInputElement | null
    // → マウント後にのみ非 null になる
    inputRef.current?.focus();
  }, []);

  // ref プロパティに渡すと React が自動的に current に代入
  return <input ref={inputRef} type="search" />;
}

// NG: 型引数を省略すると RefObject<unknown> になる
const badRef = useRef(null);  // RefObject<null>(HTMLInputElement ではない)
// badRef.current?.focus() → Error: null に focus は存在しない

パターン 2:mutable 値(タイマー ID・前回値・フラグ)

// 再レンダリングを起こさず値を保持したい場合
// 初期値を渡すと MutableRefObject<T> になり current を書き換えられる

// タイマー ID の保持
function Debounce({ onSearch }: { onSearch: (q: string) => void }) {
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  //    ^^^^^^^^                                                 ^^^^
  //    MutableRefObject                               初期値あり → 書き換え可

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => onSearch(e.target.value), 300);
  };

  return <input onChange={handleChange} />;
}

// 前回の値を保持するカスタムフック
function usePrevious<T>(value: T): T | undefined {
  const prevRef = useRef<T>();  // 初期値なし → MutableRefObject<T | undefined>
  useEffect(() => {
    prevRef.current = value;
  });
  return prevRef.current;
}
DOM 参照に MutableRefObject を使わない
useRef<HTMLInputElement>()(初期値なし)は MutableRefObject になりますが、<input ref={inputRef} />ref プロパティに渡せません。ref に渡せるのは RefObject<T>useRef(null) で得られる)だけです。DOM 参照には必ず useRef<T>(null)(初期値 null)を使ってください。

forwardRef

親コンポーネントが子の DOM 要素に直接アクセスできるよう ref を転送する際に使います。型引数は forwardRef<Ref型, Props型> の順番です。

import { forwardRef, useImperativeHandle } from "react";

interface InputProps {
  label:       string;
  placeholder?: string;
}

// forwardRef<Ref の型, Props の型>
const LabeledInput = forwardRef<HTMLInputElement, InputProps>(
  ({ label, placeholder }, ref) => {
    // ref の型: React.ForwardedRef<HTMLInputElement>
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} placeholder={placeholder} />
      </div>
    );
  }
);

// ─── 利用側 ───
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  const focusInput = () => inputRef.current?.focus();

  return (
    <div>
      <LabeledInput ref={inputRef} label="メールアドレス" />
      <button onClick={focusInput}>フォーカス</button>
    </div>
  );
}

useImperativeHandle で公開 API を型定義する

// 子コンポーネントが親に公開するメソッドの型
interface DialogHandle {
  open:  () => void;
  close: () => void;
  isOpen: boolean;
}

interface DialogProps {
  title:    string;
  children: React.ReactNode;
}

// 型引数は forwardRef<公開API型, Props型>
const Dialog = forwardRef<DialogHandle, DialogProps>(({ title, children }, ref) => {
  const [open, setOpen] = useState(false);

  // useImperativeHandle<公開API型> で親への公開 API を定義
  useImperativeHandle(ref, () => ({
    open:   () => setOpen(true),
    close:  () => setOpen(false),
    get isOpen() { return open; },
  }));

  if (!open) return null;
  return (
    <div role="dialog">
      <h2>{title}</h2>
      {children}
      <button onClick={() => setOpen(false)}>閉じる</button>
    </div>
  );
});

// ─── 利用側: 公開された API だけが型補完される ───
function App() {
  const dialogRef = useRef<DialogHandle>(null);

  return (
    <>
      <button onClick={() => dialogRef.current?.open()}>開く</button>
      <Dialog ref={dialogRef} title="確認">
        <p>本当に削除しますか?</p>
      </Dialog>
    </>
  );
}

useCallback と useMemo

import { useCallback, useMemo } from "react";

// ─── useCallback ───
// 戻り値の型は関数の型から自動推論される
const handleClick = useCallback(() => {
  console.log("clicked");
}, []); // () => void

// 引数がある場合も型推論される
const handleChange = useCallback(
  (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  },
  []
); // (e: React.ChangeEvent<HTMLInputElement>) => void

// ─── useMemo ───
// 戻り値の型はコールバックの戻り値から推論
const filtered = useMemo(
  () => items.filter(item => item.active),
  [items]
); // Item[]

// 型を明示したい場合はジェネリクスで指定(通常は不要)
const sorted = useMemo<string[]>(
  () => [...names].sort(),
  [names]
);
useCallback の依存配列と型安全
useCallback の依存配列は TypeScript では型チェックされません。ESLint の react-hooks/exhaustive-depsルールを有効にして、依存配列の漏れをコンパイル前に検出してください。(ESLint と TypeScript の連携は ESLint + Prettier セットアップガイドを参照。)

イベントハンドラーの型定義

React のイベント型は React.XxxEvent<HTMLElement> の形式です。主要な型を一覧で確認しましょう。

イベント 主なプロパティ
クリック React.MouseEvent<HTMLButtonElement> e.currentTarget, e.preventDefault()
フォーム入力 React.ChangeEvent<HTMLInputElement> e.target.value, e.target.checked
テキストエリア React.ChangeEvent<HTMLTextAreaElement> e.target.value
セレクト React.ChangeEvent<HTMLSelectElement> e.target.value
フォーム送信 React.FormEvent<HTMLFormElement> e.preventDefault()
キー入力 React.KeyboardEvent<HTMLInputElement> e.key, e.code, e.ctrlKey
フォーカス React.FocusEvent<HTMLInputElement> e.relatedTarget
ドラッグ React.DragEvent<HTMLDivElement> e.dataTransfer
// ─── 実践的なフォーム処理 ───
function LoginForm() {
  const [email, setEmail]       = useState("");
  const [password, setPassword] = useState("");

  const handleEmailChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    setEmail(e.target.value);
  };

  const handleSubmit = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();
    await login(email, password);
  };

  // ─── ハンドラーを汎用的にする ───
  // HTMLInputElement・HTMLTextAreaElement 共通のハンドラー
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email"    onChange={handleChange} />
      <input name="password" onChange={handleChange} type="password" />
      <button type="submit">ログイン</button>
    </form>
  );
}

型安全なカスタムフックの設計

タプルを返すカスタムフック(as const で型を固定)

// NG: 戻り値が (boolean | () => void)[] と推論されてしまう
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  return [on, toggle]; // (boolean | (() => void))[](型情報が失われる)
}
const [isOpen, toggleOpen] = useToggle();
// toggleOpen(true); // エラーにならない(boolean も渡せてしまう)

// OK1: as const でタプル型を固定
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  return [on, toggle] as const; // [boolean, () => void] に固定
}

// OK2: 戻り値型を明示
function useToggle(initial = false): [boolean, () => void] {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  return [on, toggle];
}

const [isOpen, toggleOpen] = useToggle();
// isOpen: boolean, toggleOpen: () => void

ジェネリクスを使ったカスタムフック

// ─── useLocalStorage<T>: 型安全なローカルストレージ ───
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  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)) => {
      setStoredValue(prev => {
        // typeof で判定(T 自体が関数型の場合も安全に動作)
        const next = typeof value === "function"
          ? (value as (prev: T) => T)(prev)
          : value;
        try {
          localStorage.setItem(key, JSON.stringify(next));
        } catch { /* storage full などを無視 */ }
        return next;
      });
    },
    [key]
  );

  return [storedValue, setValue];
}

// 利用例(型推論が働く)
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
// theme: "light" | "dark"
// setTheme("light") → OK
// setTheme("blue")  → Error: 型 "blue" は型 "light" | "dark" に割り当てられない

データフェッチカスタムフック

// ─── useFetch<T>: ジェネリクスでレスポンス型を指定 ───
interface FetchState<T> {
  data:    T | null;
  loading: boolean;
  error:   Error | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, dispatch] = useReducer(
    (
      prev: FetchState<T>,
      action:
        | { type: "loading" }
        | { type: "success"; payload: T }
        | { type: "error";   error:   Error }
    ): FetchState<T> => {
      switch (action.type) {
        case "loading": return { data: null, loading: true,  error: null };
        case "success": return { data: action.payload, loading: false, error: null };
        case "error":   return { data: null, loading: false, error: action.error };
      }
    },
    { data: null, loading: false, error: null }
  );

  useEffect(() => {
    let cancelled = false;
    dispatch({ type: "loading" });

    fetch(url)
      .then(r => r.json() as Promise<T>)
      .then(data => { if (!cancelled) dispatch({ type: "success", payload: data }); })
      .catch(err => { if (!cancelled) dispatch({ type: "error", error: err }); });

    return () => { cancelled = true; }; // アンマウント時のキャンセル
  }, [url]);

  return state;
}

// 利用例
interface Post { id: number; title: string; body: string; }

function PostList() {
  const { data, loading, error } = useFetch<Post[]>("/api/posts");
  // data: Post[] | null
  if (loading) return <p>Loading...</p>;
  if (error)   return <p>Error: {error.message}</p>;
  if (!data)   return null;
  return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

Children と Props の型定義パターン

import type { ReactNode, PropsWithChildren, ComponentPropsWithoutRef } from "react";

// ─── children の型 ───

// ReactNode: JSX要素・文字列・数値・null・配列など何でも受け取る(最も広い)
interface CardProps {
  title:    string;
  children: ReactNode;
}

// PropsWithChildren<T>: { children?: ReactNode } を自動追加するユーティリティ
interface BoxProps {
  padding: number;
}
function Box({ padding, children }: PropsWithChildren<BoxProps>) {
  return <div style={{ padding }}>{children}</div>;
}

// ─── 既存の HTML 属性を継承する Props ───
// ComponentPropsWithoutRef<"button"> で button の全 HTML 属性を引き継ぐ
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
  variant?: "primary" | "secondary" | "danger";
  loading?: boolean;
}

function Button({ variant = "primary", loading, children, ...rest }: ButtonProps) {
  return (
    <button
      {...rest} // onClick・disabled・type など全 button 属性が使える
      disabled={loading || rest.disabled}
      className={`btn btn-${variant}`}
    >
      {loading ? "読み込み中..." : children}
    </button>
  );
}

// 利用側: button の全属性 + 独自 variant が使える
<Button variant="primary" onClick={() => save()} type="submit">保存</Button>

よくある型エラーと解消法

「Type ‘null’ is not assignable to type ‘HTMLInputElement’」

// NG: useRef の型引数を省略すると RefObject<undefined> になる
const ref = useRef();
ref.current.focus(); // Error: Object is possibly undefined

// OK: 型引数と null 初期値を明示
const ref = useRef<HTMLInputElement>(null);
ref.current?.focus(); // Optional chaining で安全にアクセス

「Cannot read properties of null (reading ‘focus’)」(実行時)

// useEffect 外で ref.current に直接アクセスすると null
// NG: コンポーネント関数本体で ref.current を使う(まだマウントされていない)
const ref = useRef<HTMLInputElement>(null);
ref.current.focus(); // ← NG: レンダリング時点では null

// OK: useEffect の中(マウント後)または イベントハンドラーの中で使う
useEffect(() => {
  ref.current?.focus(); // マウント後は HTMLInputElement
}, []);

「Type ‘string’ is not assignable to type ‘never’」

// NG: 空配列を初期値にすると never[] と推論される
const [tags, setTags] = useState([]);
setTags(["react", "ts"]); // Error: string は never に代入不可

// OK: 型引数を明示
const [tags, setTags] = useState<string[]>([]);
setTags(["react", "ts"]); // OK

よくある質問

QReact.FCReact.FunctionComponent)は使うべきですか?

A現在は 使わないのが主流です。React.FC は以前 children を自動で含んでいましたがReact 18 で削除されました。また React.FCdisplayName などの余分な型も含むため、シンプルに function Component(props: Props) と書く方が明示的で推奨されています。

QuseState の初期値に関数を渡すと型はどうなりますか?

A遅延初期化(Lazy initialization)パターンです。useState(() => heavyCompute()) と渡すと、heavyCompute() の戻り値型から状態の型が推論されます。初期化コストの高い計算(localStorage.getItem・大きなオブジェクト生成など)を初回レンダリング時だけ実行したい場合に使います。型を明示したい場合は useState<T>(() => initial) と書けます。

QuseCallbackuseMemo の戻り値型は常に推論に任せてよいですか?

Aほとんどの場合、推論で問題ありません。明示すべきケースは 戻り値が Union 型になるが特定の型に絞りたいとき条件分岐で型が変わりすぎるときです。例: useMemo<User | Admin>(() => ...)。型が正確に推論されているかは IDE のホバーで確認できます。

QContext の型に関数を含める場合、() => void() => never どちらが正しいですか?

A通常は () => void です。void は「戻り値を気にしない(あっても無視してよい)」という意味で、関数型に使う場合は「戻り値を使わない」ことを表します。never は「絶対に返らない(例外を投げる・無限ループ)」関数の型なので、Context の no-op には適しません。実際に値を返す関数を入れる場合は () => User() => Promise<void> のように明示します。

QuseRef で前回の値を保持するパターンが TypeScript でうまく動きません。

AuseRef<T>()(初期値なし)は MutableRefObject<T | undefined> になるため、prevRef.currentT | undefined です。前回値が存在しない初回レンダリング時は undefined を返すので、利用側で prev !== undefined のチェックが必要です。初回から値が必要な場合は useRef<T>(initialValue) で初期値を渡してください。

まとめ

フック 型定義のポイント
useState 初期値が null・空配列・Union 型の場合は型引数を明示
useReducer Action を Discriminated Union で定義 → dispatch の型が完全に制御できる
useContext undefined をカスタムフックで隠すパターンが最も安全
useRef(DOM) useRef<HTMLElement>(null)。Optional Chaining でアクセス
useRef(mutable) useRef<T>(initialValue)。MutableRefObject になり書き換え可能
forwardRef forwardRef<RefType, PropsType>。型引数の順番に注意
カスタムフック(タプル) as const か戻り値型の明示でタプル型を固定
カスタムフック(ジェネリクス) 型引数を受け取って内部の useState・useReducer に伝播
イベントハンドラー React.ChangeEvent<HTML要素型> を明示

Hooks の型定義を習得すると、React コンポーネント全体の型安全性が格段に向上します。さらに進んだ内容として、TypeScript React 入門ガイドTypeScript Next.js ガイドESLint + Prettier セットアップガイドも参照してください。