TypeScriptの型安全なReactコンポーネント設計 - 実践ガイド

TypeScriptReact設計ベストプラクティス

TypeScriptの型システムを活用して、保守性とパフォーマンスを両立したReactコンポーネントを設計する実践的な方法を紹介します。

TypeScriptの型安全なReactコンポーネント設計 - 実践ガイド

概要

TypeScriptとReactを組み合わせることで、型安全で保守性の高いコンポーネントを作成できます。この記事では、実践的なパターンとベストプラクティスを紹介します。

基本的なPropsの型定義

1. シンプルなコンポーネント

interface ButtonProps {
  variant: 'primary' | 'secondary' | 'danger'
  size: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
  onClick: () => void
  children: React.ReactNode
}

export const Button: React.FC<ButtonProps> = ({
  variant,
  size,
  disabled = false,
  loading = false,
  onClick,
  children
}) => {
  return (
    <button
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled || loading}
      onClick={onClick}
    >
      {loading ? '読み込み中...' : children}
    </button>
  )
}

2. 条件付きPropsの型定義

interface BaseCardProps {
  title: string
  children: React.ReactNode
}

interface ClickableCardProps extends BaseCardProps {
  onClick: () => void
  isClickable: true
}

interface StaticCardProps extends BaseCardProps {
  isClickable?: false
}

type CardProps = ClickableCardProps | StaticCardProps

export const Card: React.FC<CardProps> = (props) => {
  const { title, children, isClickable, onClick } = props
  
  if (isClickable) {
    return (
      <div className="card clickable" onClick={onClick}>
        <h3>{title}</h3>
        {children}
      </div>
    )
  }
  
  return (
    <div className="card">
      <h3>{title}</h3>
      {children}
    </div>
  )
}

型安全なイベントハンドリング

フォームイベント

interface FormData {
  email: string
  password: string
}

const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
  event.preventDefault()
  const formData = new FormData(event.currentTarget)
  
  const data: FormData = {
    email: formData.get('email') as string,
    password: formData.get('password') as string
  }
  
  // バリデーション
  if (!data.email || !data.password) {
    console.error('必須項目が入力されていません')
    return
  }
  
  // API呼び出し
  submitForm(data)
}

入力イベント

const handleInputChange = (
  event: React.ChangeEvent<HTMLInputElement>
) => {
  const { name, value, type, checked } = event.target
  
  if (type === 'checkbox') {
    setFormData(prev => ({
      ...prev,
      [name]: checked
    }))
  } else {
    setFormData(prev => ({
      ...prev,
      [name]: value
    }))
  }
}

ジェネリクスを活用した再利用可能コンポーネント

汎用リストコンポーネント

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

