T-CREATOR

React × TypeScript:Hooks とコンポーネントの型安全な書き方

React × TypeScript:Hooks とコンポーネントの型安全な書き方

React と TypeScript を組み合わせた開発で、「Props の型定義ってどう書くの?」「useState の型推論がうまくいかない」「カスタム Hooks の型をどうやって安全にするの?」といった疑問を抱えていませんか?

TypeScript の導入により型安全性は向上しますが、React 特有のパターン(コンポーネント、Hooks、Context など)では、適切な型定義を行わないと、かえって開発効率が低下してしまうことがあります。特に大規模なプロジェクトでは、型エラーの解決に多くの時間を費やしてしまい、本来のビジネスロジック開発に集中できないという課題が頻発しています。

本記事では、React × TypeScript 開発における実践的な型安全パターンを、コンポーネント設計から高度な型統合まで体系的に解説します。実際のプロジェクトで即座に活用できるベストプラクティスを豊富なコード例とともにご紹介しますので、明日からの開発で自信を持って React TypeScript を扱えるようになるでしょう。

コンポーネント設計の型安全化

React コンポーネントの型安全性を確保することは、保守性の高いアプリケーション開発の基盤となります。適切な型定義により、開発時のエラー検出と IDE サポートの恩恵を最大限に活用できます。

関数コンポーネントと Props の型定義ベストプラクティス

関数コンポーネントでは、Props の型定義が最も重要な要素となります。明確で拡張性のある型定義パターンをマスターしましょう。

typescript// 基本的なProps型定義
interface ButtonProps {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
}

// 推奨:関数コンポーネントの型定義
const Button: React.FC<ButtonProps> = ({
  children,
  onClick,
  disabled = false,
  variant = 'primary',
  size = 'medium',
}) => {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

// より詳細な型制約を持つコンポーネント
interface UserCardProps {
  user: {
    id: string;
    name: string;
    email: string;
    avatar?: string;
    isOnline: boolean;
  };
  onEdit?: (userId: string) => void;
  onDelete?: (userId: string) => void;
  showActions?: boolean;
  className?: string;
}

const UserCard: React.FC<UserCardProps> = ({
  user,
  onEdit,
  onDelete,
  showActions = true,
  className = '',
}) => {
  const handleEdit = () => {
    onEdit?.(user.id);
  };

  const handleDelete = () => {
    if (window.confirm('本当に削除しますか?')) {
      onDelete?.(user.id);
    }
  };

  return (
    <div className={`user-card ${className}`}>
      <div className='user-info'>
        {user.avatar && (
          <img
            src={user.avatar}
            alt={`${user.name}のアバター`}
          />
        )}
        <div>
          <h3>{user.name}</h3>
          <p>{user.email}</p>
          <span
            className={`status ${
              user.isOnline ? 'online' : 'offline'
            }`}
          >
            {user.isOnline ? 'オンライン' : 'オフライン'}
          </span>
        </div>
      </div>

      {showActions && (onEdit || onDelete) && (
        <div className='actions'>
          {onEdit && (
            <button onClick={handleEdit} type='button'>
              編集
            </button>
          )}
          {onDelete && (
            <button onClick={handleDelete} type='button'>
              削除
            </button>
          )}
        </div>
      )}
    </div>
  );
};

// ジェネリクスを活用した再利用可能なコンポーネント
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  emptyMessage?: string;
  className?: string;
  onItemClick?: (item: T, index: number) => void;
}

