はじめに#
本記事では、TypeScriptとReactを組み合わせた「型安全なコンポーネント開発」について詳しく解説します。
TypeScriptを導入することで、コンパイル時にエラーを検出でき、IDE上での補完・ナビゲーションが強化され、チーム開発における品質と生産性が大幅に向上します。React公式ドキュメントでもTypeScriptの使用が推奨されており、2026年現在のReact開発においては事実上の標準となっています。
本記事を読むことで、以下のことができるようになります。
- TypeScriptの基本的な型システムを理解する
- React.FCを使うべきか判断できるようになる
- Propsに型定義を付与し、型安全なコンポーネントを作成する
- イベントハンドラに適切な型を指定する
- useState・useRef・useContext・useReducerなどのHooksを型安全に使用する
- ジェネリクスを活用して再利用性の高いコンポーネントを設計する
実行環境・前提条件#
必要な環境#
- Node.js 20.x以上
- Viteで作成したReactプロジェクト(TypeScript)
- VS Code(推奨)
前提知識#
- 関数コンポーネントの基本
- useState・useEffectの使い方
- Propsによるデータの受け渡し
- JavaScriptの基本構文
TypeScriptの基本#
なぜTypeScriptを使うのか#
TypeScriptはJavaScriptに「型」を追加した言語です。Reactと組み合わせることで、以下のメリットが得られます。
| メリット |
説明 |
| コンパイル時のエラー検出 |
実行前に型の不整合を発見できる |
| IDEの補完強化 |
Propsやstateの候補が自動表示される |
| リファクタリングの安全性 |
変更の影響範囲を型システムが追跡する |
| ドキュメント効果 |
型定義がそのままAPIドキュメントになる |
| チーム開発の効率化 |
コンポーネントの使い方が型から明確になる |
TypeScriptの基本的な型#
Reactでよく使用する基本的な型を確認しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// プリミティブ型
const name: string = "太郎";
const age: number = 25;
const isActive: boolean = true;
// 配列型
const numbers: number[] = [1, 2, 3];
const names: string[] = ["太郎", "花子"];
// Array<T>という書き方も可能
const items: Array<string> = ["item1", "item2"];
// オブジェクト型
const user: { name: string; age: number } = {
name: "太郎",
age: 25
};
// オプショナルプロパティ(?をつけると省略可能)
const profile: { name: string; bio?: string } = {
name: "太郎"
// bioは省略可能
};
// ユニオン型(複数の型を許容)
const id: string | number = "abc123";
const status: "loading" | "success" | "error" = "success";
// null/undefinedを許容する型
const value: string | null = null;
const optionalValue: string | undefined = undefined;
|
期待される動作#
上記のコードでは、各変数に指定した型以外の値を代入しようとすると、TypeScriptがコンパイルエラーを報告します。
1
2
|
// エラー例:number型にstringを代入しようとしている
const age: number = "25"; // Type 'string' is not assignable to type 'number'
|
interfaceとtypeの違い#
オブジェクトの型を定義する方法としてinterfaceとtypeがあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// interfaceによる型定義
interface User {
name: string;
age: number;
}
// typeによる型定義
type UserType = {
name: string;
age: number;
};
// どちらも同じように使用できる
const user1: User = { name: "太郎", age: 25 };
const user2: UserType = { name: "花子", age: 22 };
|
使い分けの指針は以下のとおりです。
| 用途 |
推奨 |
理由 |
| オブジェクトの型定義 |
interface |
拡張(extends)が容易 |
| ユニオン型・交差型 |
type |
interfaceでは表現不可 |
| Propsの型定義 |
interface |
React公式ドキュメントで採用 |
| 関数の型定義 |
type |
簡潔に記述できる |
Reactコンポーネントでは、Propsの型定義にinterfaceを使用することが一般的です。
関数コンポーネントの型定義とReact.FC#
関数コンポーネントの型定義方法#
TypeScriptでReactの関数コンポーネントを定義する方法は複数あります。それぞれの違いを理解しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
interface GreetingProps {
name: string;
}
// 方法1: 引数に直接型を指定(推奨)
function Greeting({ name }: GreetingProps) {
return <div>こんにちは、{name}さん!</div>;
}
// 方法2: アロー関数 + 引数に型を指定(推奨)
const GreetingArrow = ({ name }: GreetingProps) => {
return <div>こんにちは、{name}さん!</div>;
};
// 方法3: 戻り値の型を明示
const GreetingExplicit = ({ name }: GreetingProps): React.JSX.Element => {
return <div>こんにちは、{name}さん!</div>;
};
// 方法4: React.FC(React.FunctionComponent)を使用
const GreetingFC: React.FC<GreetingProps> = ({ name }) => {
return <div>こんにちは、{name}さん!</div>;
};
|
React.FCを使うべきか#
React.FC(React.FunctionComponentの略)は、関数コンポーネントの型として長らく使用されてきましたが、現在は使用しないことが推奨されています。
| 観点 |
React.FCを使う場合 |
直接型を指定する場合 |
| 暗黙のchildren |
React 18以降はなし |
なし |
| ジェネリクス |
複雑になる |
シンプル |
| 冗長さ |
やや冗長 |
簡潔 |
| 公式の推奨 |
非推奨 |
推奨 |
React公式ドキュメントおよびReact TypeScript Cheatsheetでは、React.FCを使わずに引数に直接Propsの型を指定する方法が推奨されています。
1
2
3
4
5
6
7
8
9
|
// 非推奨: React.FCを使用
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
// 推奨: 引数に直接型を指定
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
|
React.FCを使う場面#
とはいえ、React.FCが完全に不要というわけではありません。以下のような場面では使用を検討できます。
1
2
3
4
5
6
7
8
|
// displayNameを設定する場合
const MemoizedComponent: React.FC<Props> = React.memo(({ title }) => {
return <h1>{title}</h1>;
});
MemoizedComponent.displayName = "MemoizedComponent";
// 既存のコードベースで統一されている場合
// チームのコーディング規約に従う
|
本記事では、React公式の推奨に従い、引数に直接型を指定する方法を採用します。
Propsの型定義#
基本的なPropsの型定義#
TypeScriptでReactコンポーネントを作成する際、Propsの型を定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// Propsの型を定義
interface GreetingProps {
name: string;
age: number;
}
// 関数コンポーネントにPropsの型を指定
function Greeting({ name, age }: GreetingProps) {
return (
<div>
<h1>こんにちは、{name}さん!</h1>
<p>年齢: {age}歳</p>
</div>
);
}
// 使用例
function App() {
return <Greeting name="太郎" age={25} />;
}
|
期待される結果#
型定義により、以下の恩恵が得られます。
1
2
3
4
5
6
7
8
9
10
|
// 正しい使用方法
<Greeting name="太郎" age={25} />
// コンパイルエラー:必須のPropsが不足
<Greeting name="太郎" />
// Property 'age' is missing in type '{ name: string; }'
// コンパイルエラー:型が一致しない
<Greeting name="太郎" age="25" />
// Type 'string' is not assignable to type 'number'
|
オプショナルなPropsとデフォルト値#
省略可能なPropsには?を付けて定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
interface ButtonProps {
label: string;
variant?: "primary" | "secondary" | "danger"; // 省略可能
disabled?: boolean; // 省略可能
}
function Button({
label,
variant = "primary", // デフォルト値
disabled = false
}: ButtonProps) {
const className = `btn btn-${variant}`;
return (
<button className={className} disabled={disabled}>
{label}
</button>
);
}
// 使用例
function App() {
return (
<div>
<Button label="送信" />
<Button label="キャンセル" variant="secondary" />
<Button label="削除" variant="danger" disabled />
</div>
);
}
|
childrenの型定義#
childrenを受け取るコンポーネントの型定義方法です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import { ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode; // あらゆるReact要素を受け取れる
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2 className="card-title">{title}</h2>
<div className="card-content">{children}</div>
</div>
);
}
// 使用例
function App() {
return (
<Card title="ユーザー情報">
<p>名前: 太郎</p>
<p>年齢: 25歳</p>
</Card>
);
}
|
ReactNodeは以下の型を含む広い型です。
- JSX要素(
<div>...</div>)
- 文字列・数値
null・undefined
- 配列
- フラグメント
より厳密に型を制限したい場合はReactElementを使用します。
1
2
3
4
5
6
|
import { ReactElement } from "react";
interface StrictCardProps {
title: string;
children: ReactElement; // JSX要素のみ受け取る
}
|
PropsWithChildrenの活用#
React 18以降では、PropsWithChildrenユーティリティ型を使用することもできます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import { PropsWithChildren } from "react";
interface ContainerProps {
maxWidth: number;
}
// PropsWithChildrenでchildrenを自動追加
function Container({ maxWidth, children }: PropsWithChildren<ContainerProps>) {
return (
<div style={{ maxWidth: `${maxWidth}px`, margin: "0 auto" }}>
{children}
</div>
);
}
|
イベントハンドラの型定義#
クリックイベント#
ボタンのクリックイベントにはReact.MouseEventを使用します。
1
2
3
4
5
6
7
8
9
|
function ClickButton() {
// イベントハンドラを別関数として定義する場合
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
console.log("クリックされました");
console.log("ボタンのテキスト:", event.currentTarget.textContent);
};
return <button onClick={handleClick}>クリック</button>;
}
|
入力イベント#
フォーム入力にはReact.ChangeEventを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import { useState, ChangeEvent } from "react";
function TextInput() {
const [value, setValue] = useState("");
// ChangeEvent<HTMLInputElement>で型を指定
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
return (
<div>
<input
type="text"
value={value}
onChange={handleChange}
placeholder="入力してください"
/>
<p>入力値: {value}</p>
</div>
);
}
|
フォーム送信イベント#
フォームの送信にはReact.FormEventを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import { useState, FormEvent } from "react";
interface FormData {
email: string;
password: string;
}
function LoginForm() {
const [formData, setFormData] = useState<FormData>({
email: "",
password: ""
});
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log("送信データ:", formData);
// APIへの送信処理など
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
/>
</div>
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
/>
</div>
<button type="submit">ログイン</button>
</form>
);
}
|
主要なイベント型一覧#
| イベント |
型 |
用途 |
| onClick |
React.MouseEvent<HTMLElement> |
クリック |
| onChange |
React.ChangeEvent<HTMLInputElement> |
入力変更 |
| onSubmit |
React.FormEvent<HTMLFormElement> |
フォーム送信 |
| onKeyDown |
React.KeyboardEvent<HTMLElement> |
キー押下 |
| onFocus |
React.FocusEvent<HTMLElement> |
フォーカス取得 |
| onBlur |
React.FocusEvent<HTMLElement> |
フォーカス喪失 |
イベントハンドラ型の簡略記法#
React.ChangeEventHandlerなどの型を使用すると、より簡潔に書けます。
1
2
3
4
5
6
7
8
9
10
11
12
|
import { ChangeEventHandler } from "react";
function SimpleInput() {
const [value, setValue] = useState("");
// ChangeEventHandler<HTMLInputElement>を使用
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setValue(event.target.value);
};
return <input value={value} onChange={handleChange} />;
}
|
Hooksの型定義#
useStateの型定義#
useStateは初期値から型を推論しますが、明示的に型を指定することも可能です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import { useState } from "react";
function Counter() {
// 型推論:number型として推論される
const [count, setCount] = useState(0);
// 明示的な型指定
const [name, setName] = useState<string>("");
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
|
初期値がnullや複数の型を取りうる場合は、ユニオン型を指定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
// null または User型
const [user, setUser] = useState<User | null>(null);
// ユニオン型でステータスを管理
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
// userがnullの場合のガード
if (!user) {
return <p>ユーザー情報がありません</p>;
}
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
|
useRefの型定義#
useRefは参照する対象によって型を指定します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import { useRef, useEffect } from "react";
function AutoFocusInput() {
// DOM要素を参照する場合
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// inputRef.currentはnullの可能性があるためチェック
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="自動フォーカス" />;
}
|
再レンダリングを伴わない値を保持する場合の型定義です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import { useRef, useEffect, useState } from "react";
function Timer() {
const [seconds, setSeconds] = useState(0);
// number型を保持(初期値をnullにしない場合)
const intervalRef = useRef<number | null>(null);
const start = () => {
if (intervalRef.current !== null) return;
intervalRef.current = window.setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
return () => stop(); // クリーンアップ
}, []);
return (
<div>
<p>経過時間: {seconds}秒</p>
<button onClick={start}>開始</button>
<button onClick={stop}>停止</button>
</div>
);
}
|
useContextの型定義#
useContextは、Context APIを使ってコンポーネントツリー全体でデータを共有する際に使用します。TypeScriptで型安全にContextを扱う方法を解説します。
基本的なuseContextの型定義#
createContextに型引数を渡すことで、Contextの値の型を定義できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { createContext, useContext, useState, ReactNode } from "react";
// Contextで共有する値の型を定義
type Theme = "light" | "dark" | "system";
// createContextに型引数を渡す
const ThemeContext = createContext<Theme>("system");
// カスタムフックでContextを利用
function useTheme() {
return useContext(ThemeContext);
}
// Providerコンポーネント
interface ThemeProviderProps {
children: ReactNode;
}
function ThemeProvider({ children }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
// 使用例
function ThemeDisplay() {
const theme = useTheme();
return <p>現在のテーマ: {theme}</p>;
}
|
nullを含むContextの型定義#
初期値として適切なデフォルト値がない場合、nullを使用することがあります。この場合、型ガードを使って安全にアクセスする必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
|
import { createContext, useContext, useState, useMemo, ReactNode } from "react";
// Contextで共有するオブジェクトの型
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
interface User {
id: number;
name: string;
email: string;
}
// 初期値をnullに設定(型は AuthContextType | null)
const AuthContext = createContext<AuthContextType | null>(null);
// カスタムフック(nullチェックを含む)
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
// Providerコンポーネント
interface AuthProviderProps {
children: ReactNode;
}
function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const value = useMemo<AuthContextType>(() => ({
user,
isAuthenticated: user !== null,
login: async (email: string, password: string) => {
// APIへのログイン処理
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
const userData: User = await response.json();
setUser(userData);
},
logout: () => {
setUser(null);
},
}), [user]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 使用例
function UserProfile() {
const { user, isAuthenticated, logout } = useAuth();
if (!isAuthenticated) {
return <p>ログインしてください</p>;
}
return (
<div>
<h2>{user?.name}</h2>
<p>{user?.email}</p>
<button onClick={logout}>ログアウト</button>
</div>
);
}
|
複数の値を持つContext#
状態と更新関数の両方をContextで共有する場合のパターンです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
import { createContext, useContext, useState, ReactNode, Dispatch, SetStateAction } from "react";
interface Todo {
id: number;
text: string;
completed: boolean;
}
// Contextの型定義
interface TodoContextType {
todos: Todo[];
setTodos: Dispatch<SetStateAction<Todo[]>>;
addTodo: (text: string) => void;
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
}
const TodoContext = createContext<TodoContextType | null>(null);
function useTodos(): TodoContextType {
const context = useContext(TodoContext);
if (!context) {
throw new Error("useTodos must be used within a TodoProvider");
}
return context;
}
function TodoProvider({ children }: { children: ReactNode }) {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
setTodos(prev => [
...prev,
{ id: Date.now(), text, completed: false }
]);
};
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
return (
<TodoContext.Provider value={{ todos, setTodos, addTodo, toggleTodo, deleteTodo }}>
{children}
</TodoContext.Provider>
);
}
// 使用例
function TodoList() {
const { todos, toggleTodo, deleteTodo } = useTodos();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>削除</button>
</li>
))}
</ul>
);
}
|
useReducerの型定義#
複雑な状態管理にはuseReducerと型定義の組み合わせが効果的です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
import { useReducer } from "react";
// State型の定義
interface TodoState {
todos: Todo[];
filter: "all" | "active" | "completed";
}
interface Todo {
id: number;
text: string;
completed: boolean;
}
// Action型の定義(ユニオン型で全アクションを表現)
type TodoAction =
| { type: "ADD_TODO"; payload: string }
| { type: "TOGGLE_TODO"; payload: number }
| { type: "DELETE_TODO"; payload: number }
| { type: "SET_FILTER"; payload: "all" | "active" | "completed" };
// 初期状態
const initialState: TodoState = {
todos: [],
filter: "all"
};
// Reducer関数
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload,
completed: false
}
]
};
case "TOGGLE_TODO":
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":
return {
...state,
filter: action.payload
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const handleAddTodo = (text: string) => {
dispatch({ type: "ADD_TODO", payload: text });
};
const handleToggle = (id: number) => {
dispatch({ type: "TOGGLE_TODO", payload: id });
};
const filteredTodos = state.todos.filter(todo => {
if (state.filter === "active") return !todo.completed;
if (state.filter === "completed") return todo.completed;
return true;
});
return (
<div>
<h1>Todoリスト</h1>
<TodoInput onAdd={handleAddTodo} />
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span style={{
textDecoration: todo.completed ? "line-through" : "none"
}}>
{todo.text}
</span>
</li>
))}
</ul>
</div>
);
}
interface TodoInputProps {
onAdd: (text: string) => void;
}
function TodoInput({ onAdd }: TodoInputProps) {
const [text, setText] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) {
onAdd(text);
setText("");
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="新しいTodoを入力"
/>
<button type="submit">追加</button>
</form>
);
}
|
ジェネリクスを活用したコンポーネント#
ジェネリクスとは#
ジェネリクス(Generics)は、型をパラメータとして受け取る仕組みです。再利用可能なコンポーネントを作成する際に威力を発揮します。
1
2
3
4
5
6
7
8
9
|
// ジェネリックな関数の例
function identity<T>(value: T): T {
return value;
}
// 使用時に型が決定される
const num = identity<number>(42); // T = number
const str = identity<string>("hello"); // T = string
const auto = identity(true); // T = boolean(型推論)
|
ジェネリックなリストコンポーネント#
任意の型のアイテムを表示するリストコンポーネントを作成します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
|
interface ListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// 使用例1: ユーザーリスト
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const users: User[] = [
{ id: 1, name: "太郎", email: "taro@example.com" },
{ id: 2, name: "花子", email: "hanako@example.com" }
];
return (
<List
items={users}
keyExtractor={user => user.id}
renderItem={user => (
<div>
<strong>{user.name}</strong> - {user.email}
</div>
)}
/>
);
}
// 使用例2: 商品リスト
interface Product {
sku: string;
name: string;
price: number;
}
function ProductList() {
const products: Product[] = [
{ sku: "A001", name: "商品A", price: 1000 },
{ sku: "B002", name: "商品B", price: 2000 }
];
return (
<List
items={products}
keyExtractor={product => product.sku}
renderItem={product => (
<div>
{product.name}: ¥{product.price.toLocaleString()}
</div>
)}
/>
);
}
|
ジェネリックなSelectコンポーネント#
選択肢を型安全に扱うSelectコンポーネントの例です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
placeholder?: string;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
placeholder = "選択してください"
}: SelectProps<T>) {
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
const selectedOption = options.find(
option => String(getValue(option)) === selectedValue
);
if (selectedOption) {
onChange(selectedOption);
}
};
return (
<select
value={value ? String(getValue(value)) : ""}
onChange={handleChange}
>
<option value="" disabled>
{placeholder}
</option>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// 使用例
interface Country {
code: string;
name: string;
}
function CountrySelect() {
const [selected, setSelected] = useState<Country | null>(null);
const countries: Country[] = [
{ code: "JP", name: "日本" },
{ code: "US", name: "アメリカ" },
{ code: "GB", name: "イギリス" }
];
return (
<div>
<Select
options={countries}
value={selected}
onChange={setSelected}
getLabel={country => country.name}
getValue={country => country.code}
placeholder="国を選択"
/>
{selected && <p>選択された国: {selected.name}</p>}
</div>
);
}
|
実践的なコンポーネント設計#
APIレスポンスの型定義#
外部APIからデータを取得する際の型定義パターンです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
// APIレスポンスの型定義
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
// カスタムフックで型を活用
function usePosts() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchPosts = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts"
);
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
const data: Post[] = await response.json();
setPosts(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"));
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
return { posts, loading, error };
}
function PostList() {
const { posts, loading, error } = usePosts();
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラー: {error.message}</p>;
return (
<ul>
{posts.slice(0, 5).map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}
|
共通の型定義ファイル#
プロジェクト全体で使用する型は、専用のファイルにまとめます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
// src/types/index.ts
// ユーザー関連
export interface User {
id: number;
name: string;
email: string;
role: UserRole;
}
export type UserRole = "admin" | "editor" | "viewer";
// APIレスポンス
export interface ApiResponse<T> {
data: T;
status: "success" | "error";
message?: string;
}
// ページネーション
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
perPage: number;
totalPages: number;
}
// フォーム状態
export interface FormState<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
isSubmitting: boolean;
isValid: boolean;
}
|
コンポーネントでの型のインポート#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// src/components/UserCard.tsx
import type { User } from "../types";
interface UserCardProps {
user: User;
onEdit?: (user: User) => void;
onDelete?: (userId: number) => void;
}
export function UserCard({ user, onEdit, onDelete }: UserCardProps) {
return (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<span className={`role-badge role-${user.role}`}>{user.role}</span>
{onEdit && (
<button onClick={() => onEdit(user)}>編集</button>
)}
{onDelete && (
<button onClick={() => onDelete(user.id)}>削除</button>
)}
</div>
);
}
|
import typeを使用すると、型のみをインポートすることを明示でき、バンドルサイズに影響を与えません。
よくあるエラーと解決策#
1. Object is possibly ’null'#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// エラーが発生するコード
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current.focus(); // Object is possibly 'null'
// 解決策1: オプショナルチェイニング
inputRef.current?.focus();
// 解決策2: nullチェック
if (inputRef.current) {
inputRef.current.focus();
}
// 解決策3: 非nullアサーション(確実にnullでない場合のみ)
inputRef.current!.focus();
|
2. Property ‘xxx’ does not exist on type#
1
2
3
4
5
6
7
8
9
10
11
12
|
// エラーが発生するコード
interface Props {
name: string;
}
function Component({ name, age }: Props) {} // 'age' does not exist
// 解決策: 型定義に追加
interface Props {
name: string;
age: number;
}
|
3. Type ‘xxx’ is not assignable to type ‘yyy’#
1
2
3
4
5
6
|
// エラーが発生するコード
const [status, setStatus] = useState<"loading" | "success">("idle");
// Type '"idle"' is not assignable to type '"loading" | "success"'
// 解決策: ユニオン型に追加
const [status, setStatus] = useState<"idle" | "loading" | "success">("idle");
|
4. イベントハンドラの型推論がうまくいかない#
1
2
3
4
5
6
7
8
|
// インラインで書く場合は型推論が効く
<input onChange={(e) => setValue(e.target.value)} />
// 別関数として定義する場合は明示的な型が必要
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
<input onChange={handleChange} />
|
まとめ#
本記事では、TypeScriptとReactを組み合わせた型安全なコンポーネント開発について解説しました。
学んだ内容を振り返ります。
- TypeScriptの基本型: プリミティブ型、配列型、オブジェクト型、ユニオン型を理解した
- React.FCの是非: React.FCは現在非推奨であり、引数に直接型を指定する方法が推奨されることを理解した
- Propsの型定義: interface/typeを使ってPropsに型を付与し、childrenやオプショナルPropsを定義できるようになった
- イベントハンドラの型: MouseEvent、ChangeEvent、FormEventなどの適切な型を指定できるようになった
- Hooksの型定義: useState、useRef、useContext、useReducerで型安全にHooksを使用できるようになった
- ジェネリクス: 型パラメータを使って再利用可能なコンポーネントを設計できるようになった
TypeScriptを活用することで、コンパイル時にエラーを発見でき、IDEの補完が強化され、チーム開発における品質と効率が大幅に向上します。
参考リンク#