React - 第4章:TypeScript + React

React - TypeScript + React

ReactコンポーネントでTypeScriptを使いこなそう

TypeScript + React - 実践的な型定義

ReactとTypeScriptを組み合わせると、より安全で保守しやすいコードが書けます。この章では、実際の開発でよく使うパターンを学びます。


Propsの型定義

基本形

interface ButtonProps {
  label: string;
  onClick: () => void;
}

function Button({ label, onClick }: ButtonProps) {
  return <button onClick={onClick}>{label}</button>;
}

// 使用例
<Button label="クリック" onClick={() => alert('クリックされました')} />

オプショナルなProps

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;  // オプショナル
  variant?: 'primary' | 'secondary';  // オプショナル
}

function Button({ 
  label, 
  onClick, 
  disabled = false,  // デフォルト値
  variant = 'primary'  // デフォルト値
}: ButtonProps) {
  return (
    <button 
      onClick={onClick} 
      disabled={disabled}
      className={variant}
    >
      {label}
    </button>
  );
}

childrenの型

interface CardProps {
  title: string;
  children: React.ReactNode;  // 任意のReact要素
}

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div>{children}</div>
    </div>
  );
}

// 使用例
<Card title="カードタイトル">
  <p>これは子要素です</p>
  <button>ボタン</button>
</Card>

実践例:複雑なProps

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

interface UserCardProps {
  user: User;
  onEdit?: (user: User) => void;
  onDelete?: (userId: number) => void;
  showActions?: boolean;
}

function UserCard({ 
  user, 
  onEdit, 
  onDelete, 
  showActions = true 
}: UserCardProps) {
  return (
    <div className="user-card">
      {user.avatar && <img src={user.avatar} alt={user.name} />}
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      {showActions && (
        <div>
          {onEdit && <button onClick={() => onEdit(user)}>編集</button>}
          {onDelete && <button onClick={() => onDelete(user.id)}>削除</button>}
        </div>
      )}
    </div>
  );
}

Stateの型定義

プリミティブ型

const [count, setCount] = useState<number>(0);
const [name, setName] = useState<string>("");
const [isOpen, setIsOpen] = useState<boolean>(false);

注意: 初期値から型推論できる場合、型アノテーションは省略できます。

const [count, setCount] = useState(0);  // number型と推論される

null許容型

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

function UserProfile() {
  const [user, setUser] = useState<User | null>(null);
  
  useEffect(() => {
    // APIからユーザーデータを取得
    fetch('/api/user')
      .then(res => res.json())
      .then(data => setUser(data));
  }, []);
  
  if (!user) {
    return <p>読み込み中...</p>;
  }
  
  return (
    <div>
      <h1>{user.name}</h1>
    </div>
  );
}

配列型とオブジェクト型

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");
  
  const addTodo = () => {
    const newTodo: Todo = {
      id: Date.now(),
      text: input,
      completed: false
    };
    setTodos([...todos, newTodo]);
    setInput("");
  };
  
  const toggleTodo = (id: number) => {
    setTodos(todos.map(todo => 
      todo.id === id 
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };
  
  return (
    <div>
      <input 
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <button onClick={addTodo}>追加</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input 
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

イベントハンドラの型

一般的なイベント型

// クリックイベント
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  console.log('クリックされました');
};

// 入力イベント
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

// フォーム送信イベント
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  console.log('送信されました');
};

// キーボードイベント
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') {
    console.log('Enterが押されました');
  }
};

実践例:フォーム

interface FormData {
  email: string;
  password: string;
}

function LoginForm() {
  const [formData, setFormData] = useState<FormData>({
    email: "",
    password: ""
  });
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData({ ...formData, [name]: value });
  };
  
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log('ログイン:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        name="email"
        type="email"
        value={formData.email}
        onChange={handleChange}
      />
      <input 
        name="password"
        type="password"
        value={formData.password}
        onChange={handleChange}
      />
      <button type="submit">ログイン</button>
    </form>
  );
}

カスタムフックの型定義

カスタムフックは、ロジックを再利用するための関数です。TypeScriptで型をつけることで、使う側が安全に使えます。

基本的なカスタムフック

// ローカルストレージを使うフック
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue] as const;
}

// 使用例
function App() {
  const [name, setName] = useLocalStorage<string>("name", "");
  const [count, setCount] = useLocalStorage<number>("count", 0);
  
  return (
    <div>
      <input 
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}

ポイント: as const を使うと、タプル型として返せます。

データ取得フック

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading, error };
}

// 使用例
interface User {
  id: number;
  name: string;
}

function UserList() {
  const { data, loading, error } = useFetch<User[]>('/api/users');
  
  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error.message}</p>;
  if (!data) return <p>データがありません</p>;
  
  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Contextの型定義

Contextは、コンポーネント間でデータを共有する仕組みです。TypeScriptで型をつけることで、安全に使えます。

import { createContext, useContext, useState } from 'react';

// Contextの型を定義
interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => void;
  logout: () => void;
}

// Contextを作成(初期値はundefined)
const AuthContext = createContext<AuthContextType | undefined>(undefined);

