Back

React & TypeScript: よりクリーンなコードのための一般的なパターン

React & TypeScript: よりクリーンなコードのための一般的なパターン

TypeScriptを使用してReactアプリケーションを構築する際、同じ課題に繰り返し遭遇するでしょう:propsを正しく型付けする方法、イベントを安全に処理する方法、そして最大限の再利用性を持つコンポーネントを構造化する方法です。これらのパターンは単にコンパイラを満足させるためだけのものではありません。理解しやすく、リファクタリングしやすく、保守しやすいコードを書くためのものです。

この記事では、日常的に使用する最も実用的なReact TypeScriptの一般的なパターンを扱います。コンポーネントを効果的に型付けする方法、オプショナルpropsを処理する方法、イベントとrefsを扱う方法、ユーティリティ型を活用する方法、そしてreducerで判別共用体を使用する方法を学びます。各パターンには、なぜそれがコードを改善するのかを正確に示す焦点を絞った例が含まれています。

重要なポイント

  • propsとstateを明示的に型付けし、インターフェースや型を使用してより良いIntelliSenseとエラー検出を実現
  • オプショナルとデフォルトpropsをTypeScriptのオプショナルプロパティとデフォルトパラメータで処理
  • ユーティリティ型PickComponentPropsなど)を使用して重複を減らし、一貫性を維持
  • イベントとrefsを適切に型付けして型安全なDOM操作を実現
  • 判別共用体をreducerで活用して包括的なアクション処理を実現
  • forwardRefでコンポーネントを合成し、refsを渡す際の型安全性を維持

PropsとStateの型付け

React TypeScriptパターンの基盤は、コンポーネントのpropsを適切に型付けすることから始まります。これによりコンパイル時にエラーを捕捉し、エディタでより良い自動補完を提供します。

基本的なPropsパターン

interface ButtonProps {
  label: string
  onClick: () => void
  disabled?: boolean
}

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

このパターンにより、コンポーネントが正しいデータ型を受け取ることが保証されます。インターフェースはコンポーネントが期待するpropsを明確に文書化し、自己文書化されたものにします。

useStateでのState型付け

TypeScriptは通常、state型を正しく推論しますが、複雑なstateは明示的な型付けの恩恵を受けます:

import { useState } from 'react'

interface User {
  id: string
  name: string
  email: string
}

function UserProfile() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(false) // 型はbooleanとして推論される

  // TypeScriptはuserがnullの可能性があることを認識している
  return user ? <div>{user.name}</div> : <div>Loading...</div>
}

オプショナルとデフォルトPropsの処理

Reactコンポーネントには、オプショナルな設定propsがよくあります。TypeScriptのオプショナルプロパティとデフォルトパラメータを組み合わせることで、クリーンなパターンが作成されます:

interface CardProps {
  title: string
  description?: string
  variant?: 'primary' | 'secondary'
  className?: string
}

function Card({ 
  title, 
  description, 
  variant = 'primary',
  className = ''
}: CardProps) {
  return (
    <div className={`card card-${variant} ${className}`}>
      <h2>{title}</h2>
      {description && <p>{description}</p>}
    </div>
  )
}

このパターンにより、型安全性を維持しながらコンポーネントを柔軟にします。必須propsは明確で、オプショナルpropsには適切なデフォルト値があります。

イベントとRefsの型付け

イベントハンドラーとrefsは、TypeScriptエラーの一般的な原因です。これらのパターンは推測を排除します:

イベントハンドラーパターン

import { useState, ChangeEvent, FormEvent } from 'react'

interface FormProps {
  onSubmit: (data: { email: string; password: string }) => void
}

function LoginForm({ onSubmit }: FormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value)
  }

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    onSubmit({ email, password })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={handleEmailChange} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit">Login</button>
    </form>
  )
}

Refパターン

import { useRef, useEffect } from 'react'

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    // TypeScriptはinputRef.currentがnullの可能性があることを認識している
    inputRef.current?.focus()
  }, [])

  return <input ref={inputRef} type="text" />
}

ユーティリティ型の使用

TypeScriptのユーティリティ型は、コードの重複を減らし、アプリケーション全体で型の同期を保ちます。

ComponentPropsパターン

型定義を繰り返すことなく、既存のコンポーネントからpropsを抽出します:

import { ComponentProps } from 'react'

// button要素が受け入れるすべてのpropsを取得
type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary'
}

function StyledButton({ variant = 'primary', ...props }: ButtonProps) {
  return <button className={`btn-${variant}`} {...props} />
}