function List<T>({ 
  items, 
  renderItem, 
  keyExtractor, 
  emptyMessage = 'データがありません',
  loading = false 
}: ListProps<T>) {
  if (loading) {
    return <div>読み込み中...</div>
  }
  
  if (items.length === 0) {
    return <div>{emptyMessage}</div>
  }
  
  return (
    <ul className="list">
      {items.map((item, index) => (
        <li key={keyExtractor(item, index)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  )
}

// 使用例
interface User {
  id: number
  name: string
  email: string
}

const UserList: React.FC<{ users: User[] }> = ({ users }) => (
  <List
    items={users}
    keyExtractor={(user) => user.id}
    renderItem={(user) => (
      <div>
        <h4>{user.name}</h4>
        <p>{user.email}</p>
      </div>
    )}
  />
)

汎用フォームコンポーネント

interface FieldConfig<T> {
  name: keyof T
  label: string
  type: 'text' | 'email' | 'password' | 'number'
  required?: boolean
  validation?: (value: any) => string | null
}

interface FormProps<T> {
  fields: FieldConfig<T>[]
  onSubmit: (data: T) => void
  initialData?: Partial<T>
  submitLabel?: string
}

function Form<T extends Record<string, any>>({
  fields,
  onSubmit,
  initialData = {},
  submitLabel = '送信'
}: FormProps<T>) {
  const [formData, setFormData] = useState<T>(initialData as T)
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
  
  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault()
    
    // バリデーション
    const newErrors: Partial<Record<keyof T, string>> = {}
    fields.forEach(field => {
      if (field.required && !formData[field.name]) {
        newErrors[field.name] = `${field.label}は必須です`
      } else if (field.validation) {
        const error = field.validation(formData[field.name])
        if (error) {
          newErrors[field.name] = error
        }
      }
    })
    
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }
    
    onSubmit(formData)
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {fields.map(field => (
        <div key={String(field.name)}>
          <label>{field.label}</label>
          <input
            type={field.type}
            name={String(field.name)}
            value={formData[field.name] || ''}
            onChange={(e) => setFormData(prev => ({
              ...prev,
              [field.name]: e.target.value
            }))}
          />
          {errors[field.name] && (
            <span className="error">{errors[field.name]}</span>
          )}
        </div>
      ))}
      <button type="submit">{submitLabel}</button>
    </form>
  )
}

パフォーマンス最適化

React.memoとuseCallback

interface ExpensiveComponentProps {
  data: string[]
  onItemClick: (index: number) => void
}

const ExpensiveComponent = React.memo<ExpensiveComponentProps>(({
  data,
  onItemClick
}) => {
  return (
    <div>
      {data.map((item, index) => (
        <div key={index} onClick={() => onItemClick(index)}>
          {item}
        </div>
      ))}
    </div>
  )
})

// 親コンポーネント
const ParentComponent: React.FC = () => {
  const [data, setData] = useState<string[]>([])
  
  const handleItemClick = useCallback((index: number) => {
    console.log(`アイテム ${index} がクリックされました`)
  }, [])
  
  return (
    <ExpensiveComponent
      data={data}
      onItemClick={handleItemClick}
    />
  )
}

カスタムフックの型定義

interface UseApiState<T> {
  data: T | null
  loading: boolean
  error: string | null
}

interface UseApiActions<T> {
  fetch: () => Promise<void>
  reset: () => void
}

function useApi<T>(
  apiCall: () => Promise<T>
): UseApiState<T> & UseApiActions<T> {
  const [state, setState] = useState<UseApiState<T>>({
    data: null,
    loading: false,
    error: null
  })
  
  const fetch = useCallback(async () => {
    setState(prev => ({ ...prev, loading: true, error: null }))
    try {
      const data = await apiCall()
      setState({ data, loading: false, error: null })
    } catch (error) {
      setState({
        data: null,
        loading: false,
        error: error instanceof Error ? error.message : 'エラーが発生しました'
      })
    }
  }, [apiCall])
  
  const reset = useCallback(() => {
    setState({ data: null, loading: false, error: null })
  }, [])
  
  return { ...state, fetch, reset }
}

エラーハンドリングとバリデーション

エラーバウンダリ

interface ErrorBoundaryState {
  hasError: boolean
  error?: Error
}

class ErrorBoundary extends React.Component<
  React.PropsWithChildren<{}>,
  ErrorBoundaryState
> {
  constructor(props: React.PropsWithChildren<{}>) {
    super(props)
    this.state = { hasError: false }
  }
  
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('エラーが発生しました:', error, errorInfo)
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>エラーが発生しました</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            再試行
          </button>
        </div>
      )
    }
    
    return this.props.children
  }
}

バリデーション関数

type ValidationRule<T> = (value: T) => string | null

const createValidator = <T extends Record<string, any>>(
  rules: Record<keyof T, ValidationRule<any>[]
) => {
  return (data: T): Partial<Record<keyof T, string>> => {
    const errors: Partial<Record<keyof T, string>> = {}
    
    Object.keys(rules).forEach(key => {
      const fieldRules = rules[key as keyof T]
      const value = data[key as keyof T]
      
      for (const rule of fieldRules) {
        const error = rule(value)
        if (error) {
          errors[key as keyof T] = error
          break
        }
      }
    })
    
    return errors
  }
}

// 使用例
interface UserForm {
  name: string
  email: string
  age: number
}

const userValidator = createValidator<UserForm>({
  name: [
    (value) => !value ? '名前は必須です' : null,
    (value) => value.length < 2 ? '名前は2文字以上で入力してください' : null
  ],
  email: [
    (value) => !value ? 'メールアドレスは必須です' : null,
    (value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '有効なメールアドレスを入力してください' : null
  ],
  age: [
    (value) => value < 0 ? '年齢は0以上で入力してください' : null,
    (value) => value > 150 ? '有効な年齢を入力してください' : null
  ]
})

実践的な例:モーダルコンポーネント

interface ModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
  size?: 'sm' | 'md' | 'lg'
}

export const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  title,
  children,
  size = 'md'
}) => {
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden'
    } else {
      document.body.style.overflow = 'unset'
    }
    
    return () => {
      document.body.style.overflow = 'unset'
    }
  }, [isOpen])
  
  if (!isOpen) return null
  
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div 
        className={`modal modal-${size}`}
        onClick={(e) => e.stopPropagation()}
      >
        <div className="modal-header">
          <h2>{title}</h2>
          <button onClick={onClose}>&times;</button>
        </div>
        <div className="modal-body">
          {children}
        </div>
      </div>
    </div>
  )
}

まとめ

TypeScriptの型システムを適切に活用することで、以下のメリットが得られます:

  1. 開発時のエラー検出: コンパイル時に多くのバグを発見できます
  2. コードの可読性向上: 型定義により、コンポーネントの使用方法が明確になります
  3. リファクタリングの安全性: 型チェックにより、安全なコード変更が可能です
  4. チーム開発の効率化: 型定義により、APIの使用方法が明確になります

これらのパターンを組み合わせることで、保守性と拡張性に優れたReactアプリケーションを構築できます。