React + TypeScript Hooks
useRef・useContext・useReducer やカスタムフックの型定義を学びます
このチャプターで学ぶこと
- useRef の型を正しく指定できる
- useContext で型安全なコンテキストを作成できる
- useReducer のアクション型を判別付きユニオンで定義できる
- カスタムフックの戻り値に型を付けられる
React + TypeScript Hooks
前のチャプターでは Props、State、イベントの型付けを学びました。このチャプターでは、残りの主要な Hooks(useRef、useContext、useReducer)と、カスタムフックの型定義を扱います。
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) のポイントは以下の通りです。
- 型パラメータ
HTMLInputElementは、参照する DOM 要素の型 - 初期値は
null(レンダリング前はまだ DOM 要素が存在しないため) inputRef.currentの型はHTMLInputElement | nullになる- アクセス時にはオプショナルチェーン
?.を使うか、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 の .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>
);
}
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>
);
}
useFetch<User[]>("/api/users") は、Laravel の Http::get("/api/users")->json() に型情報を付けたものと考えてください。Laravel では実行時まで戻り値の型がわかりませんが、TypeScript では型パラメータを指定することで、IDE が users.map(u => u.name) のような操作を補完してくれます。
上で解説した 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> { /* ... */ }
まとめ
useRef<HTMLElement>(null)で DOM 要素への型安全な参照を作れるuseRef<T>(initialValue)でミュータブルな値を保持できる(DOM ref と区別する)useContextはcreateContext<T | null>(null)+ カスタムフック + null チェックの3点セットで型安全にするuseReducerのアクション型は判別付きユニオンで定義し、switch文で型の絞り込みを活用する- カスタムフックの戻り値型は明示的に定義する。タプルを返す場合は
as constを付ける useFetch<T>のようなジェネリックなカスタムフックで、型安全かつ再利用可能なデータ取得ロジックが作れる
これで React + TypeScript の主要な Hooks の型付けをマスターしました。ここまで学んだ知識があれば、型安全な React アプリケーションを構築できます。