Chapter 08

React + TypeScript Hooks

useRef・useContext・useReducer やカスタムフックの型定義を学びます

40 min

このチャプターで学ぶこと

  • useRef の型を正しく指定できる
  • useContext で型安全なコンテキストを作成できる
  • useReducer のアクション型を判別付きユニオンで定義できる
  • カスタムフックの戻り値に型を付けられる

React + TypeScript Hooks

前のチャプターでは Props、State、イベントの型付けを学びました。このチャプターでは、残りの主要な Hooks(useRefuseContextuseReducer)と、カスタムフックの型定義を扱います。

Hooks の型付けは一見複雑に見えますが、前のチャプターで学んだジェネリクスの知識があれば理解できます。useRef<T>useContext<T>useReducer<R, A> はすべてジェネリック関数です。

useRef の型定義

useRef には大きく分けて 2つの使い方 があります。それぞれで型の指定方法が異なります。

1. DOM 要素への参照(DOM ref)

フォームの入力欄にフォーカスを当てたり、スクロール位置を制御したりするために DOM 要素を参照するケースです。

import { useRef, useEffect } from "react";

function SearchBox() {
  // DOM 要素の型を指定し、初期値は null
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // コンポーネントがマウントされたらフォーカスを当てる
    inputRef.current?.focus();
  }, []);

  return (
    <div>
      <input
        ref={inputRef}
        type="text"
        placeholder="検索ワードを入力..."
      />
    </div>
  );
}

useRef<HTMLInputElement>(null) のポイントは以下の通りです。

よく使う DOM 要素の型

useRef<HTMLInputElement>(null);    // <input>
useRef<HTMLTextAreaElement>(null);  // <textarea>
useRef<HTMLSelectElement>(null);    // <select>
useRef<HTMLButtonElement>(null);    // <button>
useRef<HTMLDivElement>(null);      // <div>
useRef<HTMLFormElement>(null);     // <form>
useRef<HTMLVideoElement>(null);    // <video>
useRef<HTMLCanvasElement>(null);   // <canvas>
💡 ヒント

DOM 要素の型がわからないときは、エディタの補完を使いましょう。HTML と入力すると候補が表示されます。また、MDN のドキュメントにも対応する TypeScript の型名が記載されています。

2. ミュータブルな値の保持(mutable ref)

再レンダリングをトリガーせずに値を保持したい場合にも useRef を使います。タイマーの ID やインターバルの参照など、レンダリングに関係しない値を保持するのに便利です。

import { useRef, useState, useEffect } from "react";

function StopWatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  // タイマーIDを保持する ref(DOM要素ではなくミュータブルな値)
  const intervalRef = useRef<number>(0);

  const start = () => {
    if (isRunning) return;
    setIsRunning(true);
    intervalRef.current = window.setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
  };

  const stop = () => {
    setIsRunning(false);
    window.clearInterval(intervalRef.current);
  };

  const reset = () => {
    stop();
    setSeconds(0);
  };

  // コンポーネントのアンマウント時にクリーンアップ
  useEffect(() => {
    return () => {
      window.clearInterval(intervalRef.current);
    };
  }, []);

  return (
    <div>
      <p>{seconds}</p>
      <button onClick={start} disabled={isRunning}>開始</button>
      <button onClick={stop} disabled={!isRunning}>停止</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

DOM ref とミュータブル ref の違い

// DOM ref:初期値を null にすると .current は readonly になる
const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current は HTMLInputElement | null(readonly)
// inputRef.current = someElement; // エラー!readonly

// ミュータブル ref:null 以外の初期値を渡すと .current は書き換え可能
const countRef = useRef<number>(0);
// countRef.current は number(書き換え可能)
countRef.current = 42; // OK
📝 なぜ DOM ref は readonly なのか

DOM ref の .current は React が管理します。React がレンダリング時に DOM 要素を自動的に代入するため、開発者が直接書き換えることは想定されていません。一方、ミュータブル ref は開発者が自由に値を読み書きするためのものです。TypeScript はこの違いを型レベルで区別しています。

useContext の型定義

useContext を使って型安全なコンテキストを作るには、コンテキストの値の型を定義し、createContext に型パラメータを渡します。

基本パターン:テーマ切り替え

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

// 1. コンテキストの値の型を定義
interface ThemeContextType {
  theme: "light" | "dark";
  toggleTheme: () => void;
}

// 2. コンテキストを作成(初期値は null)
const ThemeContext = createContext<ThemeContextType | null>(null);

// 3. カスタムフックで安全にコンテキストを取得
function useTheme(): ThemeContextType {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error("useTheme は ThemeProvider の中で使う必要があります");
  }
  return context;
}