function List<T>({
  items,
  renderItem,
  emptyMessage = 'アイテムがありません',
  className = '',
  onItemClick,
}: ListProps<T>): React.ReactElement {
  if (items.length === 0) {
    return (
      <div className='empty-message'>{emptyMessage}</div>
    );
  }

  return (
    <ul className={`list ${className}`}>
      {items.map((item, index) => (
        <li
          key={index}
          className='list-item'
          onClick={() => onItemClick?.(item, index)}
        >
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  );
}

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

const ProductList: React.FC = () => {
  const products: Product[] = [
    { id: '1', name: 'MacBook Pro', price: 299000 },
    { id: '2', name: 'iPhone 15', price: 124800 },
  ];

  return (
    <List
      items={products}
      renderItem={(product) => (
        <div>
          <h4>{product.name}</h4>
          <p>¥{product.price.toLocaleString()}</p>
        </div>
      )}
      onItemClick={(product) => {
        console.log(
          '商品がクリックされました:',
          product.name
        );
      }}
    />
  );
};

子コンポーネントとイベントハンドリングの型制御

親子コンポーネント間でのデータフローとイベント処理における型安全性を確保するパターンをご紹介します。

typescript// イベントハンドラーの型定義
interface FormFieldProps {
  label: string;
  name: string;
  value: string;
  onChange: (name: string, value: string) => void;
  onBlur?: (name: string) => void;
  error?: string;
  required?: boolean;
  type?: 'text' | 'email' | 'password' | 'tel';
  placeholder?: string;
}

const FormField: React.FC<FormFieldProps> = ({
  label,
  name,
  value,
  onChange,
  onBlur,
  error,
  required = false,
  type = 'text',
  placeholder,
}) => {
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    onChange(name, e.target.value);
  };

  const handleBlur = (
    e: React.FocusEvent<HTMLInputElement>
  ) => {
    onBlur?.(name);
  };

  return (
    <div
      className={`form-field ${error ? 'has-error' : ''}`}
    >
      <label htmlFor={name}>
        {label}
        {required && <span className='required'>*</span>}
      </label>
      <input
        id={name}
        name={name}
        type={type}
        value={value}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        className={error ? 'error' : ''}
      />
      {error && (
        <span className='error-message'>{error}</span>
      )}
    </div>
  );
};

// 複雑なフォームコンポーネントの型設計
interface UserFormData {
  name: string;
  email: string;
  age: string;
  department: string;
}

interface UserFormErrors {
  name?: string;
  email?: string;
  age?: string;
  department?: string;
}

interface UserFormProps {
  initialData?: Partial<UserFormData>;
  onSubmit: (data: UserFormData) => Promise<void>;
  onCancel?: () => void;
  isLoading?: boolean;
}

const UserForm: React.FC<UserFormProps> = ({
  initialData = {},
  onSubmit,
  onCancel,
  isLoading = false,
}) => {
  const [formData, setFormData] =
    React.useState<UserFormData>({
      name: initialData.name || '',
      email: initialData.email || '',
      age: initialData.age || '',
      department: initialData.department || '',
    });

  const [errors, setErrors] =
    React.useState<UserFormErrors>({});
  const [touched, setTouched] = React.useState<
    Record<keyof UserFormData, boolean>
  >({
    name: false,
    email: false,
    age: false,
    department: false,
  });

  const handleFieldChange = (
    name: string,
    value: string
  ) => {
    setFormData((prev) => ({ ...prev, [name]: value }));

    // エラーをクリア
    if (errors[name as keyof UserFormErrors]) {
      setErrors((prev) => ({ ...prev, [name]: undefined }));
    }
  };

  const handleFieldBlur = (name: string) => {
    setTouched((prev) => ({ ...prev, [name]: true }));
    validateField(
      name as keyof UserFormData,
      formData[name as keyof UserFormData]
    );
  };

  const validateField = (
    name: keyof UserFormData,
    value: string
  ): boolean => {
    let error: string | undefined;

    switch (name) {
      case 'name':
        if (!value.trim()) {
          error = '名前は必須です';
        } else if (value.trim().length < 2) {
          error = '名前は2文字以上で入力してください';
        }
        break;

      case 'email':
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!value.trim()) {
          error = 'メールアドレスは必須です';
        } else if (!emailRegex.test(value)) {
          error = '有効なメールアドレスを入力してください';
        }
        break;

      case 'age':
        const age = parseInt(value);
        if (!value.trim()) {
          error = '年齢は必須です';
        } else if (isNaN(age) || age < 0 || age > 150) {
          error = '有効な年齢を入力してください';
        }
        break;

      case 'department':
        if (!value.trim()) {
          error = '部署は必須です';
        }
        break;
    }

    setErrors((prev) => ({ ...prev, [name]: error }));
    return !error;
  };

  const validateForm = (): boolean => {
    const newErrors: UserFormErrors = {};
    let isValid = true;

    (
      Object.keys(formData) as Array<keyof UserFormData>
    ).forEach((key) => {
      if (!validateField(key, formData[key])) {
        isValid = false;
      }
    });

    return isValid;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // すべてのフィールドをtouchedにマーク
    setTouched({
      name: true,
      email: true,
      age: true,
      department: true,
    });

    if (validateForm()) {
      try {
        await onSubmit(formData);
      } catch (error) {
        console.error('フォーム送信エラー:', error);
      }
    }
  };

  const isSubmitDisabled =
    isLoading ||
    Object.values(errors).some((error) => !!error);

  return (
    <form onSubmit={handleSubmit} className='user-form'>
      <FormField
        label='名前'
        name='name'
        value={formData.name}
        onChange={handleFieldChange}
        onBlur={handleFieldBlur}
        error={touched.name ? errors.name : undefined}
        required
      />

      <FormField
        label='メールアドレス'
        name='email'
        type='email'
        value={formData.email}
        onChange={handleFieldChange}
        onBlur={handleFieldBlur}
        error={touched.email ? errors.email : undefined}
        required
      />

      <FormField
        label='年齢'
        name='age'
        value={formData.age}
        onChange={handleFieldChange}
        onBlur={handleFieldBlur}
        error={touched.age ? errors.age : undefined}
        required
      />

      <FormField
        label='部署'
        name='department'
        value={formData.department}
        onChange={handleFieldChange}
        onBlur={handleFieldBlur}
        error={
          touched.department ? errors.department : undefined
        }
        required
      />

      <div className='form-actions'>
        {onCancel && (
          <button
            type='button'
            onClick={onCancel}
            disabled={isLoading}
          >
            キャンセル
          </button>
        )}
        <button type='submit' disabled={isSubmitDisabled}>
          {isLoading ? '送信中...' : '送信'}
        </button>
      </div>
    </form>
  );
};

Conditional Rendering での型安全性確保

条件付きレンダリングにおいて型安全性を保つための実装パターンを解説します。

typescript// Union型を活用した条件付きレンダリング
type LoadingState =
  | 'idle'
  | 'loading'
  | 'success'
  | 'error';

interface DataDisplayProps<T> {
  state: LoadingState;
  data?: T;
  error?: string;
  onRetry?: () => void;
  renderData: (data: T) => React.ReactNode;
  loadingMessage?: string;
  emptyMessage?: string;
}

function DataDisplay<T>({
  state,
  data,
  error,
  onRetry,
  renderData,
  loadingMessage = '読み込み中...',
  emptyMessage = 'データがありません',
}: DataDisplayProps<T>): React.ReactElement {
  switch (state) {
    case 'idle':
      return <div className='idle-state'>準備完了</div>;

    case 'loading':
      return (
        <div className='loading-state'>
          <div className='spinner' />
          <p>{loadingMessage}</p>
        </div>
      );

    case 'error':
      return (
        <div className='error-state'>
          <p className='error-message'>
            {error || 'エラーが発生しました'}
          </p>
          {onRetry && (
            <button onClick={onRetry} type='button'>
              再試行
            </button>
          )}
        </div>
      );

    case 'success':
      if (!data) {
        return (
          <div className='empty-state'>{emptyMessage}</div>
        );
      }
      return (
        <div className='success-state'>
          {renderData(data)}
        </div>
      );

    default:
      // TypeScriptの網羅性チェックを活用
      const exhaustiveCheck: never = state;
      throw new Error(
        `Unhandled state: ${exhaustiveCheck}`
      );
  }
}

// 判別可能ユニオン型でより型安全な状態管理
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

interface ApiDataDisplayProps<T> {
  apiState: ApiState<T>;
  onRetry?: () => void;
  renderData: (data: T) => React.ReactNode;
}

function ApiDataDisplay<T>({
  apiState,
  onRetry,
  renderData,
}: ApiDataDisplayProps<T>): React.ReactElement {
  switch (apiState.status) {
    case 'idle':
      return <div>待機中</div>;

    case 'loading':
      return <div>読み込み中...</div>;

    case 'success':
      // TypeScriptが data プロパティの存在を保証
      return <div>{renderData(apiState.data)}</div>;

    case 'error':
      // TypeScriptが error プロパティの存在を保証
      return (
        <div>
          <p>エラー: {apiState.error}</p>
          {onRetry && (
            <button onClick={onRetry}>再試行</button>
          )}
        </div>
      );

    default:
      const exhaustiveCheck: never = apiState;
      throw new Error(
        `Unhandled api state: ${exhaustiveCheck}`
      );
  }
}

// 使用例
const UserProfile: React.FC<{ userId: string }> = ({
  userId,
}) => {
  const [apiState, setApiState] = React.useState<
    ApiState<User>
  >({ status: 'idle' });

  const fetchUser = async () => {
    setApiState({ status: 'loading' });
    try {
      const userData = await fetchUserById(userId);
      setApiState({ status: 'success', data: userData });
    } catch (error) {
      setApiState({
        status: 'error',
        error:
          error instanceof Error
            ? error.message
            : 'Unknown error',
      });
    }
  };

  React.useEffect(() => {
    fetchUser();
  }, [userId]);

  return (
    <ApiDataDisplay
      apiState={apiState}
      onRetry={fetchUser}
      renderData={(user) => (
        <UserCard
          user={user}
          onEdit={(id) => console.log('Edit user:', id)}
          onDelete={(id) => console.log('Delete user:', id)}
        />
      )}
    />
  );
};

コンポーネント合成パターンの型設計

再利用可能で拡張性の高いコンポーネント合成パターンの型安全な実装方法を解説します。

typescript// Compound Components パターンの型安全実装
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext =
  React.createContext<TabsContextValue | null>(null);

const useTabs = (): TabsContextValue => {
  const context = React.useContext(TabsContext);
  if (!context) {
    throw new Error(
      'useTabs must be used within a Tabs component'
    );
  }
  return context;
};

interface TabsProps {
  children: React.ReactNode;
  defaultTab?: string;
  onTabChange?: (tab: string) => void;
}

const Tabs: React.FC<TabsProps> & {
  List: typeof TabsList;
  Tab: typeof Tab;
  Panels: typeof TabsPanels;
  Panel: typeof TabPanel;
} = ({ children, defaultTab, onTabChange }) => {
  const [activeTab, setActiveTab] = React.useState(
    defaultTab || ''
  );

  const handleTabChange = (tab: string) => {
    setActiveTab(tab);
    onTabChange?.(tab);
  };

  const contextValue: TabsContextValue = {
    activeTab,
    setActiveTab: handleTabChange,
  };

  return (
    <TabsContext.Provider value={contextValue}>
      <div className='tabs'>{children}</div>
    </TabsContext.Provider>
  );
};

interface TabsListProps {
  children: React.ReactNode;
  className?: string;
}

const TabsList: React.FC<TabsListProps> = ({
  children,
  className = '',
}) => {
  return (
    <div className={`tabs-list ${className}`}>
      {children}
    </div>
  );
};

interface TabProps {
  value: string;
  children: React.ReactNode;
  disabled?: boolean;
  className?: string;
}

const Tab: React.FC<TabProps> = ({
  value,
  children,
  disabled = false,
  className = '',
}) => {
  const { activeTab, setActiveTab } = useTabs();

  const isActive = activeTab === value;

  const handleClick = () => {
    if (!disabled) {
      setActiveTab(value);
    }
  };

  return (
    <button
      type='button'
      onClick={handleClick}
      disabled={disabled}
      className={`tab ${isActive ? 'active' : ''} ${
        disabled ? 'disabled' : ''
      } ${className}`}
    >
      {children}
    </button>
  );
};

interface TabsPanelsProps {
  children: React.ReactNode;
  className?: string;
}

const TabsPanels: React.FC<TabsPanelsProps> = ({
  children,
  className = '',
}) => {
  return (
    <div className={`tabs-panels ${className}`}>
      {children}
    </div>
  );
};

interface TabPanelProps {
  value: string;
  children: React.ReactNode;
  className?: string;
}

const TabPanel: React.FC<TabPanelProps> = ({
  value,
  children,
  className = '',
}) => {
  const { activeTab } = useTabs();

  if (activeTab !== value) {
    return null;
  }

  return (
    <div className={`tab-panel ${className}`}>
      {children}
    </div>
  );
};

// Compound Componentsの型定義を完成
Tabs.List = TabsList;
Tabs.Tab = Tab;
Tabs.Panels = TabsPanels;
Tabs.Panel = TabPanel;

// 使用例
const DashboardTabs: React.FC = () => {
  return (
    <Tabs
      defaultTab='overview'
      onTabChange={(tab) =>
        console.log('Tab changed:', tab)
      }
    >
      <Tabs.List>
        <Tabs.Tab value='overview'>概要</Tabs.Tab>
        <Tabs.Tab value='analytics'>分析</Tabs.Tab>
        <Tabs.Tab value='settings'>設定</Tabs.Tab>
        <Tabs.Tab value='help' disabled>
          ヘルプ
        </Tabs.Tab>
      </Tabs.List>

      <Tabs.Panels>
        <Tabs.Panel value='overview'>
          <h2>ダッシュボード概要</h2>
          <p>
            アプリケーションの全体的な状況を表示します。
          </p>
        </Tabs.Panel>

        <Tabs.Panel value='analytics'>
          <h2>分析データ</h2>
          <p>詳細な分析結果とグラフを表示します。</p>
        </Tabs.Panel>

        <Tabs.Panel value='settings'>
          <h2>設定</h2>
          <p>アプリケーションの設定を変更できます。</p>
        </Tabs.Panel>
      </Tabs.Panels>
    </Tabs>
  );
};

// Render Props パターンの型安全実装
interface RenderPropsDataFetcherProps<T> {
  url: string;
  children: (props: {
    data: T | null;
    loading: boolean;
    error: string | null;
    refetch: () => void;
  }) => React.ReactNode;
}

function DataFetcher<T>({
  url,
  children,
}: RenderPropsDataFetcherProps<T>): React.ReactElement {
  const [data, setData] = React.useState<T | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(
    null
  );

  const fetchData = React.useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(
        err instanceof Error
          ? err.message
          : 'An error occurred'
      );
    } finally {
      setLoading(false);
    }
  }, [url]);

  React.useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = React.useCallback(() => {
    fetchData();
  }, [fetchData]);

  return (
    <>
      {children({
        data,
        loading,
        error,
        refetch,
      })}
    </>
  );
}

// 使用例
const UsersList: React.FC = () => {
  return (
    <DataFetcher<User[]> url='/api/users'>
      {({ data, loading, error, refetch }) => {
        if (loading)
          return <div>ユーザー一覧を読み込み中...</div>;
        if (error)
          return (
            <div>
              エラー: {error}{' '}
              <button onClick={refetch}>再試行</button>
            </div>
          );
        if (!data) return <div>データがありません</div>;

        return (
          <div>
            <h2>ユーザー一覧</h2>
            <List
              items={data}
              renderItem={(user) => (
                <UserCard
                  key={user.id}
                  user={user}
                  onEdit={(id) => console.log('Edit:', id)}
                  onDelete={(id) =>
                    console.log('Delete:', id)
                  }
                />
              )}
            />
          </div>
        );
      }}
    </DataFetcher>
  );
};

このようにコンポーネント設計における型安全性を確保することで、再利用可能で保守性の高い React アプリケーションを構築できます。次のセクションでは、Hooks の型制御について詳しく見ていきましょう。

Hooks の完全型制御

React Hooks における型安全性の確保は、アプリケーションの状態管理とロジックの分離において極めて重要です。適切な型定義により、予期しないバグを防ぎ、開発体験を大幅に向上させることができます。

useState の型推論最適化と型指定パターン

useState は React Hooks の中で最も基本的でありながら、型推論の課題が頻発する Hook です。適切な型指定により型安全性を確保しましょう。

typescript// 基本的な useState の型推論
const [count, setCount] = React.useState(0); // number型として推論される
const [message, setMessage] = React.useState(''); // string型として推論される

// 明示的な型指定が必要なケース
interface User {
  id: string;
  name: string;
  email: string;
}

// 初期値が null の場合は明示的に型を指定
const [user, setUser] = React.useState<User | null>(null);

// 配列の初期値が空の場合も明示的に型を指定
const [users, setUsers] = React.useState<User[]>([]);

// Union型を活用した状態管理
type SubmitStatus =
  | 'idle'
  | 'pending'
  | 'success'
  | 'error';
const [submitStatus, setSubmitStatus] =
  React.useState<SubmitStatus>('idle');

// 複雑なオブジェクト状態の型安全管理
interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
}

const [formState, setFormState] = React.useState<FormState>(
  {
    values: {},
    errors: {},
    touched: {},
    isSubmitting: false,
  }
);

// 型安全な状態更新関数
const updateFormValue = (name: string, value: string) => {
  setFormState((prevState) => ({
    ...prevState,
    values: {
      ...prevState.values,
      [name]: value,
    },
    // エラーをクリア
    errors: {
      ...prevState.errors,
      [name]: '',
    },
  }));
};

const setFormError = (name: string, error: string) => {
  setFormState((prevState) => ({
    ...prevState,
    errors: {
      ...prevState.errors,
      [name]: error,
    },
  }));
};

// オプション型とデフォルト値の型安全な管理
interface UserPreferences {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  notifications: boolean;
  autoSave: boolean;
}

const defaultPreferences: UserPreferences = {
  theme: 'light',
  language: 'ja',
  notifications: true,
  autoSave: true,
};

const [preferences, setPreferences] =
  React.useState<UserPreferences>(defaultPreferences);

// 部分的な更新を型安全に行う関数
const updatePreference = <K extends keyof UserPreferences>(
  key: K,
  value: UserPreferences[K]
) => {
  setPreferences((prev) => ({
    ...prev,
    [key]: value,
  }));
};

// 使用例
const ProfileSettings: React.FC = () => {
  return (
    <div>
      <label>
        <input
          type='checkbox'
          checked={preferences.notifications}
          onChange={(e) =>
            updatePreference(
              'notifications',
              e.target.checked
            )
          }
        />
        通知を受け取る
      </label>

      <select
        value={preferences.theme}
        onChange={(e) =>
          updatePreference(
            'theme',
            e.target.value as 'light' | 'dark'
          )
        }
      >
        <option value='light'>ライト</option>
        <option value='dark'>ダーク</option>
      </select>
    </div>
  );
};

useEffect 依存配列の型安全化

useEffect の依存配列における型安全性を確保し、無限ループやメモリリークを防ぐパターンを解説します。

typescript// 基本的な useEffect の型安全な使用
const UserProfile: React.FC<{ userId: string }> = ({
  userId,
}) => {
  const [user, setUser] = React.useState<User | null>(null);
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;

    const fetchUser = async () => {
      try {
        setLoading(true);
        const userData = await getUserById(userId);

        if (!cancelled) {
          setUser(userData);
        }
      } catch (error) {
        if (!cancelled) {
          console.error(
            'ユーザーの取得に失敗しました:',
            error
          );
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    return () => {
      cancelled = true;
    };
  }, [userId]); // 依存配列を明示的に管理

  return loading ? (
    <div>読み込み中...</div>
  ) : (
    <UserCard user={user} />
  );
};

// 複雑な依存関係の型安全管理
interface SearchFilters {
  query: string;
  category: string;
  priceRange: [number, number];
  sortBy: 'name' | 'price' | 'date';
}

const ProductSearch: React.FC = () => {
  const [filters, setFilters] =
    React.useState<SearchFilters>({
      query: '',
      category: '',
      priceRange: [0, 1000000],
      sortBy: 'name',
    });

  const [products, setProducts] = React.useState<Product[]>(
    []
  );
  const [loading, setLoading] = React.useState(false);

  // 検索関数をメモ化して依存配列を最適化
  const searchProducts = React.useCallback(
    async (searchFilters: SearchFilters) => {
      setLoading(true);
      try {
        const results = await searchProductsAPI(
          searchFilters
        );
        setProducts(results);
      } catch (error) {
        console.error('商品検索エラー:', error);
        setProducts([]);
      } finally {
        setLoading(false);
      }
    },
    []
  ); // searchProductsAPI が変わらない限り再作成しない

  // フィルターが変更されたら検索を実行
  React.useEffect(() => {
    const timeoutId = setTimeout(() => {
      searchProducts(filters);
    }, 300); // デバウンス処理

    return () => clearTimeout(timeoutId);
  }, [filters, searchProducts]);

  return (
    <div>
      <ProductFilters
        filters={filters}
        onFiltersChange={setFilters}
      />
      {loading ? (
        <div>検索中...</div>
      ) : (
        <ProductList products={products} />
      )}
    </div>
  );
};

// カスタムHookでuseEffectの型安全性を向上
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(
  url: string,
  options?: RequestInit
): UseFetchResult<T> {
  const [data, setData] = React.useState<T | null>(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState<string | null>(
    null
  );

  const fetchData = React.useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}`
        );
      }
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(
        err instanceof Error ? err.message : 'Unknown error'
      );
    } finally {
      setLoading(false);
    }
  }, [url, options]); // optionsの変更も検知

  React.useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = React.useCallback(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch };
}

// useFetch の使用例
const UserList: React.FC = () => {
  const {
    data: users,
    loading,
    error,
    refetch,
  } = useFetch<User[]>('/api/users');

  if (loading) return <div>ユーザーを読み込み中...</div>;
  if (error)
    return (
      <div>
        エラー: {error}{' '}
        <button onClick={refetch}>再試行</button>
      </div>
    );
  if (!users) return <div>ユーザーが見つかりません</div>;

  return (
    <div>
      <h2>ユーザー一覧</h2>
      <List
        items={users}
        renderItem={(user) => (
          <UserCard key={user.id} user={user} />
        )}
      />
    </div>
  );
};

useCallback/useMemo の型パフォーマンス最適化

useCallback と useMemo を適切に型定義してパフォーマンスを最適化するパターンをご紹介します。

typescript// useCallback の型安全な使用
interface TodoItemProps {
  todo: {
    id: string;
    text: string;
    completed: boolean;
  };
  onToggle: (id: string) => void;
  onDelete: (id: string) => void;
  onEdit: (id: string, newText: string) => void;
}

const TodoItem: React.FC<TodoItemProps> = React.memo(
  ({ todo, onToggle, onDelete, onEdit }) => {
    const [isEditing, setIsEditing] = React.useState(false);
    const [editText, setEditText] = React.useState(
      todo.text
    );

    // イベントハンドラーをメモ化
    const handleToggle = React.useCallback(() => {
      onToggle(todo.id);
    }, [todo.id, onToggle]);

    const handleDelete = React.useCallback(() => {
      onDelete(todo.id);
    }, [todo.id, onDelete]);

    const handleEdit = React.useCallback(() => {
      if (isEditing) {
        onEdit(todo.id, editText);
        setIsEditing(false);
      } else {
        setIsEditing(true);
      }
    }, [todo.id, editText, isEditing, onEdit]);

    const handleCancel = React.useCallback(() => {
      setEditText(todo.text);
      setIsEditing(false);
    }, [todo.text]);

    // 計算処理をメモ化
    const displayText = React.useMemo(() => {
      return isEditing ? editText : todo.text;
    }, [isEditing, editText, todo.text]);

    return (
      <li
        className={`todo-item ${
          todo.completed ? 'completed' : ''
        }`}
      >
        <input
          type='checkbox'
          checked={todo.completed}
          onChange={handleToggle}
        />

        {isEditing ? (
          <input
            type='text'
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            autoFocus
          />
        ) : (
          <span>{displayText}</span>
        )}

        <div className='actions'>
          <button onClick={handleEdit}>
            {isEditing ? '保存' : '編集'}
          </button>
          {isEditing && (
            <button onClick={handleCancel}>
              キャンセル
            </button>
          )}
          <button onClick={handleDelete}>削除</button>
        </div>
      </li>
    );
  }
);

// 複雑な計算処理のメモ化
interface AnalyticsData {
  sales: Array<{ date: string; amount: number }>;
  users: Array<{ date: string; count: number }>;
  revenue: Array<{ date: string; value: number }>;
}

interface AnalyticsSummary {
  totalSales: number;
  averageDailySales: number;
  totalUsers: number;
  totalRevenue: number;
  growthRate: number;
}

const AnalyticsDashboard: React.FC<{
  data: AnalyticsData;
}> = ({ data }) => {
  // 重い計算処理をメモ化
  const summary = React.useMemo((): AnalyticsSummary => {
    const totalSales = data.sales.reduce(
      (sum, item) => sum + item.amount,
      0
    );
    const averageDailySales =
      totalSales / data.sales.length;
    const totalUsers = data.users.reduce(
      (sum, item) => sum + item.count,
      0
    );
    const totalRevenue = data.revenue.reduce(
      (sum, item) => sum + item.value,
      0
    );

    // 成長率の計算(前月比)
    const currentMonthRevenue = data.revenue
      .slice(-30)
      .reduce((sum, item) => sum + item.value, 0);
    const previousMonthRevenue = data.revenue
      .slice(-60, -30)
      .reduce((sum, item) => sum + item.value, 0);
    const growthRate =
      previousMonthRevenue > 0
        ? ((currentMonthRevenue - previousMonthRevenue) /
            previousMonthRevenue) *
          100
        : 0;

    return {
      totalSales,
      averageDailySales,
      totalUsers,
      totalRevenue,
      growthRate,
    };
  }, [data]); // data が変更された時のみ再計算

  // フォーマット用の関数をメモ化
  const formatCurrency = React.useCallback(
    (amount: number): string => {
      return new Intl.NumberFormat('ja-JP', {
        style: 'currency',
        currency: 'JPY',
      }).format(amount);
    },
    []
  );

  const formatPercentage = React.useCallback(
    (rate: number): string => {
      return `${rate > 0 ? '+' : ''}${rate.toFixed(2)}%`;
    },
    []
  );

  return (
    <div className='analytics-dashboard'>
      <div className='summary-cards'>
        <div className='card'>
          <h3>総売上</h3>
          <p>{formatCurrency(summary.totalRevenue)}</p>
          <span
            className={
              summary.growthRate >= 0
                ? 'positive'
                : 'negative'
            }
          >
            {formatPercentage(summary.growthRate)}
          </span>
        </div>

        <div className='card'>
          <h3>平均日次売上</h3>
          <p>{formatCurrency(summary.averageDailySales)}</p>
        </div>

        <div className='card'>
          <h3>総ユーザー数</h3>
          <p>{summary.totalUsers.toLocaleString()}</p>
        </div>
      </div>
    </div>
  );
};

// カスタムHookでのメモ化パターン
interface UseFilteredListProps<T> {
  items: T[];
  filterFn: (item: T) => boolean;
  sortFn?: (a: T, b: T) => number;
}

function useFilteredList<T>({
  items,
  filterFn,
  sortFn,
}: UseFilteredListProps<T>): T[] {
  return React.useMemo(() => {
    let result = items.filter(filterFn);

    if (sortFn) {
      result = result.sort(sortFn);
    }

    return result;
  }, [items, filterFn, sortFn]);
}

// useFilteredList の使用例
const FilterableUserList: React.FC = () => {
  const [users] = React.useState<User[]>([
    {
      id: '1',
      name: 'Alice',
      email: 'alice@example.com',
      isOnline: true,
    },
    {
      id: '2',
      name: 'Bob',
      email: 'bob@example.com',
      isOnline: false,
    },
    {
      id: '3',
      name: 'Charlie',
      email: 'charlie@example.com',
      isOnline: true,
    },
  ]);

  const [showOnlineOnly, setShowOnlineOnly] =
    React.useState(false);
  const [sortBy, setSortBy] = React.useState<
    'name' | 'email'
  >('name');

  // フィルター関数をメモ化
  const filterFn = React.useCallback(
    (user: User) => {
      return !showOnlineOnly || user.isOnline;
    },
    [showOnlineOnly]
  );

  // ソート関数をメモ化
  const sortFn = React.useCallback(
    (a: User, b: User) => {
      return a[sortBy].localeCompare(b[sortBy]);
    },
    [sortBy]
  );

  const filteredUsers = useFilteredList({
    items: users,
    filterFn,
    sortFn,
  });

  return (
    <div>
      <div className='controls'>
        <label>
          <input
            type='checkbox'
            checked={showOnlineOnly}
            onChange={(e) =>
              setShowOnlineOnly(e.target.checked)
            }
          />
          オンラインユーザーのみ表示
        </label>

        <select
          value={sortBy}
          onChange={(e) =>
            setSortBy(e.target.value as 'name' | 'email')
          }
        >
          <option value='name'>名前順</option>
          <option value='email'>メール順</option>
        </select>
      </div>

      <List
        items={filteredUsers}
        renderItem={(user) => (
          <UserCard key={user.id} user={user} />
        )}
      />
    </div>
  );
};

カスタム Hooks の型安全な設計原則

カスタム Hooks は React における強力な抽象化手法です。型安全で再利用可能なカスタム Hooks の設計パターンを解説します。

typescript// 基本的なカスタムHooks の型定義
interface UseToggleReturn {
  value: boolean;
  toggle: () => void;
  setTrue: () => void;
  setFalse: () => void;
}

function useToggle(
  initialValue: boolean = false
): UseToggleReturn {
  const [value, setValue] = React.useState(initialValue);

  const toggle = React.useCallback(() => {
    setValue((prev) => !prev);
  }, []);

  const setTrue = React.useCallback(() => {
    setValue(true);
  }, []);

  const setFalse = React.useCallback(() => {
    setValue(false);
  }, []);

  return { value, toggle, setTrue, setFalse };
}

// ジェネリクスを活用したカスタムHooks
interface UseLocalStorageReturn<T> {
  value: T | null;
  setValue: (newValue: T) => void;
  removeValue: () => void;
}

function useLocalStorage<T>(
  key: string,
  defaultValue?: T
): UseLocalStorageReturn<T> {
  const [value, setInternalValue] =
    React.useState<T | null>(() => {
      try {
        const storedValue = localStorage.getItem(key);
        return storedValue
          ? JSON.parse(storedValue)
          : defaultValue || null;
      } catch {
        return defaultValue || null;
      }
    });

  const setValue = React.useCallback(
    (newValue: T) => {
      try {
        localStorage.setItem(key, JSON.stringify(newValue));
        setInternalValue(newValue);
      } catch (error) {
        console.error(
          'localStorage への書き込みに失敗しました:',
          error
        );
      }
    },
    [key]
  );

  const removeValue = React.useCallback(() => {
    try {
      localStorage.removeItem(key);
      setInternalValue(null);
    } catch (error) {
      console.error(
        'localStorage からの削除に失敗しました:',
        error
      );
    }
  }, [key]);

  return { value, setValue, removeValue };
}

// 複雑な状態管理を型安全に行うカスタムHooks
interface FormField {
  value: string;
  error?: string;
  touched: boolean;
}

type FormFields<T> = {
  [K in keyof T]: FormField;
};

interface UseFormOptions<T> {
  initialValues: T;
  validate?: (
    values: T
  ) => Partial<Record<keyof T, string>>;
  onSubmit?: (values: T) => Promise<void> | void;
}

interface UseFormReturn<T> {
  fields: FormFields<T>;
  values: T;
  errors: Partial<Record<keyof T, string>>;
  isSubmitting: boolean;
  isValid: boolean;
  handleChange: (name: keyof T, value: string) => void;
  handleBlur: (name: keyof T) => void;
  handleSubmit: (e: React.FormEvent) => void;
  reset: () => void;
  setFieldError: (name: keyof T, error: string) => void;
}

function useForm<T extends Record<string, string>>({
  initialValues,
  validate,
  onSubmit,
}: UseFormOptions<T>): UseFormReturn<T> {
  const [fields, setFields] = React.useState<FormFields<T>>(
    () => {
      const initialFields = {} as FormFields<T>;

      for (const key in initialValues) {
        initialFields[key] = {
          value: initialValues[key],
          error: undefined,
          touched: false,
        };
      }

      return initialFields;
    }
  );

  const [isSubmitting, setIsSubmitting] =
    React.useState(false);

  // 現在の値を取得
  const values = React.useMemo(() => {
    const currentValues = {} as T;

    for (const key in fields) {
      currentValues[key] = fields[key].value as T[keyof T];
    }

    return currentValues;
  }, [fields]);

  // エラーを取得
  const errors = React.useMemo(() => {
    const currentErrors: Partial<Record<keyof T, string>> =
      {};

    for (const key in fields) {
      if (fields[key].error) {
        currentErrors[key] = fields[key].error;
      }
    }

    return currentErrors;
  }, [fields]);

  // バリデーション状態
  const isValid = React.useMemo(() => {
    return Object.values(errors).every((error) => !error);
  }, [errors]);

  const handleChange = React.useCallback(
    (name: keyof T, value: string) => {
      setFields((prev) => ({
        ...prev,
        [name]: {
          ...prev[name],
          value,
          error: undefined, // 入力時にエラーをクリア
        },
      }));
    },
    []
  );

  const handleBlur = React.useCallback(
    (name: keyof T) => {
      setFields((prev) => ({
        ...prev,
        [name]: {
          ...prev[name],
          touched: true,
        },
      }));

      // バリデーション実行
      if (validate) {
        const newValues = {
          ...values,
          [name]: fields[name].value,
        };
        const validationErrors = validate(newValues);

        if (validationErrors[name]) {
          setFields((prev) => ({
            ...prev,
            [name]: {
              ...prev[name],
              error: validationErrors[name],
            },
          }));
        }
      }
    },
    [validate, values, fields]
  );

  const setFieldError = React.useCallback(
    (name: keyof T, error: string) => {
      setFields((prev) => ({
        ...prev,
        [name]: {
          ...prev[name],
          error,
          touched: true,
        },
      }));
    },
    []
  );

  const handleSubmit = React.useCallback(
    async (e: React.FormEvent) => {
      e.preventDefault();

      if (!onSubmit) return;

      // 全フィールドを touched にマーク
      setFields((prev) => {
        const newFields = { ...prev };
        for (const key in newFields) {
          newFields[key] = {
            ...newFields[key],
            touched: true,
          };
        }
        return newFields;
      });

      // バリデーション実行
      if (validate) {
        const validationErrors = validate(values);
        if (Object.keys(validationErrors).length > 0) {
          setFields((prev) => {
            const newFields = { ...prev };
            for (const [key, error] of Object.entries(
              validationErrors
            )) {
              if (error) {
                newFields[key as keyof T] = {
                  ...newFields[key as keyof T],
                  error,
                };
              }
            }
            return newFields;
          });
          return;
        }
      }

      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('フォーム送信エラー:', error);
      } finally {
        setIsSubmitting(false);
      }
    },
    [onSubmit, validate, values]
  );

  const reset = React.useCallback(() => {
    setFields(() => {
      const resetFields = {} as FormFields<T>;

      for (const key in initialValues) {
        resetFields[key] = {
          value: initialValues[key],
          error: undefined,
          touched: false,
        };
      }

      return resetFields;
    });
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    fields,
    values,
    errors,
    isSubmitting,
    isValid,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    setFieldError,
  };
}

// useForm の使用例
interface LoginFormData {
  email: string;
  password: string;
}

const LoginForm: React.FC = () => {
  const form = useForm<LoginFormData>({
    initialValues: {
      email: '',
      password: '',
    },
    validate: (values) => {
      const errors: Partial<
        Record<keyof LoginFormData, string>
      > = {};

      if (!values.email) {
        errors.email = 'メールアドレスは必須です';
      } else if (
        !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)
      ) {
        errors.email =
          '有効なメールアドレスを入力してください';
      }

      if (!values.password) {
        errors.password = 'パスワードは必須です';
      } else if (values.password.length < 8) {
        errors.password =
          'パスワードは8文字以上で入力してください';
      }

      return errors;
    },
    onSubmit: async (values) => {
      console.log('Login attempt:', values);
      // ログイン処理
    },
  });

  return (
    <form onSubmit={form.handleSubmit}>
      <div>
        <label htmlFor='email'>メールアドレス</label>
        <input
          id='email'
          type='email'
          value={form.fields.email.value}
          onChange={(e) =>
            form.handleChange('email', e.target.value)
          }
          onBlur={() => form.handleBlur('email')}
        />
        {form.fields.email.touched &&
          form.fields.email.error && (
            <span className='error'>
              {form.fields.email.error}
            </span>
          )}
      </div>

      <div>
        <label htmlFor='password'>パスワード</label>
        <input
          id='password'
          type='password'
          value={form.fields.password.value}
          onChange={(e) =>
            form.handleChange('password', e.target.value)
          }
          onBlur={() => form.handleBlur('password')}
        />
        {form.fields.password.touched &&
          form.fields.password.error && (
            <span className='error'>
              {form.fields.password.error}
            </span>
          )}
      </div>

      <button
        type='submit'
        disabled={!form.isValid || form.isSubmitting}
      >
        {form.isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
};

このように、カスタム Hooks における型安全性を確保することで、再利用可能で保守性の高いロジックを構築できます。次のセクションでは、より高度な型統合パターンについて詳しく見ていきましょう。

高度な型統合パターン

React アプリケーションが複雑になるにつれて、Context API、useReducer、forwardRef などの高度なパターンが必要になります。これらのパターンを型安全に実装する方法を解説します。

Context API の型安全な実装戦略

Context API を型安全に利用するための包括的な実装パターンをご紹介します。

typescript// 基本的な Context の型安全実装
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

interface AuthContextValue {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  isLoading: boolean;
}

const AuthContext =
  React.createContext<AuthContextValue | null>(null);

// Context を安全に使用するカスタムHook
export const useAuth = (): AuthContextValue => {
  const context = React.useContext(AuthContext);
  if (!context) {
    throw new Error(
      'useAuth must be used within an AuthProvider'
    );
  }
  return context;
};

// Provider コンポーネントの型安全実装
interface AuthProviderProps {
  children: React.ReactNode;
}

export const AuthProvider: React.FC<AuthProviderProps> = ({
  children,
}) => {
  const [user, setUser] = React.useState<User | null>(null);
  const [isLoading, setIsLoading] = React.useState(true);

  const login = React.useCallback(
    async (email: string, password: string) => {
      setIsLoading(true);
      try {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email, password }),
        });

        if (!response.ok) {
          throw new Error('ログインに失敗しました');
        }

        const userData = await response.json();
        setUser(userData);
      } finally {
        setIsLoading(false);
      }
    },
    []
  );

  const logout = React.useCallback(() => {
    setUser(null);
    // ログアウト処理
  }, []);

  React.useEffect(() => {
    // 初期化時のユーザー情報取得
    const checkAuth = async () => {
      try {
        const response = await fetch('/api/auth/me');
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        }
      } catch (error) {
        console.error('認証チェックエラー:', error);
      } finally {
        setIsLoading(false);
      }
    };

    checkAuth();
  }, []);

  const contextValue: AuthContextValue = {
    user,
    login,
    logout,
    isLoading,
  };

  return (
    <AuthContext.Provider value={contextValue}>
      {children}
    </AuthContext.Provider>
  );
};

// 複数の Context を組み合わせる型安全パターン
interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext =
  React.createContext<ThemeContextValue | null>(null);

export const useTheme = (): ThemeContextValue => {
  const context = React.useContext(ThemeContext);
  if (!context) {
    throw new Error(
      'useTheme must be used within a ThemeProvider'
    );
  }
  return context;
};

// 複数の Provider を組み合わせるコンポーネント
interface AppProvidersProps {
  children: React.ReactNode;
}

export const AppProviders: React.FC<AppProvidersProps> = ({
  children,
}) => {
  return (
    <AuthProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </AuthProvider>
  );
};

// Context を使用したコンポーネントの例
const UserProfile: React.FC = () => {
  const { user, logout } = useAuth();
  const { theme, toggleTheme } = useTheme();

  if (!user) {
    return <div>ログインしてください</div>;
  }

  return (
    <div className={`profile ${theme}`}>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>Role: {user.role}</p>

      <button onClick={toggleTheme}>
        {theme === 'light' ? 'ダーク' : 'ライト'}
        モードに切り替え
      </button>

      <button onClick={logout}>ログアウト</button>
    </div>
  );
};

useReducer での複雑な状態管理型設計

useReducer を活用した型安全な状態管理パターンを解説します。複雑な状態遷移を明確に定義できます。

typescript// Action の型定義(判別可能ユニオン型)
type TodoAction =
  | { type: 'ADD_TODO'; payload: { text: string } }
  | { type: 'TOGGLE_TODO'; payload: { id: string } }
  | { type: 'DELETE_TODO'; payload: { id: string } }
  | {
      type: 'EDIT_TODO';
      payload: { id: string; text: string };
    }
  | { type: 'SET_FILTER'; payload: { filter: TodoFilter } }
  | { type: 'CLEAR_COMPLETED' };

// State の型定義
interface Todo {
  id: string;
  text: string;
  completed: boolean;
  createdAt: Date;
}

type TodoFilter = 'all' | 'active' | 'completed';

interface TodoState {
  todos: Todo[];
  filter: TodoFilter;
  nextId: number;
}

// Reducer の型安全実装
function todoReducer(
  state: TodoState,
  action: TodoAction
): TodoState {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: state.nextId.toString(),
            text: action.payload.text,
            completed: false,
            createdAt: new Date(),
          },
        ],
        nextId: state.nextId + 1,
      };

    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };

    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(
          (todo) => todo.id !== action.payload.id
        ),
      };

    case 'EDIT_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload.id
            ? { ...todo, text: action.payload.text }
            : todo
        ),
      };

    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload.filter,
      };

    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(
          (todo) => !todo.completed
        ),
      };

    default:
      // TypeScript の網羅性チェック
      const exhaustiveCheck: never = action;
      throw new Error(
        `Unhandled action: ${exhaustiveCheck}`
      );
  }
}

// useReducer を使用したコンポーネント
const TodoApp: React.FC = () => {
  const [state, dispatch] = React.useReducer(todoReducer, {
    todos: [],
    filter: 'all',
    nextId: 1,
  });

  // Action Creator を型安全に定義
  const addTodo = React.useCallback((text: string) => {
    if (text.trim()) {
      dispatch({
        type: 'ADD_TODO',
        payload: { text: text.trim() },
      });
    }
  }, []);

  const toggleTodo = React.useCallback((id: string) => {
    dispatch({ type: 'TOGGLE_TODO', payload: { id } });
  }, []);

  const deleteTodo = React.useCallback((id: string) => {
    dispatch({ type: 'DELETE_TODO', payload: { id } });
  }, []);

  const editTodo = React.useCallback(
    (id: string, text: string) => {
      dispatch({
        type: 'EDIT_TODO',
        payload: { id, text },
      });
    },
    []
  );

  const setFilter = React.useCallback(
    (filter: TodoFilter) => {
      dispatch({ type: 'SET_FILTER', payload: { filter } });
    },
    []
  );

  const clearCompleted = React.useCallback(() => {
    dispatch({ type: 'CLEAR_COMPLETED' });
  }, []);

  // フィルタリングされたTodoを計算
  const filteredTodos = React.useMemo(() => {
    switch (state.filter) {
      case 'active':
        return state.todos.filter(
          (todo) => !todo.completed
        );
      case 'completed':
        return state.todos.filter((todo) => todo.completed);
      default:
        return state.todos;
    }
  }, [state.todos, state.filter]);

  return (
    <div className='todo-app'>
      <TodoInput onAdd={addTodo} />
      <TodoFilters
        currentFilter={state.filter}
        onFilterChange={setFilter}
      />
      <TodoList
        todos={filteredTodos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
        onEdit={editTodo}
      />
      <TodoSummary
        total={state.todos.length}
        completed={
          state.todos.filter((t) => t.completed).length
        }
        onClearCompleted={clearCompleted}
      />
    </div>
  );
};

// より複雑な状態管理のためのカスタムHook
interface UseAsyncReducerOptions<T> {
  initialState: T;
  reducer: (state: T, action: any) => T;
}

function useAsyncReducer<T, A>({
  initialState,
  reducer,
}: UseAsyncReducerOptions<T>) {
  const [state, dispatch] = React.useReducer(
    reducer,
    initialState
  );
  const [loading, setLoading] = React.useState(false);

  const dispatchAsync = React.useCallback(
    async (actionOrAsyncAction: A | (() => Promise<A>)) => {
      if (typeof actionOrAsyncAction === 'function') {
        setLoading(true);
        try {
          const action = await (
            actionOrAsyncAction as () => Promise<A>
          )();
          dispatch(action);
        } finally {
          setLoading(false);
        }
      } else {
        dispatch(actionOrAsyncAction);
      }
    },
    []
  );

  return { state, dispatch: dispatchAsync, loading };
}

forwardRef と型の組み合わせ技法

forwardRef を型安全に使用するパターンを解説します。DOM 要素への参照を適切に型定義できます。

typescript// 基本的な forwardRef の型定義
interface InputProps {
  label: string;
  error?: string;
  placeholder?: string;
}

const Input = React.forwardRef<
  HTMLInputElement,
  InputProps
>(({ label, error, placeholder, ...props }, ref) => {
  return (
    <div className='input-wrapper'>
      <label>{label}</label>
      <input
        ref={ref}
        placeholder={placeholder}
        className={error ? 'error' : ''}
        {...props}
      />
      {error && (
        <span className='error-message'>{error}</span>
      )}
    </div>
  );
});

Input.displayName = 'Input';

// ジェネリクスを使用した汎用的な forwardRef
interface GenericInputProps<T extends HTMLElement> {
  as?: React.ElementType;
  className?: string;
  children?: React.ReactNode;
}

function createForwardRefComponent<
  T extends HTMLElement
>() {
  return React.forwardRef<
    T,
    GenericInputProps<T> & React.HTMLProps<T>
  >(
    (
      {
        as: Component = 'div',
        className,
        children,
        ...props
      },
      ref
    ) => {
      return (
        <Component
          ref={ref}
          className={className}
          {...props}
        >
          {children}
        </Component>
      );
    }
  );
}

const FlexBox = createForwardRefComponent<HTMLDivElement>();
FlexBox.displayName = 'FlexBox';

// 複雑なコンポーネントでの forwardRef 使用例
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title?: string;
  children: React.ReactNode;
  size?: 'small' | 'medium' | 'large';
}

const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
  (
    { isOpen, onClose, title, children, size = 'medium' },
    ref
  ) => {
    const [mounted, setMounted] = React.useState(false);

    React.useEffect(() => {
      setMounted(true);
    }, []);

    React.useEffect(() => {
      const handleEscape = (e: KeyboardEvent) => {
        if (e.key === 'Escape') {
          onClose();
        }
      };

      if (isOpen) {
        document.addEventListener('keydown', handleEscape);
        return () =>
          document.removeEventListener(
            'keydown',
            handleEscape
          );
      }
    }, [isOpen, onClose]);

    if (!mounted || !isOpen) {
      return null;
    }

    return ReactDOM.createPortal(
      <div className='modal-overlay' onClick={onClose}>
        <div
          ref={ref}
          className={`modal modal-${size}`}
          onClick={(e) => e.stopPropagation()}
        >
          {title && (
            <div className='modal-header'>
              <h2>{title}</h2>
              <button onClick={onClose} type='button'>
                ×
              </button>
            </div>
          )}
          <div className='modal-body'>{children}</div>
        </div>
      </div>,
      document.body
    );
  }
);

Modal.displayName = 'Modal';

// 使用例
const MyComponent: React.FC = () => {
  const inputRef = React.useRef<HTMLInputElement>(null);
  const modalRef = React.useRef<HTMLDivElement>(null);
  const [isModalOpen, setIsModalOpen] =
    React.useState(false);

  const focusInput = () => {
    inputRef.current?.focus();
  };

  return (
    <div>
      <Input
        ref={inputRef}
        label='お名前'
        placeholder='名前を入力してください'
      />
      <button onClick={focusInput}>
        入力欄にフォーカス
      </button>

      <button onClick={() => setIsModalOpen(true)}>
        モーダルを開く
      </button>

      <Modal
        ref={modalRef}
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title='確認'
      >
        <p>この操作を実行しますか?</p>
        <button onClick={() => setIsModalOpen(false)}>
          キャンセル
        </button>
        <button onClick={() => setIsModalOpen(false)}>
          実行
        </button>
      </Modal>
    </div>
  );
};

Higher-Order Components の型制御

HOC(高次コンポーネント)を型安全に実装するパターンを解説します。

typescript// 基本的な HOC の型定義
interface WithLoadingProps {
  loading: boolean;
}

function withLoading<P extends object>(
  Component: React.ComponentType<P>
): React.FC<P & WithLoadingProps> {
  return ({ loading, ...props }) => {
    if (loading) {
      return <div className='loading'>読み込み中...</div>;
    }

    return <Component {...(props as P)} />;
  };
}

// より複雑な HOC の実装
interface WithAuthProps {
  user: User | null;
  redirectTo?: string;
}

function withAuth<P extends object>(
  Component: React.ComponentType<P>,
  options: {
    redirectTo?: string;
    requiredRole?: User['role'];
  } = {}
): React.FC<Omit<P, keyof WithAuthProps>> {
  return (props) => {
    const { user } = useAuth();
    const navigate = useNavigate(); // Next.js なら useRouter

    React.useEffect(() => {
      if (!user) {
        navigate(options.redirectTo || '/login');
        return;
      }

      if (
        options.requiredRole &&
        user.role !== options.requiredRole
      ) {
        navigate('/unauthorized');
        return;
      }
    }, [user, navigate]);

    if (!user) {
      return <div>認証中...</div>;
    }

    if (
      options.requiredRole &&
      user.role !== options.requiredRole
    ) {
      return <div>権限がありません</div>;
    }

    return <Component {...(props as P)} user={user} />;
  };
}

// HOC を組み合わせる型安全パターン
function compose<P extends object>(
  ...hocs: Array<
    (
      component: React.ComponentType<any>
    ) => React.ComponentType<any>
  >
) {
  return (Component: React.ComponentType<P>) => {
    return hocs.reduceRight(
      (acc, hoc) => hoc(acc),
      Component
    );
  };
}

// 使用例
interface DashboardProps {
  user: User;
}

const Dashboard: React.FC<DashboardProps> = ({ user }) => {
  const [loading, setLoading] = React.useState(true);

  React.useEffect(() => {
    // データ取得処理
    setTimeout(() => setLoading(false), 2000);
  }, []);

  return (
    <div>
      <h1>ダッシュボード</h1>
      <p>ようこそ、{user.name}さん</p>
    </div>
  );
};

// HOC を適用
const EnhancedDashboard = compose(
  withAuth,
  withLoading
)(Dashboard);

// または個別に適用
const AuthenticatedDashboard = withAuth(Dashboard, {
  requiredRole: 'admin',
  redirectTo: '/login',
});

const LoadingDashboard = withLoading(
  AuthenticatedDashboard
);

これらの高度な型統合パターンを活用することで、React アプリケーションの型安全性を大幅に向上させることができます。

まとめ:持続可能な React TypeScript 開発

本記事では、React × TypeScript における型安全な開発パターンを体系的に解説いたしました。コンポーネント設計から Hooks の活用、高度な型統合まで、実践的なベストプラクティスをご紹介してきました。

これらのパターンを活用することで得られる主なメリットは以下の通りです:

開発効率の向上

型安全なコンポーネント設計により、開発時点でのエラー検出が可能になり、デバッグ時間を大幅に短縮できます。IDE の自動補完機能も最大限に活用でき、開発速度が向上するでしょう。

保守性の確保

適切な型定義により、コードの意図が明確になり、チーム開発での認識齟齬を防げます。リファクタリング時の安全性も大幅に向上し、長期的なプロジェクト運営がスムーズになります。

スケーラビリティの実現

型安全な Hooks と Context パターンにより、アプリケーションの規模拡大に対応できる柔軟な設計が可能です。新機能追加時の既存コードへの影響を最小限に抑えられます。

実践での注意点

型安全性を重視しすぎて過度に複雑な型定義を作成することは避けましょう。可読性とのバランスを保ち、チーム全体が理解できるレベルでの実装を心がけることが重要です。

また、TypeScript の更新に合わせて、より良い型定義パターンが登場することもあります。継続的な学習と改善を行い、最新のベストプラクティスを取り入れていくことをお勧めします。

React × TypeScript の組み合わせは、現代のフロントエンド開発において非常に強力な武器となります。本記事でご紹介したパターンを参考に、より安全で保守性の高いアプリケーション開発に取り組んでいただければと思います。

皆様の React TypeScript 開発がより充実したものになることを心より願っております。

関連リンク