はじめに

本記事では、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の違い

オブジェクトの型を定義する方法としてinterfacetypeがあります。

 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.FCReact.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>
  • 文字列・数値
  • nullundefined
  • 配列
  • フラグメント

より厳密に型を制限したい場合は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の補完が強化され、チーム開発における品質と効率が大幅に向上します。

参考リンク