// 4. Provider コンポーネントを作成
interface ThemeProviderProps {
  children: React.ReactNode;
}

function ThemeProvider({ children }: ThemeProviderProps) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const toggleTheme = () => {
    setTheme(prev => (prev === "light" ? "dark" : "light"));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

// 5. コンポーネントで使う
function Header() {
  const { theme, toggleTheme } = useTheme();

  return (
    <header style={{
      backgroundColor: theme === "dark" ? "#1a1a2e" : "#ffffff",
      color: theme === "dark" ? "#ffffff" : "#000000",
      padding: "1rem",
    }}>
      <h1>マイアプリ</h1>
      <button onClick={toggleTheme}>
        {theme === "light" ? "ダークモードに切替" : "ライトモードに切替"}
      </button>
    </header>
  );
}

// 6. アプリのルートで Provider を配置
function App() {
  return (
    <ThemeProvider>
      <Header />
      {/* 他のコンポーネント */}
    </ThemeProvider>
  );
}
⚠️ 注意

createContext の初期値を null にして、カスタムフック内で null チェックするパターンを推奨します。createContext<ThemeContextType>(undefined as any) のようなキャストは型安全性を損なうので避けましょう。

実用パターン:認証コンテキスト

実際のアプリケーションでよくある認証コンテキストの例です。

import { createContext, useContext, useState, useCallback } from "react";

// ユーザー型
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
}

// 認証コンテキストの型
interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | null>(null);

// カスタムフック
function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (context === null) {
    throw new Error("useAuth は AuthProvider の中で使う必要があります");
  }
  return context;
}

// Provider
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = useCallback(async (email: string, password: string) => {
    // API呼び出し(実際にはfetchやaxiosを使う)
    const response = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    const data: User = await response.json();
    setUser(data);
  }, []);

  const logout = useCallback(() => {
    setUser(null);
  }, []);

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

// 使う側
function UserMenu() {
  const { user, isAuthenticated, logout } = useAuth();

  if (!isAuthenticated || !user) {
    return <p>ログインしてください</p>;
  }

  return (
    <div>
      <p>{user.name}さん({user.role}</p>
      <button onClick={logout}>ログアウト</button>
    </div>
  );
}
📝 Laravelで例えると

AuthContext は Laravel の Auth::user() に相当します。Laravel では Auth ファサードを通じてどこからでもログインユーザーにアクセスできますが、React では Context を使って同じことを実現します。型が付いているので、user.name のようなアクセスが安全に行えます。

useReducer の型定義

useReducer は複雑な State ロジックを管理するためのフックです。TypeScript では 判別付きユニオン(discriminated union) を使ってアクション型を定義するのが定番です。

State とアクションの型定義

import { useReducer } from "react";

// State の型
interface TodoState {
  todos: Todo[];
  filter: "all" | "active" | "completed";
}

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// アクション型(判別付きユニオン)
type TodoAction =
  | { type: "ADD_TODO"; payload: string }
  | { type: "TOGGLE_TODO"; payload: number }
  | { type: "DELETE_TODO"; payload: number }
  | { type: "SET_FILTER"; payload: "all" | "active" | "completed" };

type プロパティでアクションを判別します。各アクションの payload の型が異なっていても、TypeScript が正しく型を絞り込んでくれます。

Reducer 関数の実装

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "ADD_TODO":
      // ここでは action.payload は string と推論される
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: Date.now(),
            text: action.payload,
            completed: false,
          },
        ],
      };

    case "TOGGLE_TODO":
      // ここでは action.payload は number と推論される
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };

    case "DELETE_TODO":
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload),
      };

    case "SET_FILTER":
      // ここでは action.payload は "all" | "active" | "completed" と推論される
      return {
        ...state,
        filter: action.payload,
      };

    default:
      return state;
  }
}
💡 ヒント

switch 文で action.type を分岐すると、各 case ブロック内で action.payload の型が自動的に絞り込まれます。これが判別付きユニオンの強みです。TypeScript コンパイラが「この case では payload は string だ」と理解してくれるので、型アサーション(as)を書く必要がありません。

コンポーネントで使う

