React + TypeScript
この章では、ReactとTypeScriptを組み合わせた型安全なフロントエンド開発について学びます。
この章で学ぶこと
- Reactプロジェクトのセットアップ
- 関数コンポーネントの型定義
- Props、イベントハンドラの型
- useState、useReducer、useRefの型
- useContextの型
- カスタムフック
TypeScriptを使うことで、Reactコンポーネントの型安全性が向上し、バグを早期に発見できます。また、IDEの補完機能が強化され、開発効率も上がります。
Reactプロジェクトのセットアップ
# Viteを使用したプロジェクト作成(推奨)
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
npm run dev
生成されるファイル構造:
my-react-app/
├── public/
├── src/
│ ├── App.tsx # メインコンポーネント
│ ├── main.tsx # エントリーポイント
│ └── vite-env.d.ts # Viteの型定義
├── index.html
├── package.json
├── tsconfig.json # TypeScript設定
├── tsconfig.node.json
└── vite.config.ts # Vite設定
関数コンポーネントの型定義
// src/components/Greeting.tsx
import { FC } from 'react';
// Propsの型定義
// interfaceでコンポーネントが受け取るpropsを定義
interface GreetingProps {
name: string; // 必須のprop
age?: number; // オプショナルなprop(?をつける)
}
// 方法1: FC(FunctionComponent)を使用
// FC<Props>でpropsの型を指定
const Greeting: FC<GreetingProps> = ({ name, age }) => {
return (
<div>
<h1>Hello, {name}!</h1>
{/* age !== undefinedで存在チェック */}
{age !== undefined && <p>You are {age} years old.</p>}
</div>
);
};
// 方法2: 型注釈を直接使用(推奨)
// FCを使わず、引数に直接型を指定する方法
function Greeting2({ name, age }: GreetingProps) {
return (
<div>
<h1>Hello, {name}!</h1>
{age !== undefined && <p>You are {age} years old.</p>}
</div>
);
}
export { Greeting, Greeting2 };
FCを使う場合と使わない場合の違い:
// childrenを含むコンポーネント
interface CardProps {
title: string;
children: React.ReactNode; // 明示的にchildrenを定義
}
// React.ReactNode: JSX要素、文字列、数値、null、配列などを受け取れる型
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
React 19(2026年時点の主流バージョン)について
本書のコード例は React 19 を前提としています。React 18 / 17 との主な違いは次の通りです:
FCは不要: React 19 以降は関数の引数に直接型注釈する「方法2」が推奨。FCは依然利用できますが、新規コードでは使わない流れですchildren型注釈: React 18 でFCが暗黙的にchildrenを含まなくなった変更は React 19 でも同じ。childrenを受け取る場合はReact.ReactNodeで明示refas a prop:forwardRefで包まなくても、refを通常の props として受け取れます(後述)
React 19 の新機能と型
React 19 で追加された API と、それぞれの TypeScript 型付けを整理します。
ref を props として受け取る(forwardRef 不要)
// React 19 以降: ref を通常の props として受け取れる
type InputProps = {
label: string
ref?: React.Ref<HTMLInputElement>
}
function LabeledInput({ label, ref }: InputProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
)
}
React 18 以前は forwardRef<HTMLInputElement, InputProps>(...) で包む必要がありましたが、React 19 からは不要です。既存の forwardRef コードは引き続き動作するため、一度に書き換える必要はありません。
useActionState による Action 管理
フォーム送信などの非同期処理を簡潔に書くための hook です。送信中状態(isPending)やエラーを自分で管理せずに済みます。
import { useActionState } from 'react'
type FormState = {
message: string
error: string | null
}
async function submitAction(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const name = formData.get('name') as string
if (!name) return { message: '', error: '名前を入力してください' }
await new Promise((r) => setTimeout(r, 500))
return { message: `${name}さん、送信完了`, error: null }
}
function ContactForm() {
// 戻り値は [現在の state, アクション関数, 送信中フラグ]
const [state, formAction, isPending] = useActionState(submitAction, {
message: '',
error: null,
})
return (
<form action={formAction}>
<input name="name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '送信中...' : '送信'}
</button>
{state.error && <p>{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
)
}
useActionState<State, Payload> の型引数は通常、引数と戻り値から推論されるため明示不要です。
useFormStatus(子コンポーネントから状態参照)
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? '送信中...' : '送信'}</button>
}
直近の親 <form> の状態を読み取ります。送信ボタンをコンポーネント化する際に使います。
useOptimistic(楽観的 UI 更新)
import { useOptimistic } from 'react'
function LikeButton({ count: serverCount }: { count: number }) {
const [optimisticCount, addOptimistic] = useOptimistic(
serverCount,
(current: number) => current + 1,
)
async function handleLike() {
addOptimistic(null) // UIを先に更新
await fetch('/api/like', { method: 'POST' })
}
return (
<form action={handleLike}>
<button type="submit">いいね ({optimisticCount})</button>
</form>
)
}
サーバー応答を待たずに UI を先行更新し、確定時に実際の値で置き換えます。
Document Metadata(<title> / <meta> 自動巻き上げ)
React 19 では、コンポーネント内に書いた <title> / <meta> / <link> が自動的に <head> へ巻き上げられます。これまで react-helmet 等のライブラリが担っていた役割が React 本体に組み込まれました。
function AboutPage() {
return (
<>
<title>会社について | MyApp</title>
<meta name="description" content="MyApp の会社情報ページです" />
<h1>会社について</h1>
</>
)
}
イベントハンドラの型
import { useState, ChangeEvent, FormEvent, MouseEvent } from 'react';
function LoginForm() {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
// 入力変更イベント
// ChangeEvent<HTMLInputElement>: input要素の変更イベントの型
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
// e.target.value: 入力された値を取得
setEmail(e.target.value);
};
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
// フォーム送信イベント
// FormEvent<HTMLFormElement>: form要素の送信イベントの型
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
// デフォルトの送信動作を防ぐ
e.preventDefault();
console.log('Login:', { email, password });
};
// クリックイベント
// MouseEvent<HTMLButtonElement>: button要素のクリックイベントの型
const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => {
// e.clientX, e.clientY: クリック位置の座標
console.log('Button clicked at:', e.clientX, e.clientY);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={handlePasswordChange}
placeholder="Password"
/>
<button type="submit" onClick={handleButtonClick}>
Login
</button>
</form>
);
}
よく使うイベント型:
| イベント | 型 |
|---|---|
| inputの変更 | ChangeEvent<HTMLInputElement> |
| textareaの変更 | ChangeEvent<HTMLTextAreaElement> |
| selectの変更 | ChangeEvent<HTMLSelectElement> |
| フォーム送信 | FormEvent<HTMLFormElement> |
| クリック | MouseEvent<HTMLButtonElement> |
| キーボード | KeyboardEvent<HTMLInputElement> |
| フォーカス | FocusEvent<HTMLInputElement> |
useStateの型
import { useState } from 'react';
// Userインターフェースを定義
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
// ===== プリミティブ型(型推論が効く) =====
// 初期値から自動的に型が推論される
const [count, setCount] = useState(0); // number型
const [name, setName] = useState(''); // string型
const [isActive, setIsActive] = useState(false); // boolean型
// ===== 初期値がnullの場合は明示的に型を指定 =====
// useState<User | null>(null)で「Userまたはnull」を表す
const [user, setUser] = useState<User | null>(null);
// ===== 配列の場合 =====
// 空配列の初期値だけでは型が推論できないため、明示的に指定
const [items, setItems] = useState<string[]>([]);
const [users, setUsers] = useState<User[]>([]);
// ===== オブジェクトの場合 =====
const [formData, setFormData] = useState<{ name: string; age: number }>({
name: '',
age: 0,
});
// ユーザーデータを取得する処理
const fetchUser = async () => {
// APIからデータを取得
const data: User = await fetch('/api/user').then(r => r.json());
// User型を期待する状態に設定
setUser(data);
};
// 配列に追加
const addItem = (item: string) => {
// prevを使って前の状態を基に更新
setItems(prev => [...prev, item]);
};
return (
<div>
{/* userがnullでない場合のみ表示 */}
{user ? (
<p>Welcome, {user.name}!</p>
) : (
<button onClick={fetchUser}>Load User</button>
)}
</div>
);
}
useReducerの型
複雑な状態管理にはuseReducerが適しています。
import { useReducer } from 'react';
// Stateの型を定義
interface CounterState {
count: number;
step: number;
}
// Actionの型(Union型で定義)
// 各アクションごとにtypeとpayloadを定義
type CounterAction =
| { type: 'INCREMENT' } // payloadなし
| { type: 'DECREMENT' } // payloadなし
| { type: 'SET_STEP'; payload: number } // payloadあり
| { type: 'RESET' }; // payloadなし
// 初期状態
const initialState: CounterState = {
count: 0,
step: 1,
};
// Reducer関数
// state: 現在の状態、action: 実行するアクション
// 戻り値: 新しい状態
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT':
// スプレッド構文で既存の状態をコピーし、countだけ更新
return { ...state, count: state.count + state.step };
case 'DECREMENT':
return { ...state, count: state.count - state.step };
case 'SET_STEP':
// action.payload: SET_STEPアクションには数値のpayloadがある
return { ...state, step: action.payload };
case 'RESET':
return initialState;
default: {
// exhaustive check: すべての case を網羅しているかチェック
// - action が never 型に絞り込まれているなら、すべて処理済み
// - 絞り込まれていないなら、CounterAction に case 漏れがある(コンパイルエラー)
// 詳しくは 11 章「型の絞り込み」の網羅性チェック節を参照
const _exhaustiveCheck: never = action;
return _exhaustiveCheck; // 絶対にここには到達しない
}
}
}
function Counter() {
// useReducer: reducerと初期状態を渡し、[state, dispatch]を取得
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<p>Step: {state.step}</p>
{/* dispatchでアクションを実行 */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'SET_STEP', payload: 5 })}>
Set Step to 5
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
useRefの型
import { useRef, useEffect } from 'react';
function TextInput() {
// DOM要素への参照
// useRef<HTMLInputElement>(null): input要素への参照
// 初期値nullで、マウント後にDOM要素が設定される
const inputRef = useRef<HTMLInputElement>(null);
// ミュータブルな値の保持(再レンダリングを起こさない)
// useRef<number>(0): 数値を保持
const renderCount = useRef<number>(0);
// タイマーIDの保持
// ReturnType<typeof setTimeout>: setTimeoutの戻り値の型
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// refの値を更新(再レンダリングは起きない)
renderCount.current += 1;
console.log(`Rendered ${renderCount.current} times`);
});
const focusInput = () => {
// nullチェックが必要(?.で安全にアクセス)
inputRef.current?.focus();
};
const startTimer = () => {
timerRef.current = setTimeout(() => {
console.log('Timer fired!');
}, 1000);
};
const cancelTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
return (
<div>
{/* refプロパティでDOM要素と紐付け */}
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus Input</button>
<button onClick={startTimer}>Start Timer</button>
<button onClick={cancelTimer}>Cancel Timer</button>
</div>
);
}
useRefの主な用途:
| 用途 | 型の例 |
|---|---|
| DOM要素への参照 | useRef<HTMLInputElement>(null) |
| 値の保持(再レンダリングなし) | useRef<number>(0) |
| タイマーID | useRef<ReturnType<typeof setTimeout> | null>(null) |
| 前の値の保持 | useRef<T>(initialValue) |
useContextの型
Contextを使ってコンポーネント間でデータを共有します。
import { createContext, useContext, useState, ReactNode } from 'react';
// Contextの型定義
interface ThemeContextType {
theme: 'light' | 'dark'; // テーマの種類
toggleTheme: () => void; // テーマを切り替える関数
}
// デフォルト値なしでContext作成
// undefinedを許可することで、Provider外での使用を検出可能
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// カスタムフックで型安全にアクセス
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
// Provider外で使用された場合はエラー
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// Providerコンポーネントのprops
interface ThemeProviderProps {
children: ReactNode; // 子コンポーネントを受け取る
}
// Providerコンポーネント
function ThemeProvider({ children }: ThemeProviderProps) {
// 内部でテーマの状態を管理
const [theme, setTheme] = useState<'light' | 'dark'>('light');
// テーマを切り替える関数
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};
return (
// value propでコンテキストの値を提供
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 使用例
function ThemedButton() {
// useThemeフックでContextの値を取得
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff',
}}
>
Current theme: {theme}
</button>
);
}
// Appコンポーネント
function App() {
return (
// ThemeProviderでラップ
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
);
}
カスタムフック
再利用可能なロジックをカスタムフックとして抽出します。
APIフェッチ用カスタムフック
import { useState, useEffect, useCallback } from 'react';
// カスタムフックの戻り値の型
interface UseFetchResult<T> {
data: T | null; // 取得したデータ
loading: boolean; // ロード中かどうか
error: Error | null; // エラー情報
refetch: () => void; // 再取得関数
}
// ジェネリック型Tでデータの型を柔軟に指定
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
// データ取得関数
// useCallbackでメモ化(urlが変わらない限り同じ関数を再利用)
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (e) {
// エラーの型を適切に処理
setError(e instanceof Error ? e : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [url]);
// マウント時とurl変更時にデータ取得
useEffect(() => {
fetchData();
}, [fetchData]);
// 状態と再取得関数を返す
return { data, loading, error, refetch: fetchData };
}
// 使用例
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
// useFetch<User[]>でUser配列を取得
const { data: users, loading, error, refetch } = useFetch<User[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!users) return <div>No data</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
}
ローカルストレージ用カスタムフック
import { useState } from 'react';
// ジェネリック型Tで保存する値の型を指定
// 戻り値: [値, 更新関数]のタプル
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// 初期値の取得(遅延初期化)
const [storedValue, setStoredValue] = useState<T>(() => {
try {
// localStorageから値を取得
const item = window.localStorage.getItem(key);
// 存在すればパース、なければ初期値を使用
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// 値の更新
const setValue = (value: T | ((prev: T) => T)) => {
try {
// 関数が渡された場合は現在の値を引数に実行
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// localStorageにも保存
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// 使用例
function Settings() {
// useLocalStorageで設定を保存
const [settings, setSettings] = useLocalStorage('settings', {
theme: 'light',
fontSize: 14,
});
return (
<div>
<p>Current theme: {settings.theme}</p>
<button onClick={() => setSettings(prev => ({
...prev,
theme: prev.theme === 'light' ? 'dark' : 'light'
}))}>
Toggle Theme
</button>
</div>
);
}
試してみよう: 型安全なフォームコンポーネントを作ろう ★★★
以下の要件を満たす登録フォームコンポーネントを作成してください。
要件:
- 名前、メールアドレス、年齢を入力できるフォーム
- 送信時に入力データをコンソールに出力
- すべての入力にproperなTypeScript型を付ける
- バリデーション(名前必須、メール形式、年齢は0-150)
ヒント
- フォームデータの型を
interfaceで定義 - 各入力のイベントハンドラに
ChangeEvent<HTMLInputElement>を使用 useStateでフォームデータとエラーを管理- 送信前にバリデーション関数で検証
回答と解説
import { useState, FormEvent, ChangeEvent } from 'react';
// フォームデータの型定義
interface FormData {
name: string;
email: string;
age: string; // inputは文字列なので、送信時に変換
}
// エラーメッセージの型定義
interface FormErrors {
name?: string;
email?: string;
age?: string;
}
function RegistrationForm() {
// フォームデータの状態
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
age: '',
});
// エラーメッセージの状態
const [errors, setErrors] = useState<FormErrors>({});
// 送信済みフラグ
const [isSubmitted, setIsSubmitted] = useState(false);
// 入力変更ハンドラ(全フィールド共通)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// nameをキーとして動的に更新
setFormData(prev => ({
...prev,
[name]: value,
}));
// 入力時にエラーをクリア
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({
...prev,
[name]: undefined,
}));
}
};
// バリデーション関数
const validate = (): boolean => {
const newErrors: FormErrors = {};
// 名前: 必須
if (!formData.name.trim()) {
newErrors.name = '名前は必須です';
}
// メール: 形式チェック
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email.trim()) {
newErrors.email = 'メールアドレスは必須です';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = '有効なメールアドレスを入力してください';
}
// 年齢: 0-150の範囲
if (!formData.age.trim()) {
newErrors.age = '年齢は必須です';
} else {
const ageNum = parseInt(formData.age, 10);
if (isNaN(ageNum) || ageNum < 0 || ageNum > 150) {
newErrors.age = '年齢は0〜150の範囲で入力してください';
}
}
setErrors(newErrors);
// エラーがなければtrue
return Object.keys(newErrors).length === 0;
};
// フォーム送信ハンドラ
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (validate()) {
// バリデーション成功
console.log('登録データ:', {
name: formData.name,
email: formData.email,
age: parseInt(formData.age, 10),
});
setIsSubmitted(true);
}
};
// 送信済みの場合
if (isSubmitted) {
return (
<div>
<h2>登録完了!</h2>
<p>ようこそ、{formData.name}さん!</p>
<button onClick={() => {
setFormData({ name: '', email: '', age: '' });
setIsSubmitted(false);
}}>
新規登録
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<h2>ユーザー登録</h2>
<div>
<label htmlFor="name">名前</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder="山田太郎"
/>
{errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="example@email.com"
/>
{errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
</div>
<div>
<label htmlFor="age">年齢</label>
<input
id="age"
name="age"
type="number"
value={formData.age}
onChange={handleChange}
placeholder="25"
min="0"
max="150"
/>
{errors.age && <span style={{ color: 'red' }}>{errors.age}</span>}
</div>
<button type="submit">登録</button>
</form>
);
}
export default RegistrationForm;
解説:
- 型定義:
FormDataとFormErrorsインターフェースでフォームの型を定義しています - 共通ハンドラ:
handleChangeでname属性を使って動的にフィールドを更新しています - バリデーション:
validate関数で各フィールドをチェックし、エラーオブジェクトを返します - 型安全な更新:
name as keyof FormErrorsでオブジェクトのキーとして型安全にアクセスしています - 条件付きレンダリング: 送信済みかどうかで表示を切り替えています
改善ポイント:
- 実際のプロダクションでは、
react-hook-formやzodなどのライブラリを使うとより効率的にバリデーションできます - エラーメッセージのスタイリングはCSSクラスで管理するとより保守しやすくなります
さらに学ぶべきトピック
本章で基本を学んだ後、以下のトピックを追加で学ぶことをおすすめします。
状態管理ライブラリ
大規模なアプリケーションでは、専用の状態管理ライブラリを導入するのが一般的です。2026年時点では Zustand v5 が軽量・シンプルで人気です。
// Zustand v5(軽量で型安全)
// TypeScript では create<T>()(...) の currying 形式が推奨
import { create } from 'zustand';
interface UserStore {
user: User | null;
setUser: (user: User) => void;
logout: () => void;
}
const useUserStore = create<UserStore>()((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// セレクタで必要な値だけを購読(再レンダリングを最小化)
const user = useUserStore((state) => state.user);
const setUser = useUserStore((state) => state.setUser);
Redux Toolkit も依然として有力な選択肢です。大規模で複雑な state 管理、Redux DevTools のデバッグ機能が必要な場合は Redux Toolkit、シンプルで軽量な API が欲しい場合は Zustand を選ぶのが一般的です。
フォームライブラリ
複雑なフォームにはreact-hook-form + zodの組み合わせが人気です。
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.email('有効なメールアドレスを入力'),
password: z.string().min(8, '8文字以上必要'),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
</form>
);
}
データフェッチングライブラリ
APIとの通信には TanStack Query v5(旧 React Query)が 2026 年時点のデファクトです。キャッシュ管理・バックグラウンド更新・Suspense 統合を持つ最も成熟した選択肢です。
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
// v5 では isLoading より isPending(初回ロードもバックグラウンド再取得もカバー)
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isPending) return <div>読み込み中...</div>;
if (isError) return <div>エラー: {error.message}</div>;
return <div>{data.name}</div>;
}
Suspense と組み合わせるなら useSuspenseQuery を使うと、ローディング処理を親の <Suspense> に委譲でき、コンポーネント内では data が必ず存在する前提で書けます。
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{data.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>読み込み中...</div>}>
<UserProfile userId={1} />
</Suspense>
);
}
まとめ
この章で学んだこと:
- プロジェクトセットアップ: Viteを使ったReact + TypeScriptプロジェクトの作成
- 関数コンポーネントの型: Propsの型定義、childrenの扱い
- イベントハンドラの型: ChangeEvent、FormEvent、MouseEventなど
- useStateの型: プリミティブ型、配列、オブジェクト、null許容
- useReducerの型: State型、Action型(Union型)、exhaustive check
- useRefの型: DOM参照、ミュータブル値の保持
- useContextの型: Context作成、カスタムフック、Provider
- カスタムフック: useFetch、useLocalStorageなど再利用可能なロジック
次の章では、Express + TypeScriptとベストプラクティスについて学びます。
初学者がつまずきやすいポイント
よくある間違い
❌ イベントハンドラの型を間違える
// ❌ 一般的なEventを使う
const handleChange = (e: Event) => {
console.log(e.target.value); // Error: targetの型が不明
};
// ✅ 要素に対応した型を使う
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value); // OK: valueプロパティにアクセス可能
};
原因: 一般的なEvent型では、targetのプロパティ(valueなど)にアクセスできません。
解決策: ChangeEvent<HTMLInputElement>のように、要素に対応した型を使いましょう。
❌ useStateの初期値でnullを考慮しない
// ❌ nullの可能性を無視
const [user, setUser] = useState<User>(); // User | undefined
function handleClick() {
console.log(user.name); // Error: userはundefinedの可能性
}
// ✅ 明示的にnullを許容し、チェックする
const [user, setUser] = useState<User | null>(null);
function handleClick() {
if (user) {
console.log(user.name); // OK
}
}
原因: 初期値がnull/undefinedの場合、その可能性を型に含める必要があります。
解決策: useState<User | null>(null)と明示し、使用時にnullチェックしましょう。
❌ useRefの.currentがnullになることを忘れる
// ❌ nullチェックを忘れる
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current.focus(); // Error: currentはnullの可能性
}
// ✅ nullチェックを行う
function focusInput() {
inputRef.current?.focus(); // OK
// または
if (inputRef.current) {
inputRef.current.focus();
}
}
原因: useRefでDOM要素を参照する場合、初期値はnullであり、マウント後に設定されます。
解決策: ?.(Optional Chaining)またはif文でnullチェックしましょう。
❌ 型定義なしでpropsを受け取る
// ❌ propsの型が不明
function UserCard(props) { // 暗黙のany
return <div>{props.name}</div>;
}
// ✅ 明示的に型を定義
interface UserCardProps {
name: string;
age?: number;
}
function UserCard({ name, age }: UserCardProps) {
return (
<div>
{name}
{age && <span> ({age})</span>}
</div>
);
}
原因: propsの型を定義しないと、暗黙のanyとなり型安全性が失われます。
解決策: interfaceでpropsの型を定義し、分割代入で受け取りましょう。
❌ JSXでジェネリクスが解釈できない
// ❌ .tsxファイルで<T>がJSXタグとして解釈される
const identity = <T>(value: T): T => value; // Error
// ✅ 方法1: カンマを追加
const identity1 = <T,>(value: T): T => value;
// ✅ 方法2: extendsを追加
const identity2 = <T extends unknown>(value: T): T => value;
// ✅ 方法3: function宣言を使う
function identity3<T>(value: T): T {
return value;
}
原因: .tsxファイルでは<T>がJSXタグとして解釈されます。
解決策: <T,>や<T extends unknown>でJSXと区別させましょう。