404 Not Found

404 Not Found


nginx

TypeScript + React のベストプラクティス

トムチームが決済モジュールを開発していた最中、本番環境で深刻なバグが発生した。あるコンポーネントが userId を期待していたにもかかわらず、アップストリームから string が送信されたため、API リクエストが失敗したのだ。もしこのプロジェクトで TypeScript を使用していたなら、この型の不一致はコンパイル段階で検出されていたはずだ。トムは、こうした問題を早期に発見できるよう、プロジェクト全体で TypeScript を採用することを決めた。


1. 学習内容


2. 概念図

次の図は、ReactコンポーネントのデータフローにおいてTypeScriptによって行われる型チェックを示しています:

100%
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[] }

トムの支払いプロジェクトには、以下のデータフローが含まれています:

TEXT
Order List Page → PaymentCard Components → AmountInput → Submit API

TypeScript を使用しない場合、AmountInputonSubmit(value: number) を期待していましたが、一覧ページは onSubmit(value: string) を渡したため、API は 99.99 ではなく "99.99" を受け取り、バックエンドでシリアライズエラーが発生しました。

TypeScript では、コンポーネントのインターフェースでパラメータの型を明示的に宣言することで、これを非常に簡単に処理できます。型が一致しない場合は、npm run build フェーズでエラーが発生します。


(1) コンポーネントのプロパティの型定義

TypeScript でプロップを定義するには、主に interfacetype の 2 つの方法があります。これらをどちらを選ぶかについてのルールは次のとおりです:

方法 適用可能なシナリオ 機能
interface オブジェクト型の定義:プロパティ/ステート 宣言の統合がサポートされており、優れたパフォーマンスを発揮します
type ユニオン型、ツール型、タプル 柔軟性が高く、型間の組み合わせや条件付き型に対応

経験則: interface はプロパティや状態の定義に、type はユニオン型やユーティリティ型の定義に使用します。

基本的な小道具の定義

TSX
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属性(idclassNamearia-*など)を受け継ぐ必要があることがよくあります。ComponentPropsWithoutRefを使用することで、それらを継承することができます:

TSX
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'> には、onClickdisabledidclassNamestylearia-* といった、すべてのネイティブボタン属性が自動的に含まれます。...rest を使用すると、これらの属性がボタン要素に適用されます。個別に宣言する必要はありません。


(2) 汎用コンポーネント

汎用コンポーネントを使用すると、型安全性を維持しつつ、1つのコンポーネントで複数のデータ型を扱うことができます。最も一般的な使用例としては、リスト、テーブル、セレクターの各コンポーネントが挙げられます。

(2) ▶ サンプル:汎用リストコンポーネント

TSX
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>
      )}
    />
  )
}
▶ 試してみよう

ジェネリック医薬品の主な作用機序:

  1. ListProps<T> 内の T は、使用時に TypeScript によって自動的に推論される「型パラメータ」です。
  2. items={users}(型はUser[])が渡されると、renderItem内のitemは自動的に型Userになります。
  3. keyExtractoritemも自動的にUserに変更され、user.idを呼び出すと完全な型ヒントが提供されます。

(3) Reactのイベントの種類

React は、独自の複合イベントシステムを使用してネイティブの DOM イベントをラップします。各イベントタイプについて、正しい currentTarget タイプを取得するには、そのイベントがバインドされる HTML 要素のタイプを指定する必要があります。

イベントの種類 対応する要素 一般的なシナリオ
ChangeEvent<HTMLInputElement> 入力 / テキストエリア / セレクト フォーム入力
ChangeEvent<HTMLSelectElement> 選択 ドロップダウンメニュー
MouseEvent<HTMLButtonElement> ボタン / div クリック
FormEvent<HTMLFormElement> フォーム フォーム送信
KeyboardEvent<HTMLInputElement> 入力 キーボードショートカット
FocusEvent<HTMLInputElement> 入力 フォーカス/アウト・オブ・フォーカス

(3) ▶ サンプル:イベント種別検索フォームの記入例

TSX
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
▶ 試してみよう

イベントの種類に関する要点:

  1. React.ChangeEvent<HTMLInputElement> — ジェネリックパラメータは、イベントにバインドされた要素の型であり、これによって e.target および e.currentTarget の型が決まります。
  2. e.currentTarget はイベントがバインドされている要素(型安全)であり、e.target は実際にイベントをトリガーする要素(子要素である場合もある)です。
  3. KeyboardEvent から e.key への変換は文字列を返す。追加の型指定は必要ない。