function TodoApp() {
  const initialState: TodoState = {
    todos: [],
    filter: "all",
  };

  const [state, dispatch] = useReducer(todoReducer, initialState);

  const handleAddTodo = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const form = event.currentTarget;
    const input = form.elements.namedItem("todoText") as HTMLInputElement;

    if (input.value.trim()) {
      // dispatch に渡すアクションも型チェックされる
      dispatch({ type: "ADD_TODO", payload: input.value.trim() });
      input.value = "";
    }
  };

  // フィルター適用後の Todo リスト
  const filteredTodos = state.todos.filter(todo => {
    if (state.filter === "active") return !todo.completed;
    if (state.filter === "completed") return todo.completed;
    return true;
  });

  return (
    <div style={{ maxWidth: "500px", margin: "0 auto", padding: "2rem" }}>
      <h1>Todoリスト</h1>

      <form onSubmit={handleAddTodo} style={{ marginBottom: "1rem" }}>
        <input name="todoText" type="text" placeholder="新しいタスクを入力..." />
        <button type="submit">追加</button>
      </form>

      <div style={{ marginBottom: "1rem" }}>
        {(["all", "active", "completed"] as const).map(filter => (
          <button
            key={filter}
            onClick={() => dispatch({ type: "SET_FILTER", payload: filter })}
            style={{
              fontWeight: state.filter === filter ? "bold" : "normal",
              marginRight: "0.5rem",
            }}
          >
            {filter === "all" ? "すべて" : filter === "active" ? "未完了" : "完了"}
          </button>
        ))}
      </div>

      <ul style={{ listStyle: "none", padding: 0 }}>
        {filteredTodos.map(todo => (
          <li key={todo.id} style={{ padding: "0.5rem 0", borderBottom: "1px solid #eee" }}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch({ type: "TOGGLE_TODO", payload: todo.id })}
            />
            <span style={{
              textDecoration: todo.completed ? "line-through" : "none",
              marginLeft: "0.5rem",
              color: todo.completed ? "#999" : "#000",
            }}>
              {todo.text}
            </span>
            <button
              onClick={() => dispatch({ type: "DELETE_TODO", payload: todo.id })}
              style={{ marginLeft: "0.5rem", color: "red" }}
            >
              削除
            </button>
          </li>
        ))}
      </ul>

      <p style={{ color: "#666", fontSize: "0.875rem" }}>
        {state.todos.filter(t => !t.completed).length} 件の未完了タスク
      </p>
    </div>
  );
}

カスタムフックの型定義

カスタムフックの戻り値にも型を付けましょう。明示的な戻り値型を指定すると、フックの使い方がわかりやすくなります。

基本的なカスタムフック

import { useState, useCallback } from "react";

// 戻り値の型を明示する
interface UseToggleReturn {
  isOn: boolean;
  toggle: () => void;
  setOn: () => void;
  setOff: () => void;
}

function useToggle(initialValue: boolean = false): UseToggleReturn {
  const [isOn, setIsOn] = useState(initialValue);

  const toggle = useCallback(() => setIsOn(prev => !prev), []);
  const setOn = useCallback(() => setIsOn(true), []);
  const setOff = useCallback(() => setIsOn(false), []);

  return { isOn, toggle, setOn, setOff };
}

// 使い方
function Modal() {
  const { isOn: isOpen, toggle, setOff: close } = useToggle(false);

  return (
    <div>
      <button onClick={toggle}>モーダルを開く</button>
      {isOpen && (
        <div style={{
          position: "fixed",
          top: 0,
          left: 0,
          width: "100%",
          height: "100%",
          backgroundColor: "rgba(0,0,0,0.5)",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}>
          <div style={{
            background: "white",
            padding: "2rem",
            borderRadius: "8px",
          }}>
            <h2>モーダル</h2>
            <p>ここにコンテンツが入ります</p>
            <button onClick={close}>閉じる</button>
          </div>
        </div>
      )}
    </div>
  );
}

タプル型を返すカスタムフック

useState のように配列(タプル)を返すカスタムフックを作ることもできます。

// タプル型を返すフック
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  // as const をつけてタプル型として返す
  return [storedValue, setValue] as const;
}

// 使い方(useState と同じ感覚で使える)
function Settings() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
  const [fontSize, setFontSize] = useLocalStorage<number>("fontSize", 16);

  return (
    <div>
      <p>現在のテーマ: {theme}</p>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        テーマ切替
      </button>
      <p>フォントサイズ: {fontSize}px</p>
      <button onClick={() => setFontSize(fontSize + 2)}>大きく</button>
      <button onClick={() => setFontSize(fontSize - 2)}>小さく</button>
    </div>
  );
}
⚠️ 注意

