Chapter 07

React + TypeScript 基礎

コンポーネントの Props・State・イベントに型を付ける方法を学びます

40 min

このチャプターで学ぶこと

  • コンポーネントの Props に型を定義できる
  • children の型(React.ReactNode)を理解できる
  • イベントハンドラの型を正しく付けられる
  • useState に型パラメータを指定できる

React + TypeScript 基礎

React コースの Chapter 3 で Props を学びましたが、今度は型安全にしていきましょう。TypeScript を使うと、コンポーネントに渡す Props の型が間違っていればエディタがすぐに教えてくれます。「実行してみたら Props 名をタイポしていた」「数値を渡すべきところに文字列を渡していた」といったミスが劇的に減ります。

コンポーネントの Props に型を定義する

JavaScript の React コンポーネントでは、Props の型はドキュメントやコメントを読まないとわかりませんでした。TypeScript では interface を使って Props の型を明示的に定義します。

// Props の型を interface で定義
interface ProfileProps {
  name: string;
  age: number;
  bio?: string; // ? はオプショナル(省略可能)
}

// 関数の引数に Props の型を指定
function Profile({ name, age, bio }: ProfileProps) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{age}</p>
      {bio && <p>{bio}</p>}
    </div>
  );
}

// 使う側:型が間違っていればエラーになる
function App() {
  return (
    <div>
      {/* OK */}
      <Profile name="山田太郎" age={25} bio="エンジニアです" />

      {/* OK:bio はオプショナルなので省略可能 */}
      <Profile name="田中花子" age={30} />

      {/* エラー!age に文字列を渡している */}
      {/* <Profile name="鈴木一郎" age="28" /> */}

      {/* エラー!必須の name が抜けている */}
      {/* <Profile age={22} /> */}
    </div>
  );
}
📝 Laravelで例えると

Laravel のフォームリクエストで rules() にバリデーションルールを書くのと似ています。Props の型定義は「このコンポーネントにはこの形式のデータを渡してください」という契約(コントラクト)です。違反すればコンパイル時にエラーになります。

Props の命名規則

Props の interface には コンポーネント名 + Props という名前をつけるのが慣習です。

// コンポーネント名 + Props
interface ButtonProps { /* ... */ }
interface UserCardProps { /* ... */ }
interface SidebarProps { /* ... */ }

React.FC vs 明示的な関数型

React コンポーネントの型付けには2つのスタイルがあります。

// スタイル1:React.FC を使う(非推奨)
const Profile: React.FC<ProfileProps> = ({ name, age }) => {
  return <div>{name}</div>;
};

// スタイル2:引数に直接型を指定する(推奨)
function Profile({ name, age }: ProfileProps) {
  return <div>{name}</div>;
}

スタイル2(明示的な関数型)を推奨します。 理由は以下の通りです。

💡 ヒント

現在のReact + TypeScript のベストプラクティスでは、React.FC を使わず通常の関数で型を指定する方法が主流です。新しいプロジェクトではスタイル2を使いましょう。

デフォルト値付きの Props

JavaScript と同じように、分割代入のデフォルト値を使えます。

interface GreetingProps {
  name: string;
  greeting?: string; // オプショナル
}

function Greeting({ name, greeting = "こんにちは" }: GreetingProps) {
  return <p>{greeting}{name}さん!</p>;
}

// 使い方
<Greeting name="山田" />                    // 「こんにちは、山田さん!」
<Greeting name="田中" greeting="おはよう" /> // 「おはよう、田中さん!」

children の型:React.ReactNode

children を受け取るコンポーネントでは、React.ReactNode 型を使います。

interface CardProps {
  title: string;
  children: React.ReactNode; // JSX、文字列、数値、null など何でも受け取れる
}

function Card({ title, children }: CardProps) {
  return (
    <div style={{
      border: "1px solid #e2e8f0",
      borderRadius: "8px",
      padding: "1.5rem",
      marginBottom: "1rem",
    }}>
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      {children}
    </div>
  );
}

// 使い方
function App() {
  return (
    <Card title="お知らせ">
      <p>TypeScriptコースが始まりました!</p>
      <ul>
        <li>全8チャプター</li>
        <li>演習問題付き</li>
      </ul>
    </Card>
  );
}

React.ReactNode vs React.ReactElement

// React.ReactNode — もっとも広い型(推奨)
// JSX要素、文字列、数値、null、undefined、boolean、配列を含む
children: React.ReactNode;

// React.ReactElement — JSX要素のみ(文字列や数値はNG)
children: React.ReactElement;

// string — 文字列のみ
children: string;

ほとんどの場合は React.ReactNode を使えば問題ありません。

⚠️ 注意

children を受け取るつもりがないコンポーネントでは、children を Props の型に含めないでください。TypeScript が「このコンポーネントに children を渡してはいけない」というエラーを出してくれるので、誤った使い方を防げます。

イベントハンドラの型

React コースでイベントハンドリングを学びましたが、TypeScript ではイベントオブジェクトにも型を付けます。

クリックイベント

