TypeScript + React のベストプラクティス
トムチームが決済モジュールを開発していた最中、本番環境で深刻なバグが発生した。あるコンポーネントが
userIdを期待していたにもかかわらず、アップストリームからstringが送信されたため、API リクエストが失敗したのだ。もしこのプロジェクトで TypeScript を使用していたなら、この型の不一致はコンパイル段階で検出されていたはずだ。トムは、こうした問題を早期に発見できるよう、プロジェクト全体で TypeScript を採用することを決めた。
1. 学習内容
- プロップスの型定義(
interfaceとtypeのどちらを選ぶかに関する指針) - 汎用コンポーネント
<T>の仕組み - Reactのイベントタイプシステム(ChangeEvent / MouseEvent / KeyboardEvent)
- カスタムフックの型注釈を完了する
- HTML要素の属性の種類を拡張するためのヒント
2. 概念図
次の図は、ReactコンポーネントのデータフローにおいてTypeScriptによって行われる型チェックを示しています:
flowchart LR
subgraph Compilation Phase
A[Parent Component] -->|"Props Type Checking"| B[Child component]
B -->|"State Type Inference"| C[useState]
C -->|"Event Type Validation"| D["onChange / onClick"]
end
subgraph Runtime
E["Actual DOM Event"] --> F["Type Matching"]
F -->|"Through"| G[Execute as usual]
F -->|"Type mismatch"| H[Compilation error]
end
I["interface / type Definition"] --> A
J["Generic Parameters <T>"] --> B
K["React.ChangeEvent"] --> D
style A fill:#e3f2fd,stroke:#1565c0
style B fill:#e8f5e9,stroke:#2e7d32
style D fill:#fff3e0,stroke:#e65100
3. 実際の事例
| TypeScript のシナリオ | 解決策 | 重要な構文 |
|---|---|---|
| プロパティ型の定義 | 呼び出し時に不正な型が渡された | interface Props { name: string } |
| イベントの種類 | onChange パラメータ型の推論 | e: React.ChangeEvent<HTMLInputElement> |
| ジェネリックコンポーネント | リスト/テーブルのデータ型のパラメータ化 | <T> ジェネリックパラメータ |
| フックの種類 | useState/useRef の型推論 | useState<string[]> |
| APIの応答タイプ | インターフェースの戻り値の構造に関する制約 | interface ApiResponse { data: User[] } |
トムの支払いプロジェクトには、以下のデータフローが含まれています:
Order List Page → PaymentCard Components → AmountInput → Submit API
TypeScript を使用しない場合、AmountInput は onSubmit(value: number) を期待していましたが、一覧ページは onSubmit(value: string) を渡したため、API は 99.99 ではなく "99.99" を受け取り、バックエンドでシリアライズエラーが発生しました。
TypeScript では、コンポーネントのインターフェースでパラメータの型を明示的に宣言することで、これを非常に簡単に処理できます。型が一致しない場合は、npm run build フェーズでエラーが発生します。
(1) コンポーネントのプロパティの型定義
TypeScript でプロップを定義するには、主に interface と type の 2 つの方法があります。これらをどちらを選ぶかについてのルールは次のとおりです:
| 方法 | 適用可能なシナリオ | 機能 |
|---|---|---|
interface |
オブジェクト型の定義:プロパティ/ステート | 宣言の統合がサポートされており、優れたパフォーマンスを発揮します |
type |
ユニオン型、ツール型、タプル | 柔軟性が高く、型間の組み合わせや条件付き型に対応 |
経験則:
interfaceはプロパティや状態の定義に、typeはユニオン型やユーティリティ型の定義に使用します。
基本的な小道具の定義
interface ButtonProps {
/** Button Text */
label: string
/** Variant Styles */
variant?: 'primary' | 'danger' | 'default'
/** Button Size */
size?: 'small' | 'medium' | 'large'
/** Is it disabled? */
disabled?: boolean
/** Click to Callback */
onClick: () => void
/** Child elements(Icons on buttons, etc.) */
children?: React.ReactNode
}
function Button({
label,
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: size === 'small' ? '4px 12px' : size === 'large' ? '12px 28px' : '8px 20px',
background: variant === 'danger' ? '#ff4d4f' : variant === 'primary' ? '#1890ff' : '#f0f0f0',
color: variant === 'default' ? '#333' : '#fff',
border: 'none',
borderRadius: 6,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'all 0.2s',
}}
>
{children}
{label}
</button>
)
}
(1) ▶ サンプル:高度なプロパティタイプ — ネイティブの HTML 属性の拡張
実際の開発では、コンポーネントがネイティブのHTML属性(id、className、aria-*など)を受け継ぐ必要があることがよくあります。ComponentPropsWithoutRefを使用することで、それらを継承することができます:
import { ComponentPropsWithoutRef } from 'react'
// Method 1:Extend Native button Properties(Recommendations)
interface PrimaryButtonProps
extends ComponentPropsWithoutRef<'button'> {
/** Loading Status */
loading?: boolean
/** Icon Name */
icon?: string
}
function PrimaryButton({
loading,
icon,
children,
disabled,
...rest // Remaining Native button Properties
}: PrimaryButtonProps) {
return (
<button
{...rest}
disabled={disabled || loading}
style={{
padding: '8px 24px',
background: loading ? '#91d5ff' : '#1890ff',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: loading ? 'wait' : 'pointer',
}}
>
{loading ? 'Loading......' : icon ? `${icon} ${children}` : children}
</button>
)
}
// Usage — Both native properties and custom properties can be passed in
<PrimaryButton
id="submit-btn"
loading={isSubmitting}
icon=">"
onClick={() => submit()}
aria-label="Submit Form"
>
Submit
</PrimaryButton>
重要なポイント: ComponentPropsWithoutRef<'button'> には、onClick、disabled、id、className、style、aria-* といった、すべてのネイティブボタン属性が自動的に含まれます。...rest を使用すると、これらの属性がボタン要素に適用されます。個別に宣言する必要はありません。
(2) 汎用コンポーネント
汎用コンポーネントを使用すると、型安全性を維持しつつ、1つのコンポーネントで複数のデータ型を扱うことができます。最も一般的な使用例としては、リスト、テーブル、セレクターの各コンポーネントが挙げられます。
(2) ▶ サンプル:汎用リストコンポーネント
import { ReactNode } from 'react'
// Generic Interfaces — T For list item types
interface ListProps<T> {
/** Data Sources */
items: T[]
/** Render each item */
renderItem: (item: T, index: number) => ReactNode
/** The Only One key Extract Function */
keyExtractor: (item: T) => string | number
/** Placeholder text when the list is empty */
emptyText?: string
}
// Generic Components — <T,> Grammar(TS for JSX Compatible syntax)
function List<T>({
items,
renderItem,
keyExtractor,
emptyText = 'No data available',
}: ListProps<T>) {
if (items.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
{emptyText}
</div>
)
}
return (
<div>
{items.map((item, index) => (
<div key={keyExtractor(item)} style={{ marginBottom: 8 }}>
{renderItem(item, index)}
</div>
))}
</div>
)
}
// --- Examples of Use ---
interface User {
id: number
name: string
role: 'admin' | 'user'
}
const users: User[] = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'user' },
]
// Automatic Type Inference:List<User>
// items Automatically inferred as User[],renderItem 's item Automatically set to User
function UserList() {
return (
<List
items={users}
keyExtractor={user => user.id}
renderItem={(user, index) => (
<div
style={{
padding: '8px 16px',
background: index % 2 === 0 ? '#fafafa' : '#fff',
borderRadius: 4,
}}
>
<span style={{ fontWeight: 600 }}>{user.name}</span>
<span
style={{
marginLeft: 8,
color: user.role === 'admin' ? '#1890ff' : '#999',
fontSize: 12,
}}
>
{user.role}
</span>
</div>
)}
/>
)
}
ジェネリック医薬品の主な作用機序:
ListProps<T>内のTは、使用時に TypeScript によって自動的に推論される「型パラメータ」です。items={users}(型はUser[])が渡されると、renderItem内のitemは自動的に型Userになります。keyExtractorのitemも自動的にUserに変更され、user.idを呼び出すと完全な型ヒントが提供されます。
(3) Reactのイベントの種類
React は、独自の複合イベントシステムを使用してネイティブの DOM イベントをラップします。各イベントタイプについて、正しい currentTarget タイプを取得するには、そのイベントがバインドされる HTML 要素のタイプを指定する必要があります。
| イベントの種類 | 対応する要素 | 一般的なシナリオ |
|---|---|---|
ChangeEvent<HTMLInputElement> |
入力 / テキストエリア / セレクト | フォーム入力 |
ChangeEvent<HTMLSelectElement> |
選択 | ドロップダウンメニュー |
MouseEvent<HTMLButtonElement> |
ボタン / div | クリック |
FormEvent<HTMLFormElement> |
フォーム | フォーム送信 |
KeyboardEvent<HTMLInputElement> |
入力 | キーボードショートカット |
FocusEvent<HTMLInputElement> |
入力 | フォーカス/アウト・オブ・フォーカス |
(3) ▶ サンプル:イベント種別検索フォームの記入例
import { useState } from 'react'
interface SearchFormProps {
/** Search Callback */
onSearch: (query: string) => Promise<void>
/** Placeholder text */
placeholder?: string
}
function SearchForm({ onSearch, placeholder = 'Search...' }: SearchFormProps) {
const [query, setQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
// ChangeEvent<HTMLInputElement> — input Value Changes
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value)
}
// KeyboardEvent<HTMLInputElement> — Keyboard Events
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Escape') {
e.currentTarget.blur() // currentTarget is HTMLInputElement
setQuery('')
}
}
// FormEvent<HTMLFormElement> — Form Submission
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!query.trim()) return
setIsSearching(true)
try {
await onSearch(query.trim())
} finally {
setIsSearching(false)
}
}
// MouseEvent<HTMLButtonElement> — Click the Clear button
function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation() // Prevent Event Bubbling
setQuery('')
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
<div style={{ position: 'relative', flex: 1 }}>
<input
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #d9d9d9',
borderRadius: 6,
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
}}
/>
{query && (
<button
type="button"
onClick={handleClear}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
border: 'none',
background: 'none',
cursor: 'pointer',
color: '#999',
}}
>
x
</button>
)}
</div>
<button
type="submit"
disabled={isSearching || !query.trim()}
style={{
padding: '8px 20px',
background: isSearching ? '#91d5ff' : '#1890ff',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: isSearching ? 'wait' : 'pointer',
fontSize: 14,
}}
>
{isSearching ? 'Searching......' : 'Search'}
</button>
</form>
)
}
export default SearchForm
イベントの種類に関する要点:
React.ChangeEvent<HTMLInputElement>— ジェネリックパラメータは、イベントにバインドされた要素の型であり、これによってe.targetおよびe.currentTargetの型が決まります。e.currentTargetはイベントがバインドされている要素(型安全)であり、e.targetは実際にイベントをトリガーする要素(子要素である場合もある)です。KeyboardEventからe.keyへの変換は文字列を返す。追加の型指定は必要ない。
(4) ▶ サンプル:カスタムフックの型注釈
カスタムフックでは、特にジェネリックを使用して呼び出し側がデータ型を指定できるようにする場合、完全な型注釈も必要です:
import { useState, useEffect, useCallback } from 'react'
// Definition Hook Interface for Return Values
interface UseFetchResult<T> {
/** Return Data */
data: T | null
/** Loading... */
loading: boolean
/** Error Message */
error: string | null
/** Manually Resend Request */
refetch: () => void
}
// Generics Hook — Specified by the caller T Type
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}: ${response.statusText}`)
}
const json: T = await response.json()
setData(json)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
fetchData()
}, [fetchData])
return { data, loading, error, refetch: fetchData }
}
// --- Usage —— Type annotations are easy to understand at a glance ---
interface UserProfile {
id: number
name: string
email: string
avatar: string
}
function ProfilePage({ userId }: { userId: number }) {
// data Automatically inferred as UserProfile | null
const { data: user, loading, error, refetch } =
useFetch<UserProfile>(`/api/users/${userId}`)
if (loading) return <div>Loading......</div>
if (error) return <div style={{ color: 'red' }}>Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<img src={user.avatar} alt={user.name} width={64} />
<h2>{user.name}</h2>
<p>{user.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
)
// ✅ user.name / user.email / user.avatar All have type hints
// ❌ user.phone Compilation errors occur(UserProfile Not included phone)
}
フック型アノテーションの主要な原則:
- 戻り値を
interfaceと定義し、各フィールドに JSDoc コメントを追加します(エディタが自動的に表示します)。 useState<T | null>TypeScript に、dataが null になる可能性があること、および使用時には null チェックを行う必要があることを通知する- ジェネリックパラメータ
<T>は呼び出し元から渡され、useFetch<UserProfile>によってuseFetch内のすべての T がUserProfileになります。
(4) 高度な型テクニック:条件付きプロパティと省略
条件付きプロパティのパターン
あるプロップの存在が別のプロップに依存している場合、「認識可能な和集合」パターンを使用できます:
// Regular Button vs Link Button — variant as 'link' Must Be Passed On href
type ButtonVariant =
| { variant: 'primary' | 'danger' | 'default' }
| { variant: 'link'; href: string; target?: '_blank' | '_self' }
interface SmartButtonProps {
label: string
} & ButtonVariant
function SmartButton(props: SmartButtonProps) {
if (props.variant === 'link') {
// Here props.href Type-safe existence
return <a href={props.href} target={props.target}>{props.label}</a>
}
return <button>{props.label}</button>
}
Omit を使用して、不要なネイティブプロパティを省略する
import { ComponentPropsWithoutRef } from 'react'
// Custom Input Components,Pass-through is not allowed type(Force to text)
type CustomInputProps = Omit<
ComponentPropsWithoutRef<'input'>,
'type'
> & {
label: string
}
function CustomInput({ label, ...inputProps }: CustomInputProps) {
return (
<label>
{label}
<input type="text" {...inputProps} />
</label>
)
}
(5) ▶ サンプル:汎用コンポーネント — 型安全なデータテーブル
interface Column<T> {
key: keyof T & string
title: string
render?: (value: T[keyof T], row: T) => React.ReactNode
}
function DataTable<T extends Record<string, any>>({ data, columns }: { data: T[]; columns: Column<T>[] }) {
return (
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} style={{ border: '1px solid #ddd', padding: 8, textAlign: 'left', background: '#f5f5f5' }}>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.key} style={{ border: '1px solid #ddd', padding: 8 }}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
interface User {
id: number
name: string
email: string
active: boolean
}
function UserTable() {
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@test.com', active: true },
{ id: 2, name: 'Bob', email: 'bob@test.com', active: false },
]
const columns: Column<User>[] = [
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
{ key: 'active', title: 'Status', render: (v) => (
<span style={{ color: v ? '#52c41a' : '#999' }}>{v ? 'Active' : 'Inactive'}</span>
)},
]
return <DataTable data={users} columns={columns} />
}
❓ よくある質問
interface と type のどちらを使うかはどう判断すればよいですか?interface を使用し(これらを一緒に宣言することでパフォーマンスが向上します)、ユニオン型、クロス型、ユーティリティ型(例:type Status = 'loading' | 'success' | 'error')を定義するには type を使用します。ほとんどのシナリオでは両者は互換性がありますが、チームプロジェクトでは、どちらか一方を主要な選択肢として標準化することをお勧めします。React.FC なぜ推奨されなくなったのですか?React.FC(またはReact.FunctionComponent)にはデフォルトでchildrenプロパティが含まれていますが、実際の開発では多くのコンポーネントがchildrenを必要としないため、型付けが過度に緩くなってしまいます。さらに、React.FCはジェネリックコンポーネントをサポートしていません。現在のコミュニティにおけるベストプラクティスは、関数のパラメータに直接型アノテーションを付与し、React.FCを使用しないことです。e.target と e.currentTarget の違いは何ですか?e.currentTarget はイベントがバインドされている要素(型安全)であり、e.target は実際にイベントをトリガーする要素(子要素である場合もあります)です。たとえば、onChangeがinputにバインドされている場合、正確な要素タイプを取得するには e.currentTarget is always that input, but e.target could be an element inside the input. In TypeScript, you can use e.currentTarget と記述します。extends を使用してジェネリックパラメータを制約します。たとえば、<T extends { id: string | number }> を指定すると、T に id フィールドが含まれていることが保証されます。渡された型に id フィールドがない場合、TypeScript はエラーをスローします。React.FC(つまりReact.FunctionComponent)の使用を推奨しなくなりました。その理由は以下の通りです:① コンポーネントに子要素が必要ない場合でも、暗黙的に children?: ReactNode が追加されてしまうこと;② ジェネリックの構文が煩雑であること(React.FC<Props> 対 単純な ({ prop }: Props) => JSX.Element);③ デフォルトエクスポートでは、パラメータ型を明示的に指定する場合に比べて型推論の精度が低下すること。関数のパラメータ型を明示的に指定することが推奨されます:function Comp({ name }: Props) {}。📖 まとめ
propsタイプは、ネイティブの HTML 属性を拡張するinterface, andComponentPropsWithoutRefを使用して定義されています。- 汎用コンポーネント
<T>を使用すると、リストやテーブルなどのコンテナコンポーネントが、型安全性を維持したまま複数のデータ型を扱うことが可能になります。 - Reactのイベント型はジェネリックです:
ChangeEvent<T>、MouseEvent<T>。ここで、Tは要素型です。 - カスタムフックでは、型推論をサポートするためにジェネリックな戻り値型を使用します。
interfaceを使用して、戻り値型の構造を定義してください - プロップ(識別可能な和)と
Omitは、コンポーネントのインターフェースを精密に制御するために用いられる高度な型技術である
📝 練習問題
- TypeScript を使用して
Tableコンポーネントを作成する。これは、列の設定(columns:{ key: keyof T, title: string })をサポートし、ソート機能を実装したジェネリックな<T extends { id: string | number }>である。 - TypeScript を使用してカスタム
useLocalStorage<T>フックを作成する:読み取りおよび書き込み操作時の型安全性を確保し、デフォルト値をサポートし、JSON のシリアライズおよびデシリアライズを自動的に処理する。 OmitとComponentPropsWithoutRefを使用してPasswordInputコンポーネントを作成します。このコンポーネントは、すべてのネイティブ入力プロパティを継承しますが、typeは"password"に設定され、パスワードの表示/非表示を切り替えるためのshowToggleプロパティが追加されています。