タプルを返す場合は as const を忘れないでください。as const がないと、戻り値は (T | (value: T) => void)[] という広い配列型に推論されてしまい、分割代入で正しい型が得られません。

ジェネリックなデータ取得フック

APIからデータを取得するジェネリックなカスタムフックは、実務で非常によく使います。

import { useState, useEffect } from "react";

// フックの戻り値の型
interface UseFetchReturn<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchReturn<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP エラー: ${response.status}`);
      }
      const json: T = await response.json();
      setData(json);
    } catch (err) {
      // err は unknown 型なので型ガードが必要
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError("不明なエラーが発生しました");
      }
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
  }, [url]);

  const refetch = () => {
    fetchData();
  };

  return { data, loading, error, refetch };
}

使い方は以下の通りです。

// ユーザー一覧を取得
interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  // 型パラメータで取得データの型を指定
  const { data: users, loading, error, refetch } = useFetch<User[]>("/api/users");

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error} <button onClick={refetch}>再試行</button></p>;
  if (!users) return null;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
}

// 単一の記事を取得
interface Article {
  id: number;
  title: string;
  body: string;
}

function ArticleDetail({ articleId }: { articleId: number }) {
  const { data: article, loading, error } = useFetch<Article>(`/api/articles/${articleId}`);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;
  if (!article) return null;

  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
    </article>
  );
}
📝 Laravelで例えると

useFetch<User[]>("/api/users") は、Laravel の Http::get("/api/users")->json() に型情報を付けたものと考えてください。Laravel では実行時まで戻り値の型がわかりませんが、TypeScript では型パラメータを指定することで、IDE が users.map(u => u.name) のような操作を補完してくれます。

✍ やってみよう:型付き useFetch カスタムフックを作る

上で解説した useFetch<T> フックを実際に作って使ってみましょう。

ステップ 1: フックの型定義

interface UseFetchReturn<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

ステップ 2: フックの実装

上の useFetch 関数を src/hooks/useFetch.ts として作成してください。

ステップ 3: テスト用のコンポーネントを作成

JSONPlaceholder(無料のテスト用API)を使って動作確認しましょう。

// JSONPlaceholder のデータ型
interface Post {
  userId: number;
  id: number;
  title: string;
  body: string;
}

function PostList() {
  const { data: posts, loading, error, refetch } = useFetch<Post[]>(
    "https://jsonplaceholder.typicode.com/posts?_limit=5"
  );

  if (loading) return <p>投稿を読み込み中...</p>;
  if (error) return (
    <div>
      <p>エラー: {error}</p>
      <button onClick={refetch}>再読み込み</button>
    </div>
  );
  if (!posts) return null;

  return (
    <div>
      <h2>投稿一覧</h2>
      <button onClick={refetch}>更新</button>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body.substring(0, 100)}...</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

発展課題 1: useFetch にオプションを追加する

// リクエストオプションを受け取れるようにする
interface UseFetchOptions {
  method?: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  body?: string;
}

function useFetch<T>(url: string, options?: UseFetchOptions): UseFetchReturn<T> {
  // fetch の第2引数に options を渡す
  // ...
}

発展課題 2: キャッシュ機能を追加する

同じ URL に対するリクエストの結果をキャッシュして、再取得時にローディングを表示しないようにしてみましょう。ヒント:コンポーネント外に Map<string, unknown> を作り、URL をキーとしてデータを保持します。

Hooks の型定義まとめ

最後に、このチャプターで学んだ Hooks の型パターンを一覧にまとめます。

// useRef:DOM 要素を参照(readonly な .current)
const inputRef = useRef<HTMLInputElement>(null);

// useRef:ミュータブルな値を保持(書き換え可能な .current)
const timerRef = useRef<number>(0);

// useContext:型安全なコンテキスト
const ThemeContext = createContext<ThemeContextType | null>(null);
const theme = useContext(ThemeContext); // ThemeContextType | null

// useReducer:判別付きユニオンでアクション型を定義
type Action = { type: "INCREMENT" } | { type: "SET"; payload: number };
const [state, dispatch] = useReducer(reducer, initialState);

// カスタムフック:戻り値の型を明示
function useToggle(initial: boolean): UseToggleReturn { /* ... */ }

// ジェネリックなカスタムフック
function useFetch<T>(url: string): UseFetchReturn<T> { /* ... */ }

まとめ

これで React + TypeScript の主要な Hooks の型付けをマスターしました。ここまで学んだ知識があれば、型安全な React アプリケーションを構築できます。