TypeScriptの基本的な型定義を学んだ次のステップとして、多くの開発者が取り組むのがReact + TypeScriptの組み合わせです。Reactは世界で最も使われているUIライブラリであり、TypeScriptとの相性は抜群です。しかし、ReactにはProps、Hooks、イベントハンドラー、Contextなど独自の概念が多く、「TypeScriptの型をどう書けばいいのか」で悩む場面が頻繁に訪れます。
この記事では、React + TypeScriptプロジェクトの環境構築から、コンポーネント・Props・Hooks・イベント・API通信・フォーム処理まで、実務で必要になる型定義パターンを体系的に解説します。すべてのコード例はコピーしてそのまま使えるように書かれています。
この記事で学べること
- React + TypeScriptの環境構築(Vite / Create React App / Next.js)
- 関数コンポーネントの型定義(React.FC vs 通常の関数)
- Propsの型定義(interface / children / オプショナル / デフォルト値)
- イベントハンドラーの型定義(onClick / onChange / onSubmit 全パターン)
- useState / useEffect / useRef / useReducer / useContext の型定義
- useMemo / useCallback / カスタムHookの型定義
- API通信の型定義(fetch / axios / エラーハンドリング)
- フォーム処理の型定義(制御・非制御 / React Hook Form連携)
- 実務で使えるコンポーネントパターン集
- React + TypeScriptでよくあるエラーと対処法
前提知識:この記事はTypeScriptの基本的な型(string, number, interface, ジェネリクスなど)を理解していることを前提としています。TypeScriptの基礎が不安な方は、TypeScript 型の書き方 完全入門から読むことをおすすめします。
React + TypeScriptの環境構築
React + TypeScriptプロジェクトを始める方法は複数あります。2024年現在、最も推奨されるのはViteを使った方法です。主要な3つの方法を紹介します。
Viteで作成する(推奨)
Viteはフランス語で「速い」を意味する、次世代のフロントエンドビルドツールです。開発サーバーの起動が高速で、Hot Module Replacement(HMR)も瞬時に反映されます。
Vite + React + TypeScript
# プロジェクト作成
npm create vite@latest my-react-app -- --template react-ts
# ディレクトリに移動してインストール
cd my-react-app
npm install
# 開発サーバー起動
npm run dev
Viteで作成されるプロジェクトの主要なファイル構成は以下の通りです。
プロジェクト構成
my-react-app/
src/
App.tsx ← メインコンポーネント(.tsx拡張子)
main.tsx ← エントリーポイント
vite-env.d.ts ← Viteの型定義
tsconfig.json ← TypeScript設定
tsconfig.node.json ← Node.js向けTS設定
vite.config.ts ← Vite設定
package.json
ポイント:Reactコンポーネントを含むファイルの拡張子は.tsx、JSX/TSXを含まない純粋なTypeScriptファイルは.tsを使います。Viteのテンプレートはこれが最初から正しく設定されています。
Create React App(CRA)で作成する
従来から使われてきた公式ツールです。2023年以降は積極的なメンテナンスが行われていないため、新規プロジェクトにはViteの利用を推奨しますが、既存プロジェクトで使われているケースは多いです。
Create React App + TypeScript
npx create-react-app my-react-app --template typescript
cd my-react-app
npm start
Next.jsで作成する
Next.jsはReactベースのフルスタックフレームワークで、サーバーサイドレンダリング(SSR)やファイルベースルーティングが特徴です。プロダクション向けのReact開発では最も人気のある選択肢の一つです。
Next.js + TypeScript
npx create-next-app@latest my-next-app --typescript
cd my-next-app
npm run dev
環境構築ツールの比較
| 項目 |
Vite |
Create React App |
Next.js |
| 起動速度 |
非常に高速 |
やや遅い |
高速 |
| SSR対応 |
プラグインで可能 |
非対応 |
標準対応 |
| ルーティング |
別途ライブラリ |
別途ライブラリ |
ファイルベース |
| 設定の柔軟性 |
高い |
ejectが必要 |
next.config.jsで可能 |
| 学習コスト |
低い |
低い |
やや高い |
| 推奨シーン |
SPA / 一般的な開発 |
既存PJ保守 |
プロダクション向け |
tsconfig.jsonの主要設定
React + TypeScriptプロジェクトで重要なtsconfig.jsonの設定を確認しましょう。
tsconfig.json(React向けの重要設定)
{
"compilerOptions": {
// JSX構文のサポート - Reactでは "react-jsx" を使用
"jsx": "react-jsx",
// strictモード - 型安全性を最大限に高める
"strict": true,
// ESモジュール相互運用
"esModuleInterop": true,
// モジュール解決戦略
"moduleResolution": "bundler",
// パスエイリアス(@/ で src/ を参照)
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
注意:"jsx": "react-jsx"はReact 17以降の新しいJSX変換に対応する設定です。この設定により、各ファイルでimport React from 'react'を省略できます。React 16以前を使っている場合は"react"を指定してください。
関数コンポーネントの型定義
React + TypeScriptでコンポーネントを定義する方法は大きく2つあります。通常の関数宣言とReact.FCです。結論から言うと、現在のReactコミュニティでは通常の関数宣言が推奨されています。
通常の関数宣言(推奨)
最もシンプルで、TypeScriptの型推論を最大限に活用できる方法です。
通常の関数宣言(推奨パターン)
// Props型を定義
interface GreetingProps {
name: string;
age?: number; // オプショナル
}
// 関数宣言でコンポーネントを定義
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<h1>Hello, {name}!</h1>
{age !== undefined && <p>Age: {age}</p>}
</div>
);
}
// アロー関数でもOK
const GreetingArrow = ({ name, age }: GreetingProps) => {
return (
<div>
<h1>Hello, {name}!</h1>
{age !== undefined && <p>Age: {age}</p>}
</div>
);
};
React.FCを使う方法
React.FC(React.FunctionComponent)は、以前は公式ドキュメントでも推奨されていた書き方です。
React.FC を使う方法
import { FC } from 'react';
interface GreetingProps {
name: string;
age?: number;
}
// FC<Props> でコンポーネントの型を明示
const Greeting: FC<GreetingProps> = ({ name, age }) => {
return (
<div>
<h1>Hello, {name}!</h1>
{age !== undefined && <p>Age: {age}</p>}
</div>
);
};
React.FC vs 通常の関数の比較
| 項目 |
通常の関数 |
React.FC |
| children |
明示的に定義が必要 |
React 18で暗黙的childrenは削除 |
| ジェネリクス |
自然に書ける |
書きにくい |
| 戻り値の型 |
自動推論(JSX.Element | null) |
ReactElement | null に制限 |
| defaultProps |
デフォルト引数で対応 |
defaultPropsと相性が良い |
| コミュニティの推奨 |
推奨 |
非推奨の流れ |
ポイント:React 18以降、React.FCから暗黙的なchildrenの型定義が削除されました。これによりReact.FCを使う主なメリットが薄れたため、通常の関数宣言を使うのが現在のベストプラクティスです。
戻り値の型を明示する場合
通常は型推論に任せますが、戻り値を明示したいケースもあります。
戻り値の型を明示
import { ReactElement } from 'react';
// JSX.Element - 最も一般的
function Greeting({ name }: { name: string }): JSX.Element {
return <h1>Hello, {name}!</h1>;
}
// ReactElement - より厳密
function Greeting2({ name }: { name: string }): ReactElement {
return <h1>Hello, {name}!</h1>;
}
// nullを返す可能性がある場合
function ConditionalGreeting({ name }: { name?: string }): JSX.Element | null {
if (!name) return null;
return <h1>Hello, {name}!</h1>;
}
Propsの型定義
Propsの型定義はReact + TypeScriptの根幹です。ここでは基本パターンから実務でよく使うパターンまで網羅的に解説します。
基本的なProps型
Props型はinterfaceまたはtypeで定義します。どちらを使っても機能的に大きな違いはありませんが、Reactコミュニティではinterfaceが主流です。
基本的なProps型の定義
// interface で定義(推奨)
interface UserCardProps {
name: string; // 必須
email: string; // 必須
age?: number; // オプショナル
isActive?: boolean; // オプショナル
tags?: string[]; // 配列
role: 'admin' | 'user' | 'guest'; // リテラル型
onClick: () => void; // コールバック関数
}
function UserCard({ name, email, age, isActive = true, tags = [], role, onClick }: UserCardProps) {
return (
<div onClick={onClick}>
<h2>{name}</h2>
<p>{email}</p>
{age && <p>Age: {age}</p>}
<span>{isActive ? 'Active' : 'Inactive'}</span>
<span>Role: {role}</span>
{tags.map(tag => <span key={tag}>{tag}</span>)}
</div>
);
}
childrenの型定義
childrenはReactコンポーネントのもっとも重要なPropsの一つです。使用する要素の種類に応じて、適切な型を選びましょう。
children の型定義パターン
import { ReactNode, PropsWithChildren } from 'react';
// パターン1: ReactNode(最も汎用的・推奨)
interface CardProps {
title: string;
children: ReactNode; // string, number, JSX, null, undefined, 配列すべてOK
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
</div>
);
}
// パターン2: PropsWithChildren ユーティリティ型
interface LayoutProps {
sidebar?: ReactNode;
}
// PropsWithChildren<LayoutProps> は { sidebar?: ReactNode; children?: ReactNode } と同等
function Layout({ sidebar, children }: PropsWithChildren<LayoutProps>) {
return (
<div>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
// パターン3: JSX.Elementのみ(文字列やnullを除外)
interface StrictCardProps {
children: JSX.Element;
}
// パターン4: 関数children(Render Props)
interface DataProviderProps<T> {
children: (data: T) => ReactNode;
}
| children の型 |
受け入れる値 |
使用場面 |
ReactNode |
JSX、文字列、数値、null、配列 |
最も汎用的(推奨) |
ReactElement |
JSX要素のみ |
JSX要素を必須にしたい場合 |
JSX.Element |
JSX要素のみ |
ReactElementとほぼ同じ |
string |
文字列のみ |
テキスト専用コンポーネント |
(data: T) => ReactNode |
関数(Render Props) |
データを渡すパターン |
オブジェクト型のProps
オブジェクト型のProps
// ネストしたオブジェクトの型
interface Address {
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
name: string;
address: Address;
}
interface UserProfileProps {
user: User;
onEdit: (userId: number) => void;
}
function UserProfile({ user, onEdit }: UserProfileProps) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.address.city}, {user.address.country}</p>
<button onClick={() => onEdit(user.id)}>Edit</button>
</div>
);
}
Props型の拡張(extends / intersection)
Props型の拡張パターン
// ベースProps
interface BaseButtonProps {
label: string;
disabled?: boolean;
}
// extends で拡張(interface)
interface PrimaryButtonProps extends BaseButtonProps {
variant: 'primary';
size?: 'sm' | 'md' | 'lg';
}
// intersection で拡張(type)
type IconButtonProps = BaseButtonProps & {
icon: ReactNode;
iconPosition?: 'left' | 'right';
};
// HTML要素のPropsを継承
interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
isLoading?: boolean;
}
// onClick, className, disabled 等すべてのbutton属性が使える
function CustomButton({ variant = 'primary', isLoading, children, ...rest }: CustomButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={isLoading}
{...rest}
>
{isLoading ? 'Loading...' : children}
</button>
);
}
ポイント:React.ButtonHTMLAttributes<HTMLButtonElement>を使うと、HTML標準のbutton属性をすべて自動的に受け入れるボタンコンポーネントが作れます。同様にReact.InputHTMLAttributes、React.AnchorHTMLAttributesなども用意されています。
Discriminated Union(判別可能なUnion型)のProps
条件によって異なるPropsを受け取るコンポーネントに最適なパターンです。
Discriminated Union Props
// リンクとしてのボタン or 通常のボタン
type ButtonProps =
| {
as: 'link';
href: string;
target?: '_blank' | '_self';
children: ReactNode;
}
| {
as?: 'button';
onClick: () => void;
children: ReactNode;
};
function Button(props: ButtonProps) {
if (props.as === 'link') {
// TypeScriptが自動的に href と target を認識
return <a href={props.href} target={props.target}>{props.children}</a>;
}
// こちらでは onClick が認識される
return <button onClick={props.onClick}>{props.children}</button>;
}
// 使用例
<Button as="link" href="/about">About</Button>
<Button onClick={() => alert('click')}>Click</Button>
<Button as="link" onClick={() => {}}>Error!</Button> // コンパイルエラー
イベントハンドラーの型定義
Reactのイベントハンドラーは合成イベント(SyntheticEvent)を使用します。TypeScriptでは、イベントの種類ごとに適切な型を指定する必要があります。
主要なイベント型一覧
| イベント |
イベント型 |
主な使用場面 |
onClick |
React.MouseEvent<HTMLElement> |
ボタン、リンクのクリック |
onChange |
React.ChangeEvent<HTMLElement> |
input, select, textareaの値変更 |
onSubmit |
React.FormEvent<HTMLFormElement> |
フォーム送信 |
onKeyDown |
React.KeyboardEvent<HTMLElement> |
キー入力 |
onFocus / onBlur |
React.FocusEvent<HTMLElement> |
フォーカス操作 |
onDrag |
React.DragEvent<HTMLElement> |
ドラッグ&ドロップ |
onScroll |
React.UIEvent<HTMLElement> |
スクロール |
onMouseEnter / onMouseLeave |
React.MouseEvent<HTMLElement> |
マウスホバー |
onClick の型定義
onClick の型定義
// インライン - 型は自動推論される
<button onClick={(e) => {
// e は React.MouseEvent<HTMLButtonElement> と自動推論
console.log(e.currentTarget.name);
}}>Click</button>
// 別関数で定義する場合は型を明示
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
console.log('Clicked!');
};
// データを渡す場合
const handleItemClick = (id: number) => (e: React.MouseEvent<HTMLButtonElement>) => {
console.log(`Item ${id} clicked`);
};
// 使用
<button onClick={handleClick}>Click</button>
<button onClick={handleItemClick(42)}>Item 42</button>
onChange の型定義
onChange の型定義(input / select / textarea)
// input要素
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; // string型
console.log(value);
};
// select要素
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
console.log(value);
};
// textarea要素
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
console.log(value);
};
// checkbox
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked; // boolean型
console.log(checked);
};
onSubmit の型定義
onSubmit の型定義
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // デフォルトの送信を防止
// FormDataを使って値を取得
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
console.log({ name, email });
};
// JSX
<form onSubmit={handleSubmit}>
<input name="name" type="text" />
<input name="email" type="email" />
<button type="submit">Submit</button>
</form>
onKeyDown の型定義
onKeyDown の型定義
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Enter pressed');
}
if (e.key === 'Escape') {
console.log('Escape pressed');
}
// Ctrl + S
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
console.log('Save shortcut!');
}
};
<input onKeyDown={handleKeyDown} />
イベントハンドラーをPropsとして渡す
イベントハンドラーをPropsとして渡す
// 方法1: React.MouseEventHandler を使う
interface ButtonProps {
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
// 方法2: 関数型を直接書く
interface ButtonProps2 {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
// 方法3: イベントオブジェクト不要なら簡潔に
interface ButtonProps3 {
onClick: () => void;
}
ポイント:イベントハンドラーの型が分からない場合、まずインラインでonChange={(e) => {}}のように書き、eにマウスカーソルを合わせるとVSCodeが型を表示してくれます。それを別関数の型注釈にコピーすれば確実です。
useStateの型定義
useStateはReactで最も頻繁に使うHookです。TypeScriptでは初期値から型が推論されますが、明示的な型指定が必要になるケースも多いです。
基本: 初期値からの型推論
useState – 初期値からの型推論
import { useState } from 'react';
// 初期値から型が推論される(型引数は不要)
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isOpen, setIsOpen] = useState(false); // boolean
const [items, setItems] = useState(['a', 'b']); // string[]
// 型推論が効いているので型安全
setCount(10); // OK
setCount('hello'); // エラー: string は number に割り当てられない
setName('Alice'); // OK
setIsOpen(true); // OK
明示的な型指定が必要なケース
useState – 明示的な型指定
// 1. オブジェクト型
interface User {
id: number;
name: string;
email: string;
}
const [user, setUser] = useState<User>({
id: 1,
name: 'Alice',
email: 'alice@example.com',
});
// 2. null許容(APIデータなど、初期値がない場合)
const [user2, setUser2] = useState<User | null>(null);
// user2を使う前にnullチェックが必要
if (user2) {
console.log(user2.name); // OK: nullチェック後はUser型
}
// 3. Union型(複数の状態を表す)
type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');
setStatus('loading'); // OK
setStatus('done'); // エラー: 'done' は Status型に含まれない
// 4. 空配列の初期値(型推論が never[] になるため明示が必要)
const [users, setUsers] = useState<User[]>([]);
// 5. undefined許容
const [selectedId, setSelectedId] = useState<number | undefined>();
注意:useState([])のように空配列を初期値にすると、型がnever[]と推論されます。必ずuseState<User[]>([])のように型引数を指定してください。同様にuseState(null)だけではnull型になるので、useState<User | null>(null)とします。
useStateのセッター関数の型
セッター関数をPropsとして渡す
import { Dispatch, SetStateAction } from 'react';
interface CounterProps {
count: number;
setCount: Dispatch<SetStateAction<number>>;
}
function Counter({ count, setCount }: CounterProps) {
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(prev => prev + 1)}>+1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
useEffectの型定義と注意点
useEffectは副作用(API呼び出し、DOMの操作、タイマーなど)を扱うHookです。useEffect自体にジェネリクスパラメータはありませんが、内部で使用する変数やコールバック関数の型定義が重要です。
基本パターン
useEffect の基本パターン
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchUser = async () => {
try {
setLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data: User = await res.json();
if (isMounted) setUser(data);
} catch (err) {
if (isMounted) setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (isMounted) setLoading(false);
}
};
fetchUser();
return () => { isMounted = false; };
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;
return <h1>{user.name}</h1>;
}
注意:useEffectのコールバック関数はasync関数にできません。useEffectはvoidまたはクリーンアップ関数(() => void)を返す必要がありますが、async関数はPromiseを返すためです。内部で別のasync関数を定義して呼び出してください。
タイマーとイベントリスナーのクリーンアップ
タイマー・イベントリスナーのクリーンアップ
// タイマーのクリーンアップ
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId: ReturnType<typeof setInterval> = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <p>{seconds}秒経過</p>;
}
// ウィンドウサイズ監視
function WindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>{size.width} x {size.height}</p>;
}
useRefの型定義
useRefには大きく2つの用途があります。DOM要素への参照と、再レンダリングを引き起こさない値の保持です。用途によって型の書き方が異なります。
DOM要素への参照
useRef – DOM要素への参照
import { useRef, useEffect } from 'react';
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleClick = () => {
inputRef.current?.focus();
inputRef.current?.select();
};
return (
<div>
<input ref={inputRef} type="text" />
<button onClick={handleClick}>Focus</button>
</div>
);
}
よく使うHTML要素の型
| HTML要素 |
型名 |
<input> |
HTMLInputElement |
<div> |
HTMLDivElement |
<button> |
HTMLButtonElement |
<form> |
HTMLFormElement |
<textarea> |
HTMLTextAreaElement |
<select> |
HTMLSelectElement |
<a> |
HTMLAnchorElement |
<img> |
HTMLImageElement |
<canvas> |
HTMLCanvasElement |
値の保持(再レンダリングを引き起こさない)
useRef – 値の保持
function StopWatch() {
const [time, setTime] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const renderCountRef = useRef(0); // number型と推論
const start = () => {
intervalRef.current = setInterval(() => {
setTime(prev => prev + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
renderCountRef.current += 1;
return (
<div>
<p>{time}秒</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
ポイント:useRef<HTMLInputElement>(null)のようにDOM参照で初期値をnullにすると、.currentは読み取り専用(RefObject)になります。一方、useRef<number>(0)のように直接値を初期値にすると、.currentは書き換え可能(MutableRefObject)になります。
useReducerの型定義
useReducerは複雑な状態管理に適したHookです。TypeScriptとの相性が非常に良く、Discriminated Union(判別可能なUnion型)でアクションを定義することで、reducer内で強力な型チェックが働きます。
基本パターン: カウンター
useReducer – 基本パターン
import { useReducer } from 'react';
interface CounterState {
count: number;
}
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; payload: number };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'set', payload: 100 })}>Set 100</button>
</div>
);
}
実践パターン: Todoリスト
useReducer – Todoリスト(実践パターン)
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: TodoState['filter'] }
| { type: 'CLEAR_COMPLETED' };
let nextId = 1;
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: nextId++, text: action.payload, completed: false }],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t => t.id === action.payload ? { ...t, completed: !t.completed } : t),
};
case 'DELETE_TODO':
return { ...state, todos: state.todos.filter(t => t.id !== action.payload) };
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'CLEAR_COMPLETED':
return { ...state, todos: state.todos.filter(t => !t.completed) };
}
}
// 使用例
const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: 'all' });
dispatch({ type: 'ADD_TODO', payload: '買い物に行く' }); // OK
dispatch({ type: 'TOGGLE_TODO', payload: 1 }); // OK
dispatch({ type: 'UNKNOWN' }); // エラー
ポイント:useReducerのAction型にDiscriminated Unionを使うと、switch文の各case内でaction.payloadの型が自動的に絞り込まれます。例えば'ADD_TODO'ではpayloadがstring型、'TOGGLE_TODO'ではnumber型と正確に推論されます。
useContextの型定義
useContextは、Propsを介さずにコンポーネントツリー全体でデータを共有するHookです。TypeScriptで型安全にContextを定義し、カスタムHookと組み合わせて使うのが実務でのベストプラクティスです。
基本: テーマContextの作成
useContext – テーマContextの作成と使用
import { createContext, useContext, useState, ReactNode } from 'react';
// 1. Context値の型を定義
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// 2. Contextを作成(初期値にnullを使うパターン)
const ThemeContext = createContext<ThemeContextType | null>(null);
// 3. カスタムHookで型安全にアクセス
function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// 4. Providerコンポーネント
function ThemeProvider({ children }: { children: ReactNode }) {
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={{ background: theme === 'dark' ? '#333' : '#fff' }}>
<button onClick={toggleTheme}>Toggle Theme</button>
</header>
);
}
// 6. アプリケーションのルート
function App() {
return (
<ThemeProvider>
<Header />
</ThemeProvider>
);
}
実践: 認証Contextの作成
認証Context(実践パターン)
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) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data: User = await res.json();
setUser(data);
};
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// 使用例
function ProfilePage() {
const { user, logout } = useAuth();
if (!user) return <p>Please login</p>;
return (
<div>
<h1>{user.name}</h1>
<p>Role: {user.role}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
ポイント:Context作成時の初期値にnullを使い、カスタムHookでnullチェックを行うパターンが最も安全です。Provider外でうっかり使った場合に、実行時エラーではなく分かりやすいエラーメッセージが表示されます。
useMemoとuseCallbackの型定義
useMemoは計算結果のメモ化、useCallbackは関数のメモ化に使います。どちらも型は自動推論されるため、明示的な型指定が必要になるケースは少ないですが、知っておくと便利です。
useMemoの型定義
useMemo の型定義
import { useMemo } from 'react';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
function ProductList({ products, category }: { products: Product[]; category: string }) {
// 型は自動推論: Product[]
const filteredProducts = useMemo(
() => products.filter(p => p.category === category),
[products, category]
);
// 型は自動推論: number
const totalPrice = useMemo(
() => filteredProducts.reduce((sum, p) => sum + p.price, 0),
[filteredProducts]
);
// 明示的に型を指定することも可能
const sortedProducts = useMemo<Product[]>(
() => [...filteredProducts].sort((a, b) => a.price - b.price),
[filteredProducts]
);
return (
<div>
<p>合計: {totalPrice}円</p>
{sortedProducts.map(p => <div key={p.id}>{p.name}: {p.price}円</div>)}
</div>
);
}
useCallbackの型定義
useCallback の型定義
import { useCallback, useState } from 'react';
function SearchForm() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
// 型は自動推論: (e: React.ChangeEvent<HTMLInputElement>) => void
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
},
[]
);
// 引数と戻り値の型を明示
const handleSearch = useCallback(
async (searchQuery: string): Promise<void> => {
const res = await fetch(`/api/search?q=${searchQuery}`);
const data: string[] = await res.json();
setResults(data);
},
[]
);
return (
<div>
<input value={query} onChange={handleChange} />
<button onClick={() => handleSearch(query)}>Search</button>
{results.map((r, i) => <p key={i}>{r}</p>)}
</div>
);
}
カスタムHookの型定義
カスタムHookはReactのロジック再利用の主要な手段です。TypeScriptでは、カスタムHookの戻り値の型定義が重要になります。特にタプル型の戻り値とジェネリクスHookのパターンを押さえましょう。
基本: useToggle
カスタムHook – useToggle
// タプル型で戻り値を定義(as const が重要)
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
// as const でタプル型に(なしだと (boolean | () => void)[] になる)
return [value, toggle, setTrue, setFalse] as const;
}
// 使用例
function Modal() {
const [isOpen, toggle, open, close] = useToggle();
// isOpen: boolean, toggle/open/close: () => void
return (
<div>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div>
<p>Modal Content</p>
<button onClick={close}>Close</button>
</div>
)}
</div>
);
}
注意:カスタムHookの戻り値を配列にする場合、as constを忘れると(boolean | (() => void))[]というUnion型の配列になってしまいます。as constを付けることで[boolean, () => void, () => void, () => void]というタプル型になり、分割代入で正しい型が得られます。
ジェネリクスHook: useFetch
ジェネリクスHook – useFetch
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: T = await res.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// 使用例: 型引数でレスポンスの型を指定
interface User { id: number; name: string; }
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
// users は User[] | null 型
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
カスタムHook: useLocalStorage
useLocalStorage(ジェネリクスHook)
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 = (value: T | ((prev: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}
// 使用例: 型が自動推論される
const [name, setName] = useLocalStorage('userName', 'Guest'); // string
const [count, setCount] = useLocalStorage('count', 0); // number
const [settings, setSettings] = useLocalStorage('settings', { theme: 'dark', lang: 'ja' });
API通信の型定義
React + TypeScriptでのAPI通信は、レスポンスの型定義とエラーハンドリングが重要です。fetch APIとaxiosの両方のパターンを解説します。
fetch APIの型定義
fetch API の型定義
// APIレスポンスの型
interface ApiResponse<T> {
data: T;
message: string;
status: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface ApiError {
message: string;
code: string;
status: number;
}
// 型安全なfetch関数
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const error: ApiError = await res.json();
throw new Error(error.message);
}
const data: ApiResponse<T> = await res.json();
return data.data;
}
// 使用例
const users = await fetchApi<User[]>('/api/users'); // User[]
const user = await fetchApi<User>('/api/users/1'); // User
// POST
const newUser = await fetchApi<User>('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com' }),
});
axiosの型定義
axios の型定義
import axios, { AxiosResponse, AxiosError } from 'axios';
interface User {
id: number;
name: string;
email: string;
}
// GET - 型引数でレスポンスデータ型を指定
const res: AxiosResponse<User[]> = await axios.get<User[]>('/api/users');
const users: User[] = res.data;
// POST
interface CreateUserRequest {
name: string;
email: string;
}
const newUser = await axios.post<User>('/api/users', {
name: 'Alice',
email: 'alice@example.com',
} satisfies CreateUserRequest);
// エラーハンドリング
try {
const res = await axios.get<User[]>('/api/users');
} catch (error) {
if (axios.isAxiosError<ApiError>(error)) {
// error.response?.data は ApiError 型
console.error(error.response?.data.message);
console.error(error.response?.status);
}
}
ポイント:axiosのisAxiosError<T>は型ガードとして機能し、catch節内でerror.response?.dataの型をTに絞り込みます。fetchの場合はこのような型ガードが標準で提供されていないため、自前で実装する必要があります。
フォーム処理の型定義
Reactのフォーム処理には制御コンポーネント(Controlled)と非制御コンポーネント(Uncontrolled)の2つのアプローチがあります。それぞれの型定義と、実務で人気のReact Hook Formとの連携方法を解説します。
制御コンポーネント(Controlled)
制御コンポーネント – フォーム
interface FormData {
name: string;
email: string;
role: 'admin' | 'user';
agreeToTerms: boolean;
}
function RegistrationForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
role: 'user',
agreeToTerms: false,
});
// 汎用的なchangeハンドラー
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox'
? (e.target as HTMLInputElement).checked
: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" value={formData.name} onChange={handleChange} />
<input name="email" type="email" value={formData.email} onChange={handleChange} />
<select name="role" value={formData.role} onChange={handleChange}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<label>
<input name="agreeToTerms" type="checkbox" checked={formData.agreeToTerms} onChange={handleChange} />
利用規約に同意
</label>
<button type="submit">登録</button>
</form>
);
}
非制御コンポーネント(Uncontrolled + useRef)
非制御コンポーネント – useRef
function UncontrolledForm() {
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const name = nameRef.current?.value ?? '';
const email = emailRef.current?.value ?? '';
console.log({ name, email });
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" />
<input ref={emailRef} type="email" defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
React Hook Form との連携
React Hook Form + TypeScript
import { useForm, SubmitHandler } from 'react-hook-form';
// フォームのデータ型を定義
interface LoginFormInputs {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
// ジェネリクスでフォームデータの型を指定
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormInputs>({
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
// SubmitHandler<T> で送信ハンドラの型を定義
const onSubmit: SubmitHandler<LoginFormInputs> = async (data) => {
// data は LoginFormInputs 型(型安全)
console.log(data.email, data.password, data.rememberMe);
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "無効なメールアドレスです" },
})}
/>
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register("password", {
required: "パスワードは必須です",
minLength: { value: 8, message: "8文字以上必要です" },
})}
/>
{errors.password && <span>{errors.password.message}</span>}
<label>
<input type="checkbox" {...register("rememberMe")} />
ログイン状態を保持
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : 'ログイン'}
</button>
</form>
);
}
コンポーネントパターン集
実務で頻出するReact + TypeScriptのコンポーネントパターンを紹介します。
Compound Components(複合コンポーネント)
親子コンポーネントが暗黙的に状態を共有するパターンです。UIライブラリで頻繁に使われます。
Compound Components パターン
interface AccordionContextType {
activeIndex: number | null;
toggleIndex: (index: number) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
// 親コンポーネント
function Accordion({ children }: { children: ReactNode }) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const toggleIndex = (index: number) => {
setActiveIndex(prev => prev === index ? null : index);
};
return (
<AccordionContext.Provider value={{ activeIndex, toggleIndex }}>
{children}
</AccordionContext.Provider>
);
}
// 子コンポーネント
function AccordionItem({ index, title, children }: {
index: number;
title: string;
children: ReactNode;
}) {
const ctx = useContext(AccordionContext);
if (!ctx) throw new Error('AccordionItem must be inside Accordion');
const isOpen = ctx.activeIndex === index;
return (
<div>
<button onClick={() => ctx.toggleIndex(index)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
}
// サブコンポーネントを親に付与
Accordion.Item = AccordionItem;
// 使用例
<Accordion>
<Accordion.Item index={0} title="Section 1">Content 1</Accordion.Item>
<Accordion.Item index={1} title="Section 2">Content 2</Accordion.Item>
</Accordion>
Polymorphic Components(多態性コンポーネント)
asプロパティでレンダリングするHTML要素を切り替えるパターンです。ChakraUIやMaterial UIなどで使われています。
Polymorphic Components
import { ElementType, ComponentPropsWithoutRef, ReactNode } from 'react';
// as プロパティの型定義
type BoxProps<C extends ElementType> = {
as?: C;
children?: ReactNode;
} & ComponentPropsWithoutRef<C>;
function Box<C extends ElementType = 'div'>({ as, children, ...rest }: BoxProps<C>) {
const Component = as || 'div';
return <Component {...rest}>{children}</Component>;
}
// 使用例: as によって型が自動的に切り替わる
<Box>Default div</Box>
<Box as="section">Section</Box>
<Box as="a" href="/about">Link</Box> // href が使える
<Box as="button" onClick={handleClick}>Button</Box> // onClick が使える
<Box as="a" onClick={handleClick}>Error</Box> // OK: a要素にもonClickはある
ジェネリクスコンポーネント
データの型をコンポーネント利用時に決定するパターンです。リスト表示やセレクトボックスに便利です。
ジェネリクスコンポーネント
// ジェネリクスなリストコンポーネント
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => ReactNode;
keyExtractor: (item: T) => string | number;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage = 'No items' }: ListProps<T>) {
if (items.length === 0) return <p>{emptyMessage}</p>;
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// 使用例: User型で使用
interface User { id: number; name: string; }
<List<User>
items={users}
keyExtractor={user => user.id}
renderItem={user => <span>{user.name}</span>}
emptyMessage="ユーザーがいません"
/>
React + TypeScriptでよくあるエラーと対処法
React + TypeScriptの開発では、独特のエラーに遭遇します。ここでは頻出するエラーとその対処法をまとめます。
1. Type ‘string’ is not assignable to type ‘…(リテラル型)’
リテラル型のエラーと対処
interface ButtonProps {
variant: 'primary' | 'secondary';
}
// エラー: 'string' は 'primary' | 'secondary' に割り当てられない
const variant = 'primary'; // string型と推論される
<Button variant={variant} /> // エラー!
// 対処法1: as const
const variant1 = 'primary' as const; // 'primary' リテラル型
<Button variant={variant1} /> // OK
// 対処法2: 型注釈
const variant2: ButtonProps['variant'] = 'primary';
<Button variant={variant2} /> // OK
// 対処法3: satisfies(TypeScript 4.9+)
const config = {
variant: 'primary',
} satisfies ButtonProps; // 型チェック + リテラル型を保持
2. Property ‘children’ does not exist
children が存在しないエラー
// エラー: CardProps に children が定義されていない
interface CardProps {
title: string;
}
function Card({ title, children }: CardProps) { ... } // エラー!
// 対処法1: children を明示的に追加
interface CardProps {
title: string;
children: ReactNode;
}
// 対処法2: PropsWithChildren を使う
function Card({ title, children }: PropsWithChildren<{ title: string }>) { ... }
3. Cannot find name ‘JSX’ / Cannot find module ‘react’
モジュール解決エラーの対処
// 原因: @types/react がインストールされていない
// 対処:
npm install --save-dev @types/react @types/react-dom
// tsconfig.json で jsx が正しく設定されているか確認
{
"compilerOptions": {
"jsx": "react-jsx" // React 17+
}
}
4. Type ‘null’ is not assignable to type ‘ReactNode’(React 18)
React 18 の型エラー対処
// React 18 で @types/react を最新にすると発生することがある
// 対処: @types/react のバージョンを確認・更新
npm install @types/react@latest @types/react-dom@latest
// 条件付きレンダリングの型エラー
function Component({ show }: { show: boolean }) {
// エラーになる場合
return show && <div>Content</div>; // boolean | JSX.Element
// 対処: 三項演算子を使う
return show ? <div>Content</div> : null;
}
5. event.target.value の型エラー
event.target の型エラー対処
// エラー: Property 'value' does not exist on type 'EventTarget'
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// e.target.value は OK(ChangeEvent<HTMLInputElement> なので)
console.log(e.target.value);
};
// よくある間違い: イベント型のジェネリクスを忘れる
const badHandler = (e: React.ChangeEvent) => {
e.target.value; // エラー: EventTarget に value がない
};
// 対処: ジェネリクスで要素型を指定する
const goodHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
e.target.value; // OK
};
エラー対処の早見表
| エラー |
原因 |
対処法 |
not assignable to type '...(リテラル)' |
string がリテラル型に推論されない |
as const または型注釈 |
Property 'children' does not exist |
Props型にchildrenが未定義 |
ReactNodeを追加 |
Cannot find module 'react' |
型定義パッケージ未インストール |
@types/reactをインストール |
not assignable to 'never' |
useState([])でnever[]に推論 |
型引数を明示<T[]>([]) |
'value' does not exist on EventTarget |
イベント型のジェネリクス省略 |
ChangeEvent<HTMLInputElement> |
async function in useEffect |
useEffectにasync関数を直接渡した |
内部で別のasync関数を定義 |
まとめ
この記事では、React + TypeScriptの環境構築から、コンポーネント・Props・Hooks・イベント・API通信・フォーム処理・コンポーネントパターンまで、実務で必要な型定義を体系的に解説しました。
この記事のポイント
- 環境構築はVite + react-tsテンプレートが最速
- コンポーネントは通常の関数宣言が推奨(React.FCは非推奨の流れ)
- Props型はinterfaceで定義し、HTML属性はComponentPropsWithoutRefで継承
- イベントハンドラーはReact.ChangeEvent<要素型>の形式で型を指定
- useStateの空配列は型引数を必ず明示(
useState<T[]>([]))
- useRefのDOM参照はuseRef<HTML要素型>(null)で初期化
- useReducerはDiscriminated UnionでAction型を定義すると型安全
- useContextはnull初期値 + カスタムHookのパターンが安全
- カスタムHookのタプル戻り値にはas constを忘れずに
- API通信はジェネリクス関数でレスポンス型を指定
TypeScript完全ガイドシリーズ
TypeScriptを基礎から体系的に学びたい方は、以下のシリーズ記事もあわせてご覧ください。
React + TypeScriptの型定義は最初は難しく感じるかもしれませんが、パターンを覚えてしまえば一貫した書き方ができるようになります。この記事のコード例をベースに、実際のプロジェクトで型定義を実践してみてください。