// 独自のコンポーネントからpropsを抽出
type MyButtonProps = ComponentProps<typeof StyledButton>

Pickパターン

コンポーネントを拡張する際に特定のpropsを選択します:

interface UserData {
  id: string
  name: string
  email: string
  role: string
  lastLogin: Date
}

// カードには一部のプロパティのみが必要
type UserCardProps = Pick<UserData, 'name' | 'email'> & {
  onClick?: () => void
}

function UserCard({ name, email, onClick }: UserCardProps) {
  return (
    <div onClick={onClick}>
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  )
}

forwardRefでのコンポーネント合成

コンポーネントライブラリを構築する際やネイティブ要素をラップする際、forwardRefはrefsを公開しながら型安全性を維持します:

import { forwardRef, useRef, InputHTMLAttributes } from 'react'

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: string
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, ...props }, ref) => {
    return (
      <div>
        <label>{label}</label>
        <input ref={ref} {...props} />
        {error && <span className="error">{error}</span>}
      </div>
    )
  }
)

Input.displayName = 'Input'

// 使用時も完全な型安全性を維持
function Form() {
  const inputRef = useRef<HTMLInputElement>(null)
  
  return <Input ref={inputRef} label="Email" type="email" />
}

Reducerでの判別共用体

判別共用体により、reducerアクションが型安全になり、包括的チェックが可能になります:

interface TodoState {
  items: Array<{ id: string; text: string; done: boolean }>
  filter: 'all' | 'active' | 'completed'
}

type TodoAction =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string }
  | { type: 'SET_FILTER'; payload: TodoState['filter'] }
  | { type: 'CLEAR_COMPLETED' }

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        items: [...state.items, {
          id: Date.now().toString(),
          text: action.payload,
          done: false
        }]
      }
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload
            ? { ...item, done: !item.done }
            : item
        )
      }
    
    case 'SET_FILTER':
      return { ...state, filter: action.payload }
    
    case 'CLEAR_COMPLETED':
      return {
        ...state,
        items: state.items.filter(item => !item.done)
      }
    
    default:
      // TypeScriptはすべてのケースが処理されることを保証
      const exhaustive: never = action
      return state
  }
}

このパターンにより、すべてのアクション型を確実に処理できます。新しいアクション型を追加した場合、reducerで処理するまでTypeScriptがエラーを出します。

React 19の更新

React 19では、Actions(useActionStateuseFormStatus)、promiseやcontextを消費するためのuse() API、Server Componentsのファーストクラスサポートなどの新機能により、型安全性がさらに重要になります。Actionsを型付けする際は、FormDataの形状とアクションの戻り値を明示的に定義して、サーバーとクライアントのコードが同期を保つようにします。forwardRefから標準propとしてrefを渡すことへの今後の移行も型付けを簡素化します。refを他のpropと同様に扱い、TypeScriptがコンポーネント境界での正確性を保証するようにします。

まとめ

これらのReact TypeScriptの一般的なパターンは、型安全なReactアプリケーションの基盤を形成します。propsとstateを明示的に型付けし、イベントとrefsを正しく処理し、TypeScriptのユーティリティ型を活用することで、より安全で保守しやすいコードを作成できます。パターンは連携して動作します。適切なprops型付けにより、より良いコンポーネント合成が可能になり、判別共用体により複雑なstate論理での包括的処理が保証されます。

基本的なprops型付けから始めて、アプリケーションが成長するにつれてユーティリティ型と高度なパターンを徐々に取り入れてください。適切な型付けへの投資は、より良いIDE支援、より少ないランタイムエラー、より明確なコンポーネント契約を通じて報われます。

よくある質問

後で拡張や宣言のマージが必要になる可能性がある場合はinterfaceを使用します。共用体、交差型、またはユーティリティ型を扱う場合はtypeを使用します。基本的なpropsではどちらでも問題ありませんので、一つのアプローチを選んで一貫性を保ってください。

最大限の柔軟性を得るためにReactNodeを使用してください:children: ReactNode。これは文字列、数値、要素、フラグメント、配列を受け入れます。より具体的なケースでは、ReactElementまたは特定の要素型を使用してください。

新しいpropsを追加しながら元のコンポーネントのprop型を保持するジェネリック関数を使用してください。ComponentPropsユーティリティ型は、重複なしに既存のコンポーネント型を抽出・拡張するのに役立ちます。

関数パラメータ、エクスポートされた関数の戻り値型、複雑なstateは明示的に型付けしてください。プリミティブでのuseStateや明らかなイベントハンドラーなどの単純なケースでは、冗長性を減らすためにTypeScriptの推論に任せてください。

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers