T-CREATOR

<div />

ReactとTypeScriptでHooksとコンポーネントを型安全に書く使い方 実務の定石を整理

2026年1月16日
ReactとTypeScriptでHooksとコンポーネントを型安全に書く使い方 実務の定石を整理

React と TypeScript を組み合わせた開発で、「useState の型推論がうまくいかない」「Props のインターフェース設計で毎回迷う」という経験はありませんか。本記事では、Hooks の型推論と Props 設計を中心に、複雑化しない型安全なコンポーネントの書き方を、実務での失敗談を交えながら整理します。

型推論と明示的な型指定の使い分け早見表

比較項目型推論に任せる明示的に型を指定
useState の初期値が null推論不可(any になる)useState<User | null>(null) で安全
Props のインターフェース省略可能だが非推奨interface で明示が実務の定石
イベントハンドラー推論可能な場面が多い複雑な場合は型を書く
カスタム Hooks の戻り値推論される複雑な戻り値は明示推奨
ジェネリックコンポーネント推論できない<T> で型パラメータが必須

それぞれの詳細は後述します。

検証環境

  • OS: macOS Sonoma 15.2
  • Node.js: 22.13.0
  • TypeScript: 5.7.3
  • 主要パッケージ:
    • react: 19.0.0
    • @types/react: 19.0.3
  • 検証日: 2026 年 01 月 16 日

Hooks と Props 設計で型安全を確保すべき背景

React と TypeScript の組み合わせは、UI/UX を担うコンポーネントの信頼性を高めるために広く採用されています。しかし、「とりあえず動く」コードと「型安全で保守しやすい」コードには大きな差があります。

実際のプロジェクトで検証したところ、型定義が曖昧なコンポーネントは、Props の変更時に予期しないバグを引き起こす確率が高いことがわかりました。特に、チーム開発では「この Props は必須なのか任意なのか」「この Hooks の戻り値は何型なのか」という確認作業に多くの時間を取られます。

つまずきやすい点:TypeScript を導入しても、any を多用すると型安全の恩恵を受けられません。

React 19 と TypeScript 5.7 の組み合わせでは、型推論がさらに強化されていますが、それでも明示的な型定義が必要な場面は多く残っています。

以下の図は、型定義のレベルと開発効率の関係を示しています。

mermaidflowchart LR
  A["型定義なし<br/>any多用"] --> B["バグ多発<br/>デバッグ時間増"]
  C["適切な型定義"] --> D["IDE補完が効く<br/>バグ早期発見"]
  E["過剰な型定義"] --> F["可読性低下<br/>開発速度低下"]
  style C fill:#90EE90
  style D fill:#90EE90

適切な型定義は、バグの早期発見と IDE の補完機能を活かした開発効率の向上につながります。一方で、過剰な型定義は可読性を損ない、かえって開発速度を落とす原因になります。

型推論が効かない場面と実務で起きた問題

実務で型推論に頼りすぎた結果、問題が発生したケースを紹介します。

useState の初期値が null の場合に起きた事故

業務で認証済みユーザー情報を管理する際、以下のようなコードを書いていました。

typescript// 問題のあるコード:型推論が null のみになる
const [user, setUser] = useState(null);

この書き方では、user の型は null と推論され、後から User 型のオブジェクトを代入しようとしても型エラーになりません(実質 any に近い挙動)。実際に API からユーザー情報を取得して setUser に渡したとき、プロパティ名のタイポに気づかず本番でエラーになりました。

typescript// 型安全な書き方:User | null を明示
interface User {
  id: string;
  name: string;
  email: string;
}

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

この書き方であれば、setUser に渡すオブジェクトの型が User でなければコンパイル時にエラーになります。

配列の初期値が空の場合の型推論

空配列で初期化すると、TypeScript は never[] と推論します。これも実務で問題になりました。

typescript// 問題のあるコード:never[] と推論される
const [items, setItems] = useState([]);

// 後で配列に要素を追加しようとするとエラー
setItems([{ id: "1", name: "Item" }]); // 型エラー

解決策は、ジェネリクスで配列の要素型を明示することです。

typescriptinterface Item {
  id: string;
  name: string;
}

const [items, setItems] = useState<Item[]>([]);

つまずきやすい点useState([]) と書くと never[] になることを知らないと、「なぜ配列に追加できないのか」で悩みます。

Props のインターフェース設計における使い方の定石

Props の型定義は、コンポーネントの「契約」を明確にするものです。実務での失敗から学んだ定石を紹介します。

interface と type の選択基準

React の Props 定義では、interfacetype のどちらを使うかで意見が分かれます。検証の結果、以下の基準で使い分けるのが実務的でした。

interface と type の使い分け基準表

用途推奨理由
コンポーネントの Propsinterface拡張しやすく、エラーメッセージが読みやすい
Union 型や Intersection 型typeinterface では表現できない
API レスポンスの型interface将来的な拡張を見越して
関数の型エイリアスtypetype Handler = () => void の方が簡潔
typescript// Props は interface で定義
interface ButtonProps {
  children: React.ReactNode;
  onClick: () => void;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
}

// Union 型は type で定義
type ButtonVariant = "primary" | "secondary" | "danger";

必須プロパティと任意プロパティの明確な区別

実務で頻発した問題は、「このプロパティは必須なのか任意なのか」がコードから読み取れないケースでした。

typescript// 悪い例:デフォルト値があるのに必須になっている
interface CardProps {
  title: string;
  description: string;
  showBorder: boolean; // 必須だがデフォルト値がある
}

// 良い例:任意プロパティはオプショナルにする
interface CardProps {
  title: string;
  description: string;
  showBorder?: boolean; // 任意であることが明確
}

コンポーネント側でデフォルト値を設定する場合は、? を使って任意プロパティであることを明示します。

typescriptconst Card: React.FC<CardProps> = ({
  title,
  description,
  showBorder = true,
}) => {
  return (
    <div className={showBorder ? 'border' : ''}>
      <h2>{title}</h2>
      <p>{description}</p>
    </div>
  );
};

コンポーネントの型安全な設計パターン

実務で効果が高かった設計パターンを紹介します。

React.FC を使うか使わないかの判断

React 18 以降、React.FCchildren が暗黙的に含まれなくなりました。React 19 でもこの方針は継続されています。

typescript// React.FC を使う場合
const Button: React.FC<ButtonProps> = ({ children, onClick }) => {
  return <button onClick={onClick}>{children}</button>;
};

// React.FC を使わない場合(関数の型を明示)
function Button({ children, onClick }: ButtonProps): React.ReactElement {
  return <button onClick={onClick}>{children}</button>;
}

実際に試したところ、どちらでも型安全性は確保できます。ただし、ジェネリックコンポーネントでは React.FC が使いにくいため、関数宣言を使う方が柔軟です。

ジェネリックコンポーネントの型安全な実装

リスト表示など、汎用的なコンポーネントではジェネリクスが必要になります。

typescriptinterface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  emptyMessage?: string;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = 'データがありません',
}: ListProps<T>): React.ReactElement {
  if (items.length === 0) {
    return <div>{emptyMessage}</div>;
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

使用例では、型引数が自動的に推論されます。

typescriptinterface User {
  id: string;
  name: string;
}

const users: User[] = [
  { id: '1', name: 'Alice' },
  { id: '2', name: 'Bob' },
];

// T が User として推論される
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

イベントハンドラーの型定義

イベントハンドラーの型は、React が提供する型を活用します。

typescriptinterface FormFieldProps {
  value: string;
  onChange: (value: string) => void;
  onBlur?: () => void;
}

const FormField: React.FC<FormFieldProps> = ({
  value,
  onChange,
  onBlur,
}) => {
  // イベントオブジェクトの型は推論される
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value);
  };

  return (
    <input
      value={value}
      onChange={handleChange}
      onBlur={onBlur}
    />
  );
};

つまずきやすい点e.target.value を直接 Props に渡すのではなく、加工した値を渡す設計にすると、親コンポーネントでの型チェックが容易になります。

Hooks の型推論を活かす使い方

Hooks ごとに型推論の効き方が異なります。実務で検証した結果をまとめます。

useState:推論される場合とされない場合

useState は初期値から型を推論しますが、以下の場合は明示が必要です。

typescript// 推論される:初期値がプリミティブ
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
const [isOpen, setIsOpen] = useState(false); // boolean

// 推論されない:明示が必要
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");

useCallback と useMemo:戻り値の型推論

useCallback と useMemo は、コールバック関数や計算結果から型を推論します。

typescript// 型が推論される
const handleClick = useCallback(() => {
  console.log("clicked");
}, []);

// 複雑な場合は戻り値の型を明示
const computedValue = useMemo((): ComputedResult => {
  return {
    total: items.reduce((sum, item) => sum + item.price, 0),
    count: items.length,
  };
}, [items]);

useRef:DOM 要素への参照と値の保持

useRef は用途によって型の指定方法が異なります。

typescript// DOM 要素への参照:null で初期化
const inputRef = useRef<HTMLInputElement>(null);

// 値の保持:初期値を設定
const timerIdRef = useRef<number | null>(null);
const previousValueRef = useRef<string>("");

DOM 要素への参照では、null を初期値にして、要素がマウントされた後にアクセスします。

typescriptconst FocusInput: React.FC = () => {
  const inputRef = useRef<HTMLInputElement>(null);

  const focusInput = () => {
    // null チェックが必要
    inputRef.current?.focus();
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>フォーカス</button>
    </>
  );
};

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

カスタム Hooks を作成する際の型設計は、再利用性と型安全のバランスが重要です。

戻り値の型を明示する理由

カスタム Hooks の戻り値は推論されますが、明示した方が良い場面があります。

typescript// 戻り値の型を明示
interface UseToggleReturn {
  value: boolean;
  toggle: () => void;
  setTrue: () => void;
  setFalse: () => void;
}

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

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

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

戻り値の型を明示することで、以下のメリットがあります。

  • 利用者が IDE で補完を受けやすい
  • Hooks の実装を変更しても、型が契約として機能する
  • ドキュメントとしての役割を果たす

ジェネリクスを活用したカスタム Hooks

汎用的なカスタム Hooks では、ジェネリクスを活用します。

typescriptinterface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

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

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

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err instanceof Error ? err.message : "不明なエラー");
    } finally {
      setLoading(false);
    }
  }, [url]);

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

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

使用例では、型引数を指定することで戻り値の型が確定します。

typescriptinterface User {
  id: string;
  name: string;
}

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

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!users) return <div>データがありません</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

状態管理パターンの型安全な実装

複雑な状態管理では、Discriminated Union(判別可能なユニオン型)が効果的です。

API 呼び出し状態の型安全な管理

API の状態を「待機中」「読み込み中」「成功」「エラー」で管理する場合、以下の型設計が有効でした。

typescripttype ApiState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

この型を使うと、状態ごとにアクセス可能なプロパティが限定されます。

typescriptfunction DataDisplay<T>({
  state,
  renderData,
}: {
  state: ApiState<T>;
  renderData: (data: T) => React.ReactNode;
}): React.ReactElement {
  switch (state.status) {
    case 'idle':
      return <div>待機中</div>;
    case 'loading':
      return <div>読み込み中...</div>;
    case 'success':
      // state.data が T 型として型安全にアクセスできる
      return <div>{renderData(state.data)}</div>;
    case 'error':
      // state.error が string として型安全にアクセスできる
      return <div>エラー: {state.error}</div>;
  }
}

以下の図は、状態遷移と型の関係を示しています。

mermaidstateDiagram-v2
  [*] --> idle
  idle --> loading: fetch開始
  loading --> success: 成功
  loading --> error: 失敗
  success --> loading: refetch
  error --> loading: retry

各状態で利用可能なプロパティが異なるため、実行時エラーを防げます。

useReducer での複雑な状態管理

フォームなど複雑な状態管理では、useReducer と型定義の組み合わせが効果的です。

typescript// Action の型定義
type FormAction =
  | { type: "SET_VALUE"; field: string; value: string }
  | { type: "SET_ERROR"; field: string; error: string }
  | { type: "SET_TOUCHED"; field: string }
  | { type: "RESET" };

// State の型定義
interface FormState {
  values: Record<string, string>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
}

const initialState: FormState = {
  values: {},
  errors: {},
  touched: {},
};

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "SET_VALUE":
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: "" },
      };
    case "SET_ERROR":
      return {
        ...state,
        errors: { ...state.errors, [action.field]: action.error },
      };
    case "SET_TOUCHED":
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      };
    case "RESET":
      return initialState;
    default:
      // TypeScript の網羅性チェック
      const _exhaustiveCheck: never = action;
      return state;
  }
}

default ケースで never 型を使うことで、Action の追加漏れをコンパイル時に検出できます。

forwardRef と高度な型パターン

外部から DOM 要素にアクセスする必要がある場合、forwardRef を使います。

forwardRef の型定義

typescriptinterface InputProps {
  label: string;
  error?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} className={error ? 'error' : ''} />
        {error && <span>{error}</span>}
      </div>
    );
  }
);

Input.displayName = 'Input';

forwardRef の型引数は <参照先の型, Props の型> の順番です。

使用例

typescriptconst Form: React.FC = () => {
  const inputRef = useRef<HTMLInputElement>(null);

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

  return (
    <form>
      <Input ref={inputRef} label="名前" />
      <button type="button" onClick={focusFirstInput}>
        入力欄にフォーカス
      </button>
    </form>
  );
};

Hooks と Props 設計の比較まとめ

ここまでの内容を整理します。型推論に任せる場面と明示的に型を指定する場面の使い分けが重要です。

Hooks と Props における型指定の判断基準表

場面型推論に任せる明示的に型を指定判断基準
useState(プリミティブ)useState(0)N/A初期値から推論可能
useState(オブジェクト / null)N/AuseState<User | null>(null)推論が不完全
useState(空配列)N/AuseState<Item[]>([])never[] になる
PropsN/Ainterface で必ず定義契約として明示
useCallback単純な場合は推論複雑な場合は戻り値を明示可読性で判断
useMemo単純な場合は推論複雑な場合は戻り値を明示可読性で判断
useRef(DOM)N/AuseRef<HTMLElement>(null)要素の型が必要
useRef(値保持)N/AuseRef<T>(initialValue)保持する値の型が必要
カスタム Hooks 戻り値単純な場合は推論複雑な場合は interface で明示再利用性で判断
ジェネリックコンポーネントN/A<T> で型パラメータ必須推論できない

向いているケース / 向かないケース:

  • 型推論に任せるのが向いているケース:単純なプリミティブ値、ローカルで完結する状態
  • 明示的な型指定が向いているケース:null を含む可能性がある状態、配列の初期化、外部に公開する API(Props や Hooks の戻り値)、チーム開発で共有されるコード

まとめ

React と TypeScript で型安全なコンポーネントを書くための定石を整理しました。

Hooks の型推論は便利ですが、useState(null)useState([]) のように推論が不完全な場面では、明示的な型指定が必要です。Props のインターフェース設計では、必須と任意の区別を明確にし、デフォルト値がある場合は ? を使って任意プロパティにすることで、利用者にとってわかりやすい API になります。

状態管理では、Discriminated Union を使うことで、状態ごとにアクセス可能なプロパティを限定でき、実行時エラーを防げます。

型安全性と可読性のバランスを取りながら、チーム全体で一貫した書き方を共有することが、実務での生産性向上につながります。過剰な型定義は避け、「なぜこの型定義が必要なのか」を説明できる範囲に留めることをお勧めします。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;