(4) ▶ サンプル:カスタムフックの型注釈

カスタムフックでは、特にジェネリックを使用して呼び出し側がデータ型を指定できるようにする場合、完全な型注釈も必要です:

TSX
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)
}
▶ 試してみよう

フック型アノテーションの主要な原則:

  1. 戻り値を interface と定義し、各フィールドに JSDoc コメントを追加します(エディタが自動的に表示します)。
  2. useState<T | null> TypeScript に、data が null になる可能性があること、および使用時には null チェックを行う必要があることを通知する
  3. ジェネリックパラメータ <T> は呼び出し元から渡され、useFetch<UserProfile> によって useFetch 内のすべての T が UserProfile になります。

(4) 高度な型テクニック:条件付きプロパティと省略

条件付きプロパティのパターン

あるプロップの存在が別のプロップに依存している場合、「認識可能な和集合」パターンを使用できます:

TSX
// 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 を使用して、不要なネイティブプロパティを省略する

TSX
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) ▶ サンプル:汎用コンポーネント — 型安全なデータテーブル

TSX
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} />
}
▶ 試してみよう

❓ よくある質問

Q interfacetype のどちらを使うかはどう判断すればよいですか?
A 簡単なルールがあります: プロップやステートを定義するには interface を使用し(これらを一緒に宣言することでパフォーマンスが向上します)、ユニオン型、クロス型、ユーティリティ型(例:type Status = 'loading' | 'success' | 'error')を定義するには type を使用します。ほとんどのシナリオでは両者は互換性がありますが、チームプロジェクトでは、どちらか一方を主要な選択肢として標準化することをお勧めします。
Q React.FC なぜ推奨されなくなったのですか?
A React.FC(またはReact.FunctionComponent)にはデフォルトでchildrenプロパティが含まれていますが、実際の開発では多くのコンポーネントがchildrenを必要としないため、型付けが過度に緩くなってしまいます。さらに、React.FCはジェネリックコンポーネントをサポートしていません。現在のコミュニティにおけるベストプラクティスは、関数のパラメータに直接型アノテーションを付与し、React.FCを使用しないことです。
Q e.targete.currentTarget の違いは何ですか?
A e.currentTarget はイベントがバインドされている要素(型安全)であり、e.target は実際にイベントをトリガーする要素(子要素である場合もあります)です。たとえば、onChangeinputにバインドされている場合、正確な要素タイプを取得するには e.currentTarget is always that input, but e.target could be an element inside the input. In TypeScript, you can use e.currentTarget と記述します。
Q ジェネリックコンポーネントの型パラメータに特定のフィールドを必須とするにはどうすればよいですか?
A extends を使用してジェネリックパラメータを制約します。たとえば、<T extends { id: string | number }> を指定すると、T に id フィールドが含まれていることが保証されます。渡された型に id フィールドがない場合、TypeScript はエラーをスローします。
Q 引き続きReact.FCを使うべきでしょうか?
A Reactチームは、React.FC(つまりReact.FunctionComponent)の使用を推奨しなくなりました。その理由は以下の通りです:① コンポーネントに子要素が必要ない場合でも、暗黙的に children?: ReactNode が追加されてしまうこと;② ジェネリックの構文が煩雑であること(React.FC<Props> 対 単純な ({ prop }: Props) => JSX.Element);③ デフォルトエクスポートでは、パラメータ型を明示的に指定する場合に比べて型推論の精度が低下すること。関数のパラメータ型を明示的に指定することが推奨されます:function Comp({ name }: Props) {}

📖 まとめ


📝 練習問題

  1. TypeScript を使用して Table コンポーネントを作成する。これは、列の設定(columns: { key: keyof T, title: string })をサポートし、ソート機能を実装したジェネリックな <T extends { id: string | number }> である。
  2. TypeScript を使用してカスタム useLocalStorage<T> フックを作成する:読み取りおよび書き込み操作時の型安全性を確保し、デフォルト値をサポートし、JSON のシリアライズおよびデシリアライズを自動的に処理する。
  3. OmitComponentPropsWithoutRef を使用して PasswordInput コンポーネントを作成します。このコンポーネントは、すべてのネイティブ入力プロパティを継承しますが、type"password" に設定され、パスワードの表示/非表示を切り替えるための showToggle プロパティが追加されています。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%