// Providerコンポーネント
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  
  const login = (email: string, password: string) => {
    // ログイン処理
    setUser({ id: 1, name: "太郎", email });
  };
  
  const logout = () => {
    setUser(null);
  };
  
  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// カスタムフック
function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuthはAuthProvider内で使用してください');
  }
  return context;
}

// 使用例
function Profile() {
  const { user, logout } = useAuth();
  
  if (!user) {
    return <p>ログインしてください</p>;
  }
  
  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={logout}>ログアウト</button>
    </div>
  );
}

よくあるエラーと解決法

エラー1: Property 'value' does not exist

// ❌ エラーになる
const handleChange = (e) => {
  console.log(e.target.value);
};
// Error: Parameter 'e' implicitly has an 'any' type

// ✅ 型をつける
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value);
};

エラー2: Object is possibly 'null'

const [user, setUser] = useState<User | null>(null);

// ❌ エラーになる
return <h1>{user.name}</h1>;
// Error: Object is possibly 'null'

// ✅ nullチェックをする
if (!user) {
  return <p>読み込み中...</p>;
}
return <h1>{user.name}</h1>;

// または Optional Chaining を使う
return <h1>{user?.name ?? "ゲスト"}</h1>;

エラー3: Type 'string' is not assignable to type 'number'

const [age, setAge] = useState<number>(0);

// ❌ エラーになる
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setAge(e.target.value);
};
// Error: Type 'string' is not assignable to type 'number'

// ✅ 数値に変換する
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setAge(Number(e.target.value));
};

エラー4: Argument of type is not assignable

interface ButtonProps {
  onClick: () => void;
}

// ❌ エラーになる
<Button onClick={(e) => console.log(e)} />
// Error: Type '(e: any) => void' is not assignable to type '() => void'

// ✅ 型を合わせる(引数なし)
<Button onClick={() => console.log('clicked')} />

// または、Propsの型を変更
interface ButtonProps {
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
}

実践的なパターン集

パターン1: 条件付きProps

// ボタンのタイプによってPropsが変わる
type ButtonProps = 
  | { type: 'button'; onClick: () => void }
  | { type: 'link'; href: string }
  | { type: 'submit' };

function Button(props: ButtonProps) {
  if (props.type === 'link') {
    return <a href={props.href}>リンク</a>;
  }
  
  if (props.type === 'submit') {
    return <button type="submit">送信</button>;
  }
  
  return <button onClick={props.onClick}>ボタン</button>;
}

// 使用例
<Button type="button" onClick={() => alert('clicked')} />
<Button type="link" href="/about" />
<Button type="submit" />

パターン2: ジェネリックなリストコンポーネント

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.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>
  );
}

// 使用例
interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: "太郎" },
  { id: 2, name: "花子" }
];

<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

パターン3: フォームの型定義

// フォームの型を一元管理
interface FormFields {
  email: string;
  password: string;
  rememberMe: boolean;
}

// フォームのエラー型
type FormErrors = {
  [K in keyof FormFields]?: string;
};

function LoginForm() {
  const [formData, setFormData] = useState<FormFields>({
    email: "",
    password: "",
    rememberMe: false
  });
  
  const [errors, setErrors] = useState<FormErrors>({});
  
  const handleChange = (field: keyof FormFields) => {
    return (e: React.ChangeEvent<HTMLInputElement>) => {
      const value = e.target.type === 'checkbox' 
        ? e.target.checked 
        : e.target.value;
      
      setFormData({ ...formData, [field]: value });
      setErrors({ ...errors, [field]: undefined });
    };
  };
  
  const validate = (): boolean => {
    const newErrors: FormErrors = {};
    
    if (!formData.email) {
      newErrors.email = "メールアドレスを入力してください";
    }
    if (!formData.password) {
      newErrors.password = "パスワードを入力してください";
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validate()) {
      console.log('送信:', formData);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input 
          type="email"
          value={formData.email}
          onChange={handleChange('email')}
        />
        {errors.email && <span>{errors.email}</span>}
      </div>
      <div>
        <input 
          type="password"
          value={formData.password}
          onChange={handleChange('password')}
        />
        {errors.password && <span>{errors.password}</span>}
      </div>
      <label>
        <input 
          type="checkbox"
          checked={formData.rememberMe}
          onChange={handleChange('rememberMe')}
        />
        ログイン状態を保持
      </label>
      <button type="submit">ログイン</button>
    </form>
  );
}

まとめ

Propsの型

  • インターフェースで型定義
  • オプショナルは ? をつける
  • children は React.ReactNode

Stateの型

  • useState<型>(初期値)
  • null許容型は 型 | null
  • 配列は 型[]

イベントハンドラの型

  • React.MouseEvent<要素>:クリックイベント
  • React.ChangeEvent<要素>:入力変更イベント
  • React.FormEvent<要素>:フォーム送信イベント

カスタムフック・Context

  • カスタムフックもしっかり型をつける
  • as const でタプル型を返せる
  • Contextは createContext<型 | undefined>

エラー対処

  • null チェックを忘れずに
  • input.value は文字列なので Number() で変換
  • エラーメッセージをよく読む

TypeScriptとReactを組み合わせることで、型安全でバグの少ないアプリケーションが作れます!