function Button() {
  // イベントハンドラの型を明示する場合
  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    console.log("クリック位置:", event.clientX, event.clientY);
    console.log("ボタン要素:", event.currentTarget.textContent);
  };

  return <button onClick={handleClick}>クリック</button>;
}

React.MouseEvent<HTMLButtonElement>HTMLButtonElement は、イベントが発生する要素の型を指定しています。<div> なら HTMLDivElement<a> なら HTMLAnchorElement です。

入力イベント

フォーム入力のイベントは React.ChangeEvent を使います。

function SearchBox() {
  const [query, setQuery] = useState("");

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(event.target.value);
  };

  return (
    <input
      type="text"
      value={query}
      onChange={handleChange}
      placeholder="検索..."
    />
  );
}

フォーム送信イベント

function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault(); // ページ遷移を防ぐ
    console.log("ログイン:", { email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
        placeholder="メールアドレス"
      />
      <input
        type="password"
        value={password}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
        placeholder="パスワード"
      />
      <button type="submit">ログイン</button>
    </form>
  );
}

インラインのイベントハンドラでは型推論が効く

JSX のイベント属性に直接関数を書く場合、TypeScript がイベントの型を自動推論してくれます。

function App() {
  return (
    <div>
      {/* インラインでは型注釈が不要(自動推論される) */}
      <button onClick={(e) => {
        // e は自動的に React.MouseEvent<HTMLButtonElement> と推論される
        console.log(e.currentTarget);
      }}>
        クリック
      </button>

      <input onChange={(e) => {
        // e は自動的に React.ChangeEvent<HTMLInputElement> と推論される
        console.log(e.target.value);
      }} />
    </div>
  );
}
💡 ヒント

インラインで書く場合は型推論に任せて、別の変数に切り出す場合は明示的に型を付ける、というのが実用的なアプローチです。

よく使うイベント型の一覧

// マウスイベント
onClick:   (e: React.MouseEvent<HTMLElement>) => void
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => void

// フォームイベント
onChange:  (e: React.ChangeEvent<HTMLInputElement>) => void
onSubmit:  (e: React.FormEvent<HTMLFormElement>) => void

// キーボードイベント
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void

// フォーカスイベント
onFocus:   (e: React.FocusEvent<HTMLElement>) => void
onBlur:    (e: React.FocusEvent<HTMLElement>) => void

コールバック Props の型

親コンポーネントから子コンポーネントに関数を渡すパターンです。

interface TodoItemProps {
  id: number;
  text: string;
  completed: boolean;
  onToggle: (id: number) => void;    // コールバック関数の型
  onDelete: (id: number) => void;    // 戻り値は void
}

function TodoItem({ id, text, completed, onToggle, onDelete }: TodoItemProps) {
  return (
    <li style={{ textDecoration: completed ? "line-through" : "none" }}>
      <input
        type="checkbox"
        checked={completed}
        onChange={() => onToggle(id)}
      />
      <span>{text}</span>
      <button onClick={() => onDelete(id)}>削除</button>
    </li>
  );
}

// 親コンポーネントでの使い方
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: "TypeScriptを学ぶ", completed: false },
    { id: 2, text: "Reactに型をつける", completed: false },
  ]);

  const handleToggle = (id: number) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const handleDelete = (id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          {...todo}
          onToggle={handleToggle}
          onDelete={handleDelete}
        />
      ))}
    </ul>
  );
}

useState に型パラメータを指定する

React コースで学んだ useState をTypeScript で使うとき、型パラメータの指定が重要になります。

型推論で十分なケース

初期値から型が明らかな場合、型パラメータの指定は不要です。

// TypeScript が自動で型を推論する
const [count, setCount] = useState(0);           // number
const [name, setName] = useState("ゲスト");       // string
const [isOpen, setIsOpen] = useState(false);      // boolean
const [items, setItems] = useState(["React"]);    // string[]

型パラメータが必要なケース

初期値だけでは型が決まらない場合に、明示的に指定します。

interface User {
  id: number;
  name: string;
  email: string;
}

// 初期値が null だが、後で User オブジェクトが入る
const [user, setUser] = useState<User | null>(null);

// 空配列の初期値だけでは要素の型がわからない
const [users, setUsers] = useState<User[]>([]);

// ユニオン型の State
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");
⚠️ 注意

useState(null) だけだと TypeScript は null 型と推論してしまい、setUser({ id: 1, ... }) のように値を設定するときにエラーになります。useState<User | null>(null) のように明示的に指定しましょう。

よくあるパターン:APIデータの取得

interface Article {
  id: number;
  title: string;
  body: string;
  author: string;
}

