メインコンテンツまでスキップ

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 で明示
  • ref as 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)
タイマーIDuseRef<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>
);
}

試してみよう: 型安全なフォームコンポーネントを作ろう ★★★

以下の要件を満たす登録フォームコンポーネントを作成してください。

要件:

  1. 名前、メールアドレス、年齢を入力できるフォーム
  2. 送信時に入力データをコンソールに出力
  3. すべての入力にproperなTypeScript型を付ける
  4. バリデーション(名前必須、メール形式、年齢は0-150)
ヒント
  1. フォームデータの型をinterfaceで定義
  2. 各入力のイベントハンドラにChangeEvent<HTMLInputElement>を使用
  3. useStateでフォームデータとエラーを管理
  4. 送信前にバリデーション関数で検証
回答と解説
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;

解説:

  • 型定義: FormDataFormErrorsインターフェースでフォームの型を定義しています
  • 共通ハンドラ: handleChangename属性を使って動的にフィールドを更新しています
  • バリデーション: validate関数で各フィールドをチェックし、エラーオブジェクトを返します
  • 型安全な更新: name as keyof FormErrorsでオブジェクトのキーとして型安全にアクセスしています
  • 条件付きレンダリング: 送信済みかどうかで表示を切り替えています

改善ポイント:

  • 実際のプロダクションでは、react-hook-formzodなどのライブラリを使うとより効率的にバリデーションできます
  • エラーメッセージのスタイリングは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と区別させましょう。


次に読む