function ArticleList() {
  // 初期値は空配列だが、Article[] 型を明示
  const [articles, setArticles] = useState<Article[]>([]);
  const [loading, setLoading] = useState(true);    // boolean は推論で十分
  const [error, setError] = useState<string | null>(null);

  // データ取得の処理(useEffect は次のチャプターで詳しく扱います)
  useEffect(() => {
    fetch("/api/articles")
      .then(res => res.json())
      .then((data: Article[]) => {
        setArticles(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;

  return (
    <ul>
      {articles.map(article => (
        <li key={article.id}>
          <h3>{article.title}</h3>
          <p>{article.body}</p>
          <small>著者: {article.author}</small>
        </li>
      ))}
    </ul>
  );
}

スタイルオブジェクトの型:React.CSSProperties

インラインスタイルに型をつける場合は React.CSSProperties を使います。

interface BadgeProps {
  label: string;
  color?: string;
  style?: React.CSSProperties; // スタイルオブジェクトの型
}

function Badge({ label, color = "#2563eb", style }: BadgeProps) {
  // 基本スタイルを定義(型は自動推論される)
  const baseStyle: React.CSSProperties = {
    display: "inline-block",
    padding: "0.25rem 0.75rem",
    borderRadius: "999px",
    fontSize: "0.875rem",
    fontWeight: "bold",
    color: "white",
    backgroundColor: color,
  };

  return (
    <span style={{ ...baseStyle, ...style }}>
      {label}
    </span>
  );
}

// 使い方
<Badge label="TypeScript" />
<Badge label="React" color="#61dafb" style={{ marginLeft: "0.5rem" }} />

React.CSSProperties を使うと、存在しないCSSプロパティ名やタイポをコンパイル時に検出できます。

ユニオン型を使った Props パターン

Props にユニオン型を使うと、限定された値のみを受け付けるコンポーネントが作れます。

interface AlertProps {
  type: "success" | "error" | "warning" | "info";
  title: string;
  children: React.ReactNode;
}

function Alert({ type, title, children }: AlertProps) {
  // 型に応じた色を定義
  const colors: Record<AlertProps["type"], string> = {
    success: "#10b981",
    error: "#ef4444",
    warning: "#f59e0b",
    info: "#3b82f6",
  };

  return (
    <div style={{
      padding: "1rem",
      borderLeft: `4px solid ${colors[type]}`,
      backgroundColor: `${colors[type]}10`,
      borderRadius: "4px",
      marginBottom: "1rem",
    }}>
      <strong>{title}</strong>
      <div>{children}</div>
    </div>
  );
}

// 使い方
<Alert type="success" title="保存完了">データが正常に保存されました。</Alert>
<Alert type="error" title="エラー">入力内容に問題があります。</Alert>

// エラー!"danger" は許可されていない
// <Alert type="danger" title="NG">...</Alert>
💡 ヒント

Record<AlertProps["type"], string> のように、Props の型から値を取り出して他の型定義に使えます。型の一元管理ができるので、新しい type を追加したときに colors の定義漏れをコンパイラが検出してくれます。

✍ やってみよう:Profile コンポーネントを TypeScript 化する

React コースで作った Profile コンポーネントを TypeScript に変換しましょう。

ステップ 1: Props のインターフェースを定義する

interface ProfileProps {
  name: string;
  bio: string;
  imageUrl?: string;           // オプショナル
  hobbies?: string[];          // オプショナル
  onContact?: (name: string) => void; // コールバック(オプショナル)
}

ステップ 2: コンポーネントに型を適用する

function Profile({ name, bio, imageUrl, hobbies = [], onContact }: ProfileProps) {
  // クリックイベントの型
  const handleContactClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    onContact?.(name); // オプショナルチェーンでコールバックを呼ぶ
  };

  return (
    <section style={{ display: "flex", gap: "1.5rem", padding: "1.5rem" }}>
      {imageUrl && (
        <img
          src={imageUrl}
          alt={`${name}のプロフィール写真`}
          style={{
            width: "80px",
            height: "80px",
            borderRadius: "50%",
            objectFit: "cover",
          }}
        />
      )}
      <div>
        <h2 style={{ margin: "0 0 0.5rem" }}>{name}</h2>
        <p style={{ margin: "0 0 0.75rem", color: "#666" }}>{bio}</p>
        {hobbies.length > 0 && (
          <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
            {hobbies.map((hobby) => (
              <span
                key={hobby}
                style={{
                  background: "#eff6ff",
                  color: "#2563eb",
                  padding: "0.2rem 0.75rem",
                  borderRadius: "999px",
                  fontSize: "0.875rem",
                }}
              >
                {hobby}
              </span>
            ))}
          </div>
        )}
        {onContact && (
          <button onClick={handleContactClick} style={{ marginTop: "0.75rem" }}>
            連絡する
          </button>
        )}
      </div>
    </section>
  );
}

ステップ 3: 親コンポーネントで使う

function App() {
  const handleContact = (name: string) => {
    alert(`${name}さんに連絡を送りました!`);
  };

  return (
    <div>
      <Profile
        name="山田太郎"
        bio="バックエンドエンジニア。Laravel歴5年。"
        imageUrl="https://i.pravatar.cc/150?img=1"
        hobbies={["Laravel", "TypeScript", "Docker"]}
        onContact={handleContact}
      />
      <Profile
        name="田中花子"
        bio="フロントエンドエンジニア。"
        hobbies={["React", "Figma"]}
      />
    </div>
  );
}

発展課題: Badge コンポーネントを作って、趣味タグの表示を切り出してみましょう。BadgeProps を定義して、labelcolor を Props として受け取れるようにしてください。

まとめ

次のチャプターでは、useRefuseContextuseReducer やカスタムフックの型定